Seahelm macOS 14+ · Swift + AppKit · 247 个文件

为 coding agent 准备的原生 macOS 工作台

一个窗口,管住所有在跑的 agent。

你的舰队排在一张名册上:每个 worktree 一行,按仓库、状态或最近动向编队,每个 split pane 里坐着一个 agent。Seahelm 用 Ghostty 引擎把它们逐个画出来,盯着谁在干活、谁在等你, 重启之后每个会话都还在原位。十二种 agent CLI 开箱即用。

openbeta/seahelm · 3 个 worktree · 5 个 pane 1 个待输入 2 个在跑
main空闲
claude
空闲 · 4 分钟
codex
空闲 · 22 分钟
feat/island-usage运行中
claude
Edit · UsageSummaryFormatter.swift
codex
Bash · xcodebuild test
fix/zmx-recovery待输入
claude
允许执行 rm -rf .build 吗?

示例舰队。上面每一种状态都是这个 app 真实会报的状态。

$ curl -fsSL https://seahelm.dev/install.sh | sh
github.com/BetaYao/seahelm

四十七秒,看它真的在跑

真实会话,不是效果图:侧栏是一列 worktree,其中一个里有 agent 正在干活, 还有一张建议卡片在问接下来往哪走。

甲板一 · 甲板之上

你掌控的是什么

Seahelm 没有编辑器,没有 diff review。整个界面只处理两件事:把 Agent 放到 合适的位置 —— 合适的仓库,合适的 worktree,合适的 Pane —— 然后告诉你其中哪个 Agent 需要你的帮助才能完成目标。

应用 项目 WORKTREE PANE seahelm — 一个窗口,一个键盘 openbeta/seahelm main claude codex feat/island-usage claude codex acme/api fix/auth-retry claude — 待输入 main aider
四层结构,一个真相来源。项目就是你加进来的 git 仓库;它的 worktree 来自 git worktree list --porcelain;pane 是这个 worktree 分屏树上的一个叶子, 里面正好装一个 agent。图里每一个叶子的状态,AgentRegistry.shared 都知道。
分屏与布局
每个 worktree 一棵二叉分屏树,可以拖分隔条,也可以纯键盘操作,整棵树会序列化进配置,重启后原样恢复。舰队列表可以按仓库、按状态、按最近活动分组,或展开到每个 pane。
侧边栏
文件树、代码编辑器、Markdown 预览、git diff 审查 —— 不用离开当前 worktree,也不打断里面正在干活的 agent。
Token 用量
解析 Claude 和 Codex 的本地会话日志,算出 token 和额度数字,在状态胶囊里一次轮播一条。
持续集成
每当一个 agent 结束一轮,整支舰队的成果就会被叠到主干上,并检出到一个你能直接构建的地方 —— 不需要任何人提交,也不碰任何一个工作目录。它是怎么做到的
远程掌舵
一个浏览器客户端 seahelm-web,由 app 自己内嵌的网关托管。用八位配对码配一次,它就能列出整支舰队,并在任意 pane 上打开一个真正的终端 —— xterm.js 跑在一条 WebSocket 上,桌面端用的那套控制调用和 VT 字节流都走这一条连接。通过 iMessage 和邮件下达指令仍是实验性的。

灵动岛

屏幕顶部的一颗胶囊,没事的时候一直闭着,直到某个 worktree 需要你。它只为三件事张开:有东西在跑、有东西在等、有东西坏了。agent 的建议会以可点击的卡片形式落在里面。

First Mate(大副)

一个盯着状态迁移的自动值班员。绿区动作它自己就做了 —— 盯着、查看、自动提交。红区动作 —— 广播指令、把 worktree 收回港口 —— 要先排队等你批准。

之前 之后 main claude codex feat/x — 刚建好 空占位 pane hook 带的 cwd moveLeaf main codex feat/x claude Station 与 zmx 会话都留着 占位 pane 被销毁
pane 跟着它的 agent 走。Claude Code 建一个 worktree 然后在里面干活;每一条 hook 消息都带着 cwd,于是 Seahelm 按最长前缀匹配 worktree,把那一个 pane 整体挪过去 —— 它的 Ghostty surface 和 zmx 会话都原样保留 —— 而不是在旁边另起一个跟它无关的。 两道闸门保证这件事不跑偏:目标必须属于同一个仓库;而且任何一次搬迁之后,自动跟随会停十分钟, 因为 agent 干活时 cwd 是来回跳的。
十二种 agent,三种集成深度
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、分屏操作、侧边面板、模式化键盘状态机。
UISources/UI/75 仪表盘布局、灵动岛、分屏容器、worktree 侧栏、diff 查看器、设置、首次引导。
核心服务Sources/Core/ · Status/133 AgentRegistry、状态识别流水线、manifest 引擎、First Mate、控制 socket、hook 安装器。
终端与 gitSources/Terminal/ · Git/19 Ghostty C API 桥接、Station 的 surface 生命周期、分屏树、worktree 发现。
来源 桥接 解读 解码 归约 消费 Claude Code Codex · opencode seahelm-hook socket ▸ webhook hook 事件 SessionStart · Stop Pre/PostToolUse HookDecoder 执行 JSON 映射 12 种 CLI 任选 无需集成 Ghostty 屏幕 加锁读取文本 StatusDetector 进程退出 ▸ OSC 133 ▸ 文本模式 ScanDecoder 12 份 JSON manifest 规则 渲染 轮询 判定 每个事件 每次轮询 NormalizedEvent PaneReducer 纯函数 AgentRegistry 唯一真相来源 增量 同时发布给三方 灵动岛 · 仪表盘 状态栏 First Mate 绿区 ▸ 红区 EventHub CLI 订阅方
两条通道,一种事件形状。会上报 hook 的 agent 走快车道;另外九种每两秒从屏幕上读一次, 再按优先级顺序去匹配 manifest —— 进程退出优先于 OSC 133 的 shell 阶段,后者又优先于文本模式匹配。 两条通道最终解码成同一个 NormalizedEvent,所以归约器保持是纯函数, 界面永远不知道某个状态是从哪条通道来的。想知道某个 pane 是被哪条规则判定的,就问它: seahelm pane explain <pane>
运行中有一次工具调用在途
待输入卡在等人
空闲提示符回来了,等着
出错agent 挂了
休眠进程已退出

同样这五种状态,驱动状态点、卡片、灵动岛,以及 worktree 那一层的汇总。没有第六种。

pane 里的一个 agent SEAHELM_PANE_ID seahelm CLI ~/.local/bin/seahelm ControlSocketServer 0600 权限的 unix socket EventHub 有界环形缓冲 pane.split pane.run wait.agent_status 调用 JSON-RPC 改动 pane 事件 agent 可以反过来驱动正在盯着它的 app
回路是闭合的。Seahelm 盯着 agent;而 agent 手里握着 $SEAHELM_PANE_ID,可以反过来 调回来分屏、在兄弟 pane 里跑命令、读它的历史输出,或者一直阻塞到另一个 pane 变空闲为止。

agent 拿到的接口

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

seahelm.app 窗口 · SplitTree · Station · Metal surface 退出即消失 你退出 app · 机器重启 zmx 会话  seahelm-<repo>-<worktree> PTY · shell · agent 进程,还在想 存活 磁盘上的 git worktree 与分支 存活 按名字重新挂接
会话活得比 app 久。每个分屏叶子都占用一个以其 worktree 命名的 zmx 会话;创建三秒后有一次健康检查, 发现是坏的就重建。退出、重启、再打开 —— agent 还在你离开时的位置上,话说到一半。

两个线程,一个终端引擎

状态轮询跑在后台队列上,而你在主队列上打字,两边调的是同一个 C 库。一把锁把它们串起来 —— 唯独按键输入故意不加锁:Ghostty 对按键本身是线程安全的,而在那里持锁会在一次同步回调上死锁。

轮询也不是一视同仁的。你正在看的那个 worktree 每个周期都读;其余的每三个周期读一次, 所以二十个 pane 的舰队,开销大约只相当于三个。

构建于

终端引擎用 Ghostty,以 C xcframework 链接进来 —— 会话持久化用 zmx —— 自动更新用 Sparkle 2, 但渲染成页面内的横幅,而不是它自带的模态弹窗。

甲板三 · 汇合点

它们还能拼到一起吗?

六个 agent,六个 worktree,六个分支,单看每一个都是绿的。但它们放一起还能不能跑, 这个问题通常要拖到合并那天才有人回答。Seahelm 每一轮结束就回答一次 —— 不需要任何人提交,也不碰任何一个工作目录。

工作目录 对象数据库 · .git feat/auth HEAD + 改动 feat/island 一轮进行中 fix/zmx HEAD + 改动 集成检出 游离 — 没有分支可选 唯一一次向上跨越边界的写,就是那一次 reset 临时索引 worktree 不受影响 快照 + 未跟踪文件 只取 HEAD 还在干活 快照 + 未跟踪文件 主干 + feat/auth + feat/island 集成后的 commit 合并 合并 = 结果 冲突 — Config.swift 本轮排除,并如实报告 reset --hard 目标已经合并好了 每一步都是:merge-tree --write-tree → commit-tree
一轮的全过程。每个 worktree 被捕捉成一个 commit,这些 commit 再一次一个 merge-tree 地叠到主干上,最后把检出移到结果上。除了最后那一根箭头, 全部发生在 .git 里面 —— 所以一轮无论是冲突、挂起还是彻底失败, 每一个工作目录都还是原样。
谁都不用先提交
一个 agent 结束一轮时,成果是改好了但没有提交的。要求先提交,就会把 git add -A && git commit 的所有毛病全都带回来:仓库的 pre-commit hook、-A 顺手扫进来的一切,以及一部塞满机器提交的历史。所以快照改在一个临时索引里做 —— GIT_INDEX_FILEread-treeadd -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 就不行:一旦冲突,那个目录就一直处于冲突状态,直到有人来收拾。
冲突也是一种结果
而不是一个中止整轮的失败。撞上的那个 worktree 会被丢掉,并连同它冲突的路径一起报告出来;其余的照常集成,所以你手上仍然有一份能构建、能测试的东西。各个来源按稳定的顺序应用,所以同一支舰队跑两遍,报告是一样的。
刻意保持游离
这个检出不落在任何分支上。分支意味着要管的东西 —— 一个要重置的名字、一个可推送的对象,以及 git branch -a 里的一条记录,而新建分支对话框正是从那里取候选基线的。从那个开一个 worktree,你就继承了一份大家工作混在一起、只测了一半的东西。保持游离,就没有 ref 可选。
唯一不可逆的一步,永远不自动
发布只是一次 reset --hard,目标是一个已经合并好的 commit,所以它不会做到一半失败,也不会留下一个冲突状态的检出。但 reset --hard 会丢掉任何未提交的内容 —— 所以如果你一直在集成检出里改文件,这一轮会把 commit 造好、挂起、然后来问你。那唯一具有破坏性的一刻,永远需要一个人点头。

比对 tree,而不是 commit

commit-tree 会打上时间戳,所以一支没有任何变化的舰队,一小时后重新构建会得到一个不同的 commit,而里面的文件一模一样。拿 commit 去比,每一轮看起来都像有改动,检出就被白白重置一次。真正的变更依据是 tree。

一轮进行中的 worktree 只交 HEAD

触发这一轮的是某一个 agent 结束,但这一轮要把所有人都叠进来 —— 而其他人可能正写到一半:半个文件、半个重命名。这些 worktree 交出的是它们最后一个自洽的状态,而不是一个撕裂的状态 —— 后者失败的样子会很像一次真实冲突。等它们自己那一轮结束时,会完整地进来。

触发条件是一次状态跳变

一个 agent 离开 Running —— 和驱动状态点的是同一个跳变 —— 意味着一段工作到了一个歇脚点。两秒内接连结束的多个 agent 会合并成一轮,而不是各触发一轮。

以“存在”作为开关

只有已经存在集成检出的仓库才会被碰。跑一次 /integrate 就是这个仓库的加入动作;没有任何东西会自作主张建目录,所以打开这个功能不会在你磁盘上凭空多出状态来。