● 为 coding agent 准备的原生 macOS 工作台
你的舰队排在一张名册上:每个 worktree 一行,按仓库、状态或最近动向编队,每个 split pane 里坐着一个 agent。Seahelm 用 Ghostty 引擎把它们逐个画出来,盯着谁在干活、谁在等你, 重启之后每个会话都还在原位。十二种 agent CLI 开箱即用。
示例舰队。上面每一种状态都是这个 app 真实会报的状态。
甲板一 · 甲板之上
Seahelm 没有编辑器,没有 diff review。整个界面只处理两件事:把 Agent 放到 合适的位置 —— 合适的仓库,合适的 worktree,合适的 Pane —— 然后告诉你其中哪个 Agent 需要你的帮助才能完成目标。
git worktree list --porcelain;pane 是这个 worktree 分屏树上的一个叶子,
里面正好装一个 agent。图里每一个叶子的状态,AgentRegistry.shared 都知道。
seahelm-web,由 app 自己内嵌的网关托管。用八位配对码配一次,它就能列出整支舰队,并在任意 pane 上打开一个真正的终端 —— xterm.js 跑在一条 WebSocket 上,桌面端用的那套控制调用和 VT 字节流都走这一条连接。通过 iMessage 和邮件下达指令仍是实验性的。屏幕顶部的一颗胶囊,没事的时候一直闭着,直到某个 worktree 需要你。它只为三件事张开:有东西在跑、有东西在等、有东西坏了。agent 的建议会以可点击的卡片形式落在里面。
一个盯着状态迁移的自动值班员。绿区动作它自己就做了 —— 盯着、查看、自动提交。红区动作 —— 广播指令、把 worktree 收回港口 —— 要先排队等你批准。
cwd,于是 Seahelm 按最长前缀匹配 worktree,把那一个 pane 整体挪过去 ——
它的 Ghostty surface 和 zmx 会话都原样保留 —— 而不是在旁边另起一个跟它无关的。
两道闸门保证这件事不跑偏:目标必须属于同一个仓库;而且任何一次搬迁之后,自动跟随会停十分钟,
因为 agent 干活时 cwd 是来回跳的。
| Agent | 状态识别 | 事件 hook | 建议卡片 |
|---|---|---|---|
| Claude Code | ✓ manifest + hooks | ✓ 原生 | ✓ |
| Codex | ✓ manifest + hooks | ✓ 原生 | ✓ |
| opencode | ✓ manifest + hooks | ✓ 插件 | 看模型自觉 |
| 另外九种 | ✓ 屏幕识别 | — | — |
agent · aider · amp · claude · cline · codex · cursor · gemini · goose · kiro · opencode · pi
甲板二 · 甲板之下
247 个 Swift 文件,分四层,跑在 Ghostty 终端引擎之上。没有 Combine,也没有 SwiftUI —— 用的是 AppKit 加 delegate,因为屏幕上那个东西本质是一块 Metal surface,后面接着一个 PTY。
| 分层 | 路径 | 文件数 | 里面装什么 |
|---|---|---|---|
| 协调层 | Sources/App/ | 15 | 窗口、tab、分屏操作、侧边面板、模式化键盘状态机。 |
| UI | Sources/UI/ | 75 | 仪表盘布局、灵动岛、分屏容器、worktree 侧栏、diff 查看器、设置、首次引导。 |
| 核心服务 | Sources/Core/ · Status/ | 133 | AgentRegistry、状态识别流水线、manifest 引擎、First Mate、控制 socket、hook 安装器。 |
| 终端与 git | Sources/Terminal/ · Git/ | 19 | Ghostty C API 桥接、Station 的 surface 生命周期、分屏树、worktree 发现。 |
NormalizedEvent,所以归约器保持是纯函数,
界面永远不知道某个状态是从哪条通道来的。想知道某个 pane 是被哪条规则判定的,就问它:
seahelm pane explain <pane>。
同样这五种状态,驱动状态点、卡片、灵动岛,以及 worktree 那一层的汇总。没有第六种。
$SEAHELM_PANE_ID,可以反过来
调回来分屏、在兄弟 pane 里跑命令、读它的历史输出,或者一直阻塞到另一个 pane 变空闲为止。
PATH 上的一个 python3 包装脚本,通过 0600 权限的 socket 走按行分隔的 JSON-RPC。没有需要认证的东西,也不走网络。
$ seahelm pane list $ seahelm pane read <pane> --lines 50 $ seahelm pane split <pane> --direction right $ seahelm pane run <pane> "npm test" $ seahelm wait agent-status <pane> --status Idle $ seahelm pane explain <pane> # 命中哪条规则? $ seahelm layout export
Hook 垫片以非破坏的方式装进每个工具自己的配置里 —— 装完就不挡路。CwdChanged 是故意不注册的:一旦接了它,Claude Code 就会把创建 worktree 这件事交给我们,反而弄坏了 --worktree。
甲板三 · 汇合点
六个 agent,六个 worktree,六个分支,单看每一个都是绿的。但它们放一起还能不能跑, 这个问题通常要拖到合并那天才有人回答。Seahelm 每一轮结束就回答一次 —— 不需要任何人提交,也不碰任何一个工作目录。
merge-tree 地叠到主干上,最后把检出移到结果上。除了最后那一根箭头,
全部发生在 .git 里面 —— 所以一轮无论是冲突、挂起还是彻底失败,
每一个工作目录都还是原样。
git add -A && git commit 的所有毛病全都带回来:仓库的 pre-commit hook、-A 顺手扫进来的一切,以及一部塞满机器提交的历史。所以快照改在一个临时索引里做 —— GIT_INDEX_FILE 把 read-tree 和 add -A 指向一个临时文件,write-tree 生成 tree,commit-tree 生成 commit。worktree、真正的索引和 HEAD 全程没被碰过,所以在那个目录里干活的 agent 根本察觉不到发生过这件事。(git stash create 看着像是答案,其实不是:它会漏掉未跟踪文件,而那恰恰是 agent 产出的大头。)merge-tree --write-tree 合并两个 commit 并写出一个 tree;commit-tree 把它变成 commit;这个 commit 就是下一次合并的左侧。整个过程不碰任何 worktree 或索引,所以也就不可能把哪个目录卡在合并到一半的状态 —— 这正是它可以无人值守运行的原因。在真实检出里跑 git merge 就不行:一旦冲突,那个目录就一直处于冲突状态,直到有人来收拾。git branch -a 里的一条记录,而新建分支对话框正是从那里取候选基线的。从那个开一个 worktree,你就继承了一份大家工作混在一起、只测了一半的东西。保持游离,就没有 ref 可选。reset --hard,目标是一个已经合并好的 commit,所以它不会做到一半失败,也不会留下一个冲突状态的检出。但 reset --hard 会丢掉任何未提交的内容 —— 所以如果你一直在集成检出里改文件,这一轮会把 commit 造好、挂起、然后来问你。那唯一具有破坏性的一刻,永远需要一个人点头。commit-tree 会打上时间戳,所以一支没有任何变化的舰队,一小时后重新构建会得到一个不同的 commit,而里面的文件一模一样。拿 commit 去比,每一轮看起来都像有改动,检出就被白白重置一次。真正的变更依据是 tree。
触发这一轮的是某一个 agent 结束,但这一轮要把所有人都叠进来 —— 而其他人可能正写到一半:半个文件、半个重命名。这些 worktree 交出的是它们最后一个自洽的状态,而不是一个撕裂的状态 —— 后者失败的样子会很像一次真实冲突。等它们自己那一轮结束时,会完整地进来。
一个 agent 离开 Running —— 和驱动状态点的是同一个跳变 —— 意味着一段工作到了一个歇脚点。两秒内接连结束的多个 agent 会合并成一轮,而不是各触发一轮。
只有已经存在集成检出的仓库才会被碰。跑一次 /integrate 就是这个仓库的加入动作;没有任何东西会自作主张建目录,所以打开这个功能不会在你磁盘上凭空多出状态来。