Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

Notable changes per release. Tagged commits use `vX.Y.Z` shortform.

## Unreleased

- New: Remote Workspaces now support Mosh as a first-class transport alongside SSH, including per-tab sessions, UDP port/prediction controls, a seven-day orphan timeout, remote Agent launch, remote cwd, and explicit SSH fallback.
- New: Mosh terminals use an independent SSH control plane for replayable Agent state and uploads. TCP/auth failures show connected/stale/auth-required status without stopping or relaunching the Mosh terminal; interactive credentials stay inside an OpenSSH PTY.
- Safety: remote runtimes use private token-scoped directories, bounded protocols, atomic snapshots, explicit identity-verified cleanup, and crash-persisted best-effort reap leases. Network silence never triggers a kill.
- Changed: File → New SSH Workspace… is now New Remote Workspace…, with SSH and Mosh choices. New state remains backward-compatible by dual-writing Mosh destinations for older Kooky releases, which safely downgrade them to SSH.

## v0.47.0 — 2026-07-31

- New: native Simplified Chinese localization across kooky's settings, menus, sheets, popovers, notifications, status bar, command palette, and other app chrome; English remains the development language.
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ A minimal modern terminal built for AI coding. Sidebar workspaces; horizontal /

**Git worktrees.** Right-click any git workspace → "Create Worktree…" to spin one up on a new branch (or check out an existing one). Each worktree shows up nested under its source repo in the sidebar with its own tabs + agent — let Claude work on a feature branch without touching what's running on main. Worktrees you create from the command line show up automatically the next time you launch kooky.

**SSH workspaces.** File → New SSH Workspace… (or ⌘P) creates a workspace that lives on a remote machine: every new tab, split, and restored tab reconnects to the same host on its own. Agent tabs start their agent on the remote — with the remote's own shell setup loaded, so tools installed through nvm and friends are found. Paste a local file or screenshot and kooky uploads it first, then pastes a path the remote agent can actually open. Connections to the same host are shared: extra tabs attach instantly, and password-authenticated hosts work throughout, pasting included.
**SSH and Mosh workspaces.** File → New Remote Workspace… (or ⌘P) creates a workspace that lives on a remote machine. Choose SSH for a conventional connection, or Mosh for a responsive terminal that survives latency spikes, sleep, roaming, and short outages. Every new tab and split gets its own remote session; restored tabs establish fresh sessions. Agent tabs start their agent after the remote shell setup loads. Mosh uses a separate, non-blocking SSH control channel for reliable agent state, remote cwd, cleanup, and uploads: if that channel drops the terminal keeps working and the status pill says stale, and password/OTP/hardware-key authentication is handled by OpenSSH in an explicit in-app terminal. Paste a local file or screenshot and kooky uploads it first, then inserts the remote path. Mosh must be installed locally and `mosh-server` must be available on the host.

Mosh also requires the server's UDP range to be reachable (use Automatic or configure the same range in your firewall). App restart creates a fresh session rather than reattaching the old one; explicitly closing a tab/workspace terminates the Kooky-owned remote runtime, while crash recovery reaps only a runtime whose token and process identity can be proven. Older Kooky versions safely open a saved Mosh workspace as SSH. Kooky deliberately does not discover or take ownership of sessions inside tmux/zellij/ET.

**Keep-awake.** Your Mac won't fall asleep under a working agent. A breathing status light in the top bar cycles three notches: Off; Auto — awake while an agent works or an SSH session is live, lid closed included (one-time admin authorization), asleep again the moment the work ends; and Always — a caffeinate you can see, awake until you switch it down. Flip sleep-disable anywhere else (`sudo pmset`, another tool) and the dial follows within seconds, in both directions.

Expand Down
4 changes: 3 additions & 1 deletion README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@

**Git worktree。** 右键任意 git workspace → "Create Worktree…",在新 branch 上(或 checkout 已有 branch)起一个 worktree。Worktree 在 sidebar 里缩进显示在源 repo 下面,有自己的 tab + agent —— 让 Claude 在 feature branch 上跑活,不打扰 main 上正在跑的进程。命令行 `git worktree add` 建的 worktree,下次启动 kooky 也会自动出现在 sidebar。

**SSH workspace。** File → New SSH Workspace…(或 ⌘P)创建一个"住"在远程机器上的 workspace:之后每个新 tab、分屏、重启恢复的 tab 都自动重连同一台主机。开 agent tab 时 agent 直接在远端启动 —— 远端自己的 shell 配置加载完才启动,nvm 装的工具都找得到。往里粘贴本地文件或截图时,kooky 先上传再粘贴远端路径,对面的 agent 才真的打得开。同一主机的连接是共享的:后续 tab 秒连,密码登录的主机也全程可用,包括粘贴上传。
**SSH 与 Mosh workspace。** File → New Remote Workspace…(或 ⌘P)创建一个“住”在远程机器上的 workspace。SSH 提供传统连接;Mosh 在延迟波动、合盖、网络切换和短时断网后仍保持终端响应。每个新 tab 和分屏拥有独立远端会话,重启恢复时建立新会话。Agent 会在远端 shell 配置加载后启动。Mosh 另用一条不阻塞终端的 SSH 控制通道同步 Agent 状态、远端 cwd、清理和上传;控制通道断开时终端继续工作,状态栏明确显示 stale,密码、OTP 与硬件密钥认证由内嵌 OpenSSH 终端处理。粘贴本地文件或截图时,kooky 先上传再插入远端路径。使用 Mosh 前需在 Mac 安装 `mosh`,并在服务器提供 `mosh-server`。

服务器还必须放通 Mosh 使用的 UDP 端口范围(建议选择 Automatic,或让防火墙与设置中的范围一致)。应用重启会建立新会话,不会 reattach 旧会话;明确关闭 tab/workspace 会终止 Kooky 拥有的远端 runtime,crash 恢复也只有在 token 与进程身份都能被证明时才回收。旧版 Kooky 会把保存的 Mosh workspace 安全降级为 SSH。Kooky 明确不探测、也不接管 tmux/zellij/ET 中的 session。

**防睡眠(keep-awake)。** agent 干活时 Mac 不会睡过去。顶部一颗会呼吸的指示灯,点击在三档间循环:Off;Auto —— agent 干活或 SSH 连接期间保持清醒,合盖也不睡(首次需一次管理员授权),活一干完就恢复正常作息;Always —— 看得见的 caffeinate,机器一直醒着直到你调回来。在 kooky 之外改了系统禁睡(`sudo pmset`、别的工具)也没关系,几秒内档位自动跟上,双向同步。

Expand Down
4 changes: 3 additions & 1 deletion README_JA.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ AI コーディングのために作られた、ミニマルでモダンな macO

**Git worktree。** 任意の git workspace を右クリック → "Create Worktree…" で新しい branch (または既存 branch の checkout) に対する worktree を作成します。worktree はサイドバーで元のリポジトリの下にネストして表示され、独自の tab + agent を持ちます —— main で何かが走っている最中でも、Claude を feature branch で並行して動かせます。コマンドラインで `git worktree add` した worktree も、次回 kooky 起動時に自動でサイドバーに現れます。

**SSH workspace。** File → New SSH Workspace… (または ⌘P) で、リモートマシン上に「住む」workspace を作成します。以降の新しい tab・分割ペイン・再起動時に復元される tab は、すべて同じホストへ自動で再接続します。agent tab を開くと agent はリモート側で起動 —— リモート自身のシェル設定を読み込んでから始まるので、nvm などで入れたツールもきちんと見つかります。ローカルのファイルやスクリーンショットを貼り付けると、kooky が先にアップロードしてからリモートパスを貼り付けるため、向こうの agent が実際に開けます。同一ホストへの接続は共有され、追加の tab は即座に接続。パスワード認証のホストでも貼り付けを含めて全部使えます。
**SSH / Mosh workspace。** File → New Remote Workspace… (または ⌘P) で、リモートマシン上に「住む」workspace を作成します。通常接続には SSH、遅延の揺れ・スリープ・ネットワーク移動・短い切断に強い端末には Mosh を選べます。新しい tab と分割はそれぞれ独立したリモート session を持ち、再起動時は新しい session を確立します。Mosh では別の SSH control channel が agent 状態、remote cwd、cleanup、upload を同期します。この channel が切れても端末は継続し、status pill が stale と表示します。パスワード・OTP・hardware key の再認証はアプリ内の OpenSSH terminal が処理します。ローカルファイルや screenshot は先に upload され、remote path だけが貼り付けられます。Mac 側の `mosh` と remote 側の `mosh-server` が必要です。

サーバー側では Mosh が使う UDP port range も到達可能にする必要があります (Automatic 推奨、または firewall と同じ range を設定)。アプリ再起動時は古い session に reattach せず新規作成し、tab/workspace を明示的に閉じると Kooky 所有の remote runtime を終了します。crash 後の回収も token と process identity を証明できる場合だけです。旧バージョンの Kooky は保存済み Mosh workspace を安全に SSH として開きます。tmux/zellij/ET 内の session は意図的に検出・所有しません。

**Keep-awake(スリープ防止)。** agent が作業中に Mac が寝てしまうことはありません。トップバーの呼吸するインジケーターライトをクリックすると 3 段階を循環します:Off;Auto —— agent の作業中や SSH 接続中はスリープせず(蓋を閉じても継続、初回のみ管理者認証が必要)、作業が終わった瞬間に通常のスリープへ戻ります;Always —— 目に見える caffeinate として、切り替えるまでずっと起きたままです。kooky の外でスリープ設定を変えても(`sudo pmset` や他のツール)、数秒でダイヤルが双方向に追従します。

Expand Down
98 changes: 92 additions & 6 deletions Sources/KookyKit/App/AgentMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,17 +83,54 @@ final class AgentMonitor {
/// so a remote shell's cwd never reaches us and naming it would point
/// at the wrong machine entirely.
let remoteHost: String?
/// Control-plane freshness for Mosh. Kept separate from Agent state:
/// stale never means idle or ended.
let remoteConnectionLabel: String?
/// Stable transport/cwd metadata for remote rows. Unlike `directory`,
/// these values describe the remote machine and are safe to display.
let remoteTransportLabel: String?
let remoteDirectory: String?
/// The tag of the WORKSPACE this session lives in — sessions aren't
/// tagged individually, so every agent in a tagged project carries that
/// project's colour. That's what turns the stripe into project grouping
/// for a list whose order is purely by state.
let tag: WorkspaceTag?

init(
id: UUID,
agent: AgentTemplate,
state: State,
tabTitle: String,
directory: URL,
remoteHost: String?,
remoteConnectionLabel: String? = nil,
remoteTransportLabel: String? = nil,
remoteDirectory: String? = nil,
tag: WorkspaceTag?
) {
self.id = id
self.agent = agent
self.state = state
self.tabTitle = tabTitle
self.directory = directory
self.remoteHost = remoteHost
self.remoteConnectionLabel = remoteConnectionLabel
self.remoteTransportLabel = remoteTransportLabel
self.remoteDirectory = remoteDirectory
self.tag = tag
}

/// Stable location text used when a compact surface needs the actual
/// project path. Remote sessions name the host because their local
/// workspace path would be misleading.
var locationPathLabel: String {
if let remoteHost { return "ssh \(remoteHost)" }
if let remoteHost {
let prefix = remoteTransportLabel ?? "ssh"
if let remoteDirectory, !remoteDirectory.isEmpty {
return "\(prefix) \(remoteHost):\(remoteDirectory)"
}
return "\(prefix) \(remoteHost)"
}
return (directory.path as NSString).abbreviatingWithTildeInPath
}

Expand All @@ -119,8 +156,11 @@ final class AgentMonitor {
@MainActor
func hoverText(tag: WorkspaceTag?) -> String {
let head = "\(singleLine(agent.title)) · \(singleLine(tabTitle)) · \(state.help)"
guard let label = tag?.hashLabel else { return "\(head)\n\(locationLabel)" }
return "\(head)\n\(label)\n\(locationLabel)"
var lines = [head]
if let label = tag?.hashLabel { lines.append(label) }
lines.append(locationLabel)
if let remoteConnectionLabel { lines.append(remoteConnectionLabel) }
return lines.joined(separator: "\n")
}
}

Expand All @@ -137,7 +177,12 @@ final class AgentMonitor {
state: Self.state(of: item.session),
tabTitle: item.session.title,
directory: item.workspace.diskPath,
remoteHost: item.session.sshWorkspaceHost ?? item.session.remoteHost,
remoteHost: item.session.workspaceTransport.remoteDestination ?? item.session.remoteHost,
remoteConnectionLabel: Self.remoteConnectionLabel(for: item.session),
remoteTransportLabel: item.session.workspaceTransport.isRemote
? item.session.workspaceTransport.label.lowercased()
: nil,
remoteDirectory: item.session.remoteWorkingDirectory,
tag: item.workspace.tag
)
}
Expand All @@ -151,6 +196,28 @@ final class AgentMonitor {
return .idle
}

private static func remoteConnectionLabel(for session: Session) -> String? {
guard case .mosh = session.workspaceTransport else { return nil }
switch session.remoteConnectionState {
case .launching:
return String(localized: "mosh · connecting", bundle: .kookyResources)
case .connected:
return String(localized: "mosh · status connected", bundle: .kookyResources)
case .degraded(let since, _):
let seconds = "\(max(0, Int(Date().timeIntervalSince(since))))"
return String(localized: "mosh · status stale for \(seconds)s", bundle: .kookyResources)
case .authenticationRequired(let since):
let seconds = "\(max(0, Int(Date().timeIntervalSince(since))))"
return String(localized: "mosh · ssh authentication required for \(seconds)s", bundle: .kookyResources)
case .disconnected:
return String(localized: "mosh · ended", bundle: .kookyResources)
case .failed:
return String(localized: "mosh · failed", bundle: .kookyResources)
case nil:
return nil
}
}

/// True when any session is actively working — an agent running, or a
/// live SSH conversation (`remoteHost`: set by the login marker, cleared
/// by the wrapper's logout marker, so it spans the whole connection).
Expand Down Expand Up @@ -191,8 +258,27 @@ final class AgentMonitor {
/// observers on every cd / OSC title update.
var hasActiveWork: Bool {
return sessionsWithWorkspace.contains { item in
item.session.remoteHost != nil
|| (!item.session.displayAgent.isShell && item.session.activityState == .running)
let session = item.session
if session.remoteHost != nil { return true }
if case .mosh = session.workspaceTransport {
switch session.remoteConnectionState {
case .launching:
return true
case .connected:
if !session.displayAgent.isShell && session.activityState == .running {
return true
}
case .degraded, .authenticationRequired:
break
case .disconnected, .failed, nil:
return false
}
if let renewed = session.remotePowerLeaseUpdatedAt,
Date().timeIntervalSince(renewed) < 15 * 60 {
return true
}
}
return !session.displayAgent.isShell && session.activityState == .running
}
}
}
Expand Down
Loading