数据截至 (上游 commit c76af90d88f4)
多 agent 抽象、远程终端与外围能力
30 秒导读: 前五章讲的是"一条会话链路怎么跑通"。这章讲两件横向的事——第一,同一套骨架 怎么同时套住 agy / claude / codex / dsh / copilot / cursor / grok / kimi / opencode / pi 这十种性格迥异的 CLI; 第二,除了聊天之外,HAPI 还从手机递过来一个真 shell、一条公网隧道和一套推送,以及这些能力 的代价在哪。
1. 这章在整本书的哪个位置
前面几章沿着一条主线往下钻:三方拓扑 → 双模接管 → 权限审批 → Hub 状态 → Runner 远程开会话。
这一章横着切一刀,回答两个"会话之外"的问题:
| 问题 | 本章小节 |
|---|---|
| 七种 agent CLI 协议各不相同(现在已到十种),怎么只写一份会话循环? | §2 两条路线、§3 能力差异、§4 目录约定 |
| 会话之外还能远程做什么?代价是什么? | §5 远程终端、§6 公网隧道、§7 通知、§8 边界 |
一张图看清本章覆盖的面:
┌──────────────────────────────────┐
手机 / 浏览器 │ Web (PWA) │
└────┬──────────────────┬──────────┘
会话消息流(前 5 章) │ │ /terminal 命名空间(§5)
┌────▼──────────────────▼──────────┐
│ Hub │──► tunwg 隧道 + 二维码(§6)
│ │──► 通知扇出(§7)
└────┬──────────────────┬──────────┘
/cli 命名空间 │ terminal:* 事件对
┌────▼──────────────────▼──────────┐
│ CLI │
│ ├ agent 适配层(§2 §3 §4) │
│ └ TerminalManager → 真 pty │
└──────────────────────────────────┘
2. 多 agent:两条路线
2.1 先说这件事为什么难
会话循环要做的事其实很固定:发一段用户输入过去,收回文字、思考过程、工具调用、token 用量, 中途还要能取消、能弹权限框。
难点在于每家 CLI 的"发和收"长得完全不一样:Claude Code 走自家的 stream-json 加控制请求,
Codex 走 codex app-server 的一套 thread/* turn/* 方法,Pi 走自己的 JSON 事件流。
如果为每家都写一遍会话循环,七套代码要各自维护取消、权限、用量统计。
HAPI 的解法是先定一个中间语言,再看谁能被便宜地翻译过去。中间语言就是
cli/src/agent/types.ts:32 的 AgentMessage 和 :64 的 PermissionRequest;
:97 的 AgentBackend 接口规定了一个后端必须提供的十来个方法(newSession / prompt /
cancelPrompt / respondToPermission / disconnect …)。
2.2 两条路线的分工
于是分出两条路线,判据是"这家 CLI 有没有现成的标准化协议可用":
原生路线(一家一套客户端,翻译代码各写各的)
claude ──► query() ─┐
codex ──► CodexAppServerClient ─┼──► 各自的转换代码 ─┐
pi ──► PiTransport ─┘ │
├──► AgentMessage
ACP 路线(一套代码吃四家) │ 统一模型
cursor ─┐ │
grok ─┼──► AcpStdioTransport ──► AcpMessageHandler ─────┘
kimi ─┤ (stdio JSON-RPC) (归一)
opencode─┘
逐个 flavor 的落点:
| flavor | 远程路线 | 子进程与协议 | 后端构造处 |
|---|---|---|---|
| claude | 原生 | spawn claude,vendored SDK 的 stream-json + 控制请求 | cli/src/claude/sdk/query.ts:293 query |
| codex | 原生 | spawn codex app-server,精简 JSON-RPC(无 jsonrpc 字段) | cli/src/codex/codexAppServerClient.ts:175 CodexAppServerClient |
| pi | 原生 | spawn pi,自定义 JSON 行事件 | cli/src/pi/piTransport.ts:14 PiTransport(cli/src/pi/runPi.ts:245 起进程) |
| cursor | ACP(新会话)/ stream-json(旧会话) | spawn agent … acp | cli/src/cursor/utils/cursorAcpBackend.ts:80 createCursorAcpBackend |
| grok | ACP | spawn grok … | cli/src/grok/utils/grokBackend.ts:39 createGrokBackend |
| kimi | ACP | spawn kimi acp | cli/src/kimi/utils/kimiBackend.ts:20 createKimiBackend |
| opencode | ACP | spawn opencode …,textChunkMode: 'delta' | cli/src/opencode/utils/opencodeBackend.ts:49 createOpencodeBackend |
| gemini | 已下线 | 不再启动,只保留只读查看 | cli/src/commands/registry.ts:29 removedGeminiCommand |
上表八行、可启动的只有七家——gemini 是墓碑,下面 §3.2 会说清它为什么还留在能力表里。
注意 opencode 那一格的 textChunkMode: 'delta':同样是 ACP,不同 agent 的
agent_message_chunk 语义不同——有的是增量(delta),有的是累计快照。这个开关就是为
这种协议内的方言留的。
2.3 ACP 是什么
ACP(Agent Client Protocol)是一套"编辑器 ↔ 编码 agent"的通用协议:双方各起一端,agent 以 子进程身份跑,两边通过标准输入输出上的一行一条 JSON-RPC互相发请求和通知。
对 HAPI 来说它的价值很直接:只要一家 CLI 支持 xxx acp,就不用再写第五套翻译代码。
ACP 目录下三个文件分工清楚,是一条自下而上的管道:
agent 子进程 stdout(一行一条 JSON)
│
▼
① AcpStdioTransport 按 \n 切行 → JSON.parse → 按形状分流
│ 维护 id↔Promise 配对、超时、stderr 错误分类
├── 通知 session/update ───────────────┐
└── 反向请求 session/request_permission │
│ │
▼ ▼
② AcpSdkBackend ③ AcpMessageHandler
把反向请求变成 PermissionRequest 把碎片 chunk 拼成
并把 Promise 挂起等审批结果 一条条 AgentMessage
| 文件 | 职责一句话 | 关键符号 |
|---|---|---|
cli/src/agent/backends/acp/AcpStdioTransport.ts | 纯管道:起进程、切行、分流、配对、超时 | :51 AcpStdioTransport、:262 handleStdout、:278 handleLine、:185 sendRequest |
cli/src/agent/backends/acp/AcpSdkBackend.ts | 协议语义:握手、开会话、发 prompt、权限、模型切换 | :61 AcpSdkBackend、:123 initialize、:281 newSession、:473 prompt |
cli/src/agent/backends/acp/AcpMessageHandler.ts | 内容归一:chunk → AgentMessage,处理缓冲与顺序 | :399 AcpMessageHandler、:583 handleUpdate |
2.4 归一是怎么做的
先看直觉版。 下面这段演示"把一串 ACP 通知折叠成几条结构化消息"的核心动作——重点是 同类 chunk 要攒起来再吐,不同类之间要按边界冲刷。
// 示意,非源码
const buf = { text: '', reasoning: '' }
function onSessionUpdate(u) {
if (u.sessionUpdate === 'agent_thought_chunk') {
buf.reasoning += u.content.text // 思考:只攒,不冲刷正文
} else if (u.sessionUpdate === 'agent_message_chunk') {
flushReasoning() // 正文出现 = 思考段落的边界
buf.text += u.content.text
} else if (u.sessionUpdate === 'tool_call') {
flushReasoning(); flushText() // 工具调用 = 正文段落的边界
emit({ type: 'tool_call', /* … */ })
}
}
// 重点看:哪种事件算"边界",决定了 UI 里 Reasoning 块和正文块的分合。
再看真实实现。 AcpMessageHandler.handleUpdate(AcpMessageHandler.ts:597)就是这个开关,
事件名常量集中在 cli/src/agent/backends/acp/constants.ts:1 的 ACP_SESSION_UPDATE_TYPES。
映射关系:
ACP sessionUpdate | 归一成 AgentMessage | 边界行为 |
|---|---|---|
agent_thought_chunk | { type: 'reasoning' } | 只进 reasoning 缓冲,不冲刷正文 |
agent_message_chunk | { type: 'text' } | 先冲刷 reasoning,再追加正文 |
tool_call | { type: 'tool_call' } | reasoning 和正文都冲刷 |
tool_call_update | { type: 'tool_result' } | 只冲刷 reasoning(见下) |
plan | { type: 'plan' } | 两者都冲刷 |
usage_update | { type: 'usage' } | 由 AcpSdkBackend 单独捕获 |
tool_call_update 那一格是踩过坑的地方:源码注释写得很直白——它是"一次已开工具调用的生命周期
事件,不是两段文字之间的边界",这里冲刷正文会把工具运行期间新流出的文字段落漏到 tool_result
另一侧去(AcpMessageHandler.ts:680-691 注释)。
AgentMessage 之后还要再翻一道,变成上行的线格式,由
cli/src/agent/messageConverter.ts:54 的 convertAgentMessage 完成。有意思的是那个线格式类型
叫 CodexMessage(messageConverter.ts:6)——历史上 Codex 路径先落地,后来的 agent 复用了它
的形状,名字就留下来了。
2.5 权限:一次被挂起的反向请求
第三章讲过手机上"允许"按下去之后的那条链路。ACP 这边的入口在
AcpSdkBackend.ts:190:后端在 initialize 时向 transport 注册了 session/request_permission
的处理器。
真正的巧妙处在 AcpSdkBackend.ts:1114 的 handlePermissionRequest——它不立即回,而是把
resolve 函数存进 pendingPermissions,把 Promise 原地挂住:
const responsePromise = new Promise((resolve) => {
this.pendingPermissions.set(toolCallId, { resolve });
});
AcpSdkBackend.ts:1146-1148。从 agent 子进程的视角看,这就是一个"客户端还没回复的 JSON-RPC
请求",于是它自然阻塞。等手机上的结果经 RPC 回到 CLI,respondToPermission
(AcpSdkBackend.ts:745)取出 resolve 并调用,transport 才把响应写回 stdin。
用一条挂起的 Promise 承载"人在几十秒后按的那个按钮",不需要任何轮询或额外状态机——这是 ACP 路线里最值得抄走的一笔。
2.6 AgentRegistry:一个已经装好但还没接线的插座
cli/src/agent/AgentRegistry.ts 很短,就是一张 agentType → 工厂函数 的静态表:
| 方法 | 行为 | 位置 |
|---|---|---|
register(agentType, factory) | 登记工厂,空字符串或非字符串直接抛错 | AgentRegistry.ts:6 |
create(agentType) | 取工厂并调用,查不到抛 Unknown agent type | AgentRegistry.ts:13 |
list() | 返回已注册类型,排序后输出 | AgentRegistry.ts:21 |
唯一的消费者是 cli/src/agent/runners/runAgentSession.ts:67——一条完全泛型的会话循环:
AgentRegistry.create(agentType) 拿后端,backend.prompt(...) 收流,
convertAgentMessage 转格式,PermissionAdapter(cli/src/agent/permissionAdapter.ts:40)接权限,
全程不认识具 体是哪家 agent。
但要诚实:在本 commit 的非测试代码里,AgentRegistry.register 没有任何调用点,
runAgentSession 也没有任何生产调用点(全仓 grep 只命中它自身与 runAgentSession.test.ts)。
实际跑的是 §4 那套"每家一个目录"的写法。所以这两个文件目前是已经成型但还没接线的理想形态,
不是当前主干。
3. 能力差异表:前端凭什么隐藏那两个选择器
3.1 要解决的小问题
不是每家 agent 都能在会话中途换模型,更不是每家都有"思考强度"这个旋钮。UI 上如果一律显示, 用户点了会失败;如果写死 if-else,加一个 agent 要改十处。
3.2 做法:一张能力集合表
shared/src/flavors.ts:12 的 FLAVOR_CAPS 是唯一事实来源,值是 Set<Capability>,能力名收在
:4 的 Capabilities 常量里(model-change / effort),避免字面量散落。
| flavor | 显示名 | 换模型 model-change | 思考强度 effort |
|---|---|---|---|
| agy | Antigravity | ✅ | ❌ |
| claude | Claude | ✅ | ✅ |
| codex | Codex | ✅ | ❌ |
| dsh | DeepSeek Harness | ❌ | ❌ |
| copilot | Copilot | ✅ | ❌ |
| cursor | Cursor | ✅ | ❌ |
| gemini | Gemini | ✅ | ❌ |
| grok | Grok Build | ✅ | ✅ |
| kimi | Kimi | ✅ | ❌ |
| opencode | OpenCode | ✅ | ❌ |
| pi | Pi | ✅ | ✅ |
依据:shared/src/flavors.ts:12-24(能力),:27-39 FLAVOR_LABELS(显示名)。
gemini 仍列在表里是刻意的——它已不能启动,但历史会话还要能在 UI 里正确渲染;
shared/src/modes.ts:10-12 的 AGENT_FLAVORS 保留它,:18 的 CREATABLE_AGENT_FLAVORS
把它过滤掉,所以"可查看"和"可新建"是两个不同的清单——十种可启动 + 一个只读墓碑,
本章凡是提"十种"都按后一个清单计。
3.3 查询函数与两端校验
三个查询函数层层收窄,都对未知 flavor 返回 false 而不是抛错:
| 函数 | 用途 | 位置 |
|---|---|---|
isKnownFlavor | 类型守卫,Object.hasOwn 判表内 | flavors.ts:42 |
hasCapability(flavor, cap) | 通用查询 | flavors.ts:46 |
supportsModelChange / supportsEffort | 两个便捷包装 | flavors.ts:57 / :61 |
同一个函数在前后端各用一次,这是关键。 前端用它决定渲染:
web/src/components/AssistantChat/HappyComposer.tsx:1558 的 showModelSettings 和
:1563 的 showEffortSettings;连快捷键都受管——:1342 里 Cmd/Ctrl+M 只在
supportsModelChange(agentFlavor) 为真时才生效。
后端不信前端,同一张表再判一遍:hub/src/web/routes/sessions.ts:653 对不支持换模型的 flavor
直接 400,:720 对 effort 同理。UI 隐藏是体验,HTTP 400 才是约束。
另外还有一个和能力无关的分组函数 isCodexFamilyFlavor(flavors.ts:65),把
codex/gemini/grok/kimi/opencode 归为一族——那是消息渲染形状的分组,不要和能力表混为一谈。
4. 同构的目录约定:以 cursor 为例
4.1 一个 agent 目录里有什么
每家 agent 在 cli/src/<flavor>/ 下摆同一组角色。cursor 是最完整的样本(它还多一条旧协议):
| 角色 | cursor 里的文件 | 干什么 |
|---|---|---|
| 命令入口 | cli/src/commands/cursor.ts | 解析 hapi cursor 的 argv,在 cli/src/commands/registry.ts:41 的 COMMANDS 里登记 |
| 顶层编排 | cli/src/cursor/runCursor.ts:28 runCursor | 建/续会话、注册 RPC、组装消息队列 |
| 双模循环 | cli/src/cursor/loop.ts:38 loop | 调 runLocalRemoteSession,在 local/remote 之间来回切 |
| 会话对象 | cli/src/cursor/session.ts:16 CursorSession | 继承 AgentSessionBase,存路径、模型、权限模式 |
| 本地接管 | cursorLocal.ts:22 cursorLocal + cursorLocalLauncher.ts:30 | 直接把用户终端交给原生 CLI |
| 远程接管 | cursorRemoteLauncher.ts:7 → ACP 或 legacy 两个 launcher | 由 HAPI 驱动 agent,把流转成消息 |
| 后端构造 | utils/cursorAcpBackend.ts:80 createCursorAcpBackend | 拼 argv、造 AcpSdkBackend |
可复用的三个基类/基函数:
| 复用点 | 位置 | 谁在用 |
|---|---|---|
AgentSessionBase<Mode> | cli/src/agent/sessionBase.ts:34 | 每家的 session.ts |
runLocalRemoteSession / runLocalRemoteLoop | cli/src/agent/loopBase.ts:8 / :27 | 每家的 loop.ts |
RemoteLauncherBase(抽象类) | cli/src/modules/common/remote/RemoteLauncherBase.ts:41 | grok / kimi / opencode / cursor 的远程 launcher |
BaseLocalLauncher | cli/src/modules/common/launcher/BaseLocalLauncher.ts:39 | 各家的本地 launcher |
RemoteLauncherBase 定了三个抽象钩子:createDisplay(:47,画 Ink 界面)、runMainLoop
(:49)、cleanup(:51);终端裸模式、abort 处理、退出流程都在基类里(:53 setupTerminal、
:76 setupAbortHandlers)。子类只写"这家 agent 的主循环"。
4.2 cursor 的双协议选路
cursor 特殊在它中途换过协议。旧会话的 cursorSessionId 无法用 ACP 的 session/load 载入,
所以旧路径必须留着。选路逻辑只有十几行:
cursorRemoteLauncher(session, metadata) ← cursorRemoteLauncher.ts:7
│
▼ resolveCursorRemoteProtocol(metadata) ← utils/cursorProtocol.ts:18
flavor==='cursor' 且 cursorSessionProtocol!=='acp' 且有 cursorSessionId ?
│
是 ──┴──► cursorLegacyRemoteLauncher spawn `agent`,读 stream-json
否 ─────► cursorAcpRemoteLauncher spawn `agent … acp`,走 ACP
真实符号:cli/src/cursor/cursorRemoteLauncher.ts:11-15(三行选路),
cli/src/cursor/utils/cursorProtocol.ts:5 isLegacyCursorSession(判据),
cli/src/cursor/cursorLegacyRemoteLauncher.ts:435(旧路径,文件顶部第 15 行有
TODO(cursor-acp): remove legacy stream-json resume path after migration window.),
cli/src/cursor/cursorAcpRemoteLauncher.ts:1255(新路径)。
默认走新路、旧路只由"旧会话有旧 id"这一个条件触发——迁移窗口过了直接删一个分支, 这种写法比双向兼容开关干净得多。
4.3 新增一个 agent 要落哪几个文件
照 kimi(最薄的样本)可以看出最小集合:
| 步骤 | 落点 | 备注 |
|---|---|---|
| 1. 注册 flavor | shared/src/modes.ts:10-12 AGENT_FLAVORS | 顺带决定 是否进 CREATABLE_AGENT_FLAVORS |
| 2. 声明能力 | shared/src/flavors.ts:12 FLAVOR_CAPS + :27 FLAVOR_LABELS | 前后端自动跟着变 |
| 3. 造后端 | cli/src/<f>/utils/<f>Backend.ts | ACP 的话就一个 new AcpSdkBackend({ command, args, env }) |
| 4. 会话对象 | cli/src/<f>/session.ts | 继承 AgentSessionBase |
| 5. 双模循环 | cli/src/<f>/loop.ts | 调 runLocalRemoteSession |
| 6. 两个 launcher | <f>LocalLauncher.ts / <f>RemoteLauncher.ts | 后者继承 RemoteLauncherBase |
| 7. 命令入口 | cli/src/commands/<f>.ts + registry.ts:41 | |
| 8. Runner 侧 argv | cli/src/runner/run.ts:1464 buildCliArgs | 手机端远程开会话要走这里 |
走 ACP 的话第 3 步真的只有几行——kimi 的整个后端文件是
cli/src/kimi/utils/kimiBackend.ts:20-27,内容就是 command: 'kimi', args: ['acp']。
5. 远程终端:手机上开一个真 shell
5.1 它是什么
会话里能让 agent 跑命令,但有时你就是想自己敲一行 git status。HAPI 在会话通道之外单开一个
Socket.IO 命名空间 /terminal,把浏览器里的 xterm 直接接到工作机上一个真正的伪终端(pty)。
5.2 三段链路
浏览器 xterm Hub /terminal CLI(工作机)
──────────── ───────────── ───────────
useTerminalSocket JWT 鉴权 + 限额 apiSession 监听
terminal:create ─────► 登记 TerminalRegistry
挑一个 CLI socket ──terminal:open──► TerminalManager.open
Bun.spawn(shell, {terminal})
terminal:write ─────► 查表转发 ─────────terminal:write──► terminal.write()
terminal:resize ─────► 查表转发 ─────────terminal:resize─► terminal.resize()
◄── terminal:output ── 查表回传 ◄────────terminal:output── data 回调
◄── terminal:exit ── 删表 ◄────────────terminal:exit ─── onExit 回调
事件成对,方向严格分开:
| 下行(Web → Hub → CLI) | 上行(CLI → Hub → Web) | 含义 |
|---|---|---|
terminal:create → terminal:open | terminal:ready | 开一个终端 / 已就绪 |
terminal:write | terminal:output | 键盘输入 / 屏幕输出 |
terminal:resize | — | 改窗口大小 |
terminal:close | terminal:exit | 主动关 / 进程退出 |
| — | terminal:error | 任一侧的失败原因 |
三处代码分别对应三段:
Web 侧 web/src/hooks/useTerminalSocket.ts:37 useTerminalSocket(:128 连的就是
manager.socket('/terminal', …));
Hub 侧 hub/src/socket/handlers/terminal.ts:33 registerTerminalHandlers(接 Web)与
hub/src/socket/handlers/cli/terminalHandlers.ts:33(接 CLI 的回程);
CLI 侧 cli/src/api/apiSession.ts:380-424 挂四个监听,转给
cli/src/terminal/TerminalManager.ts:129 TerminalManager。
注意 Web 端下行事件名是 terminal:create,Hub 转给 CLI 时改叫 terminal:open
(hub/src/socket/handlers/terminal.ts:135)——这一改名让"客户端请求"和"CLI 指令"在日志里不会混。
5.3 Hub 是个记账的中继,不是数据通道
Hub 自己不存任何终端内容,只维护一张表:hub/src/socket/terminalRegistry.ts:18 TerminalRegistry。
一条记录四个字段(terminalId / sessionId / 浏览器 socket / CLI socket)加一个 idle 定时器,
另有三张反向索引(按浏览器 socket、按 session、按 CLI socket)——三张索引对应三种批量清理需求。
三道闸门,都在这张表上:
| 闸门 | 行为 | 位置 |
|---|---|---|
| idle 超时 | 默认 15 分钟无读写就踢掉,并给两侧发提示 | terminalRegistry.ts:127 scheduleIdle;hub/src/socket/server.ts:23 DEFAULT_IDLE_TIMEOUT_MS |
| 每 socket / 每 session 上限 | 默认各 4 个,超了回 terminal:error | hub/src/socket/handlers/terminal.ts:104-110;server.ts:23 DEFAULT_MAX_TERMINALS |
| CLI 掉线即清理 | CLI socket 断开,按 removeByCliSocket 批量删,并通知浏览器 "CLI disconnected." | hub/src/socket/handlers/cli/terminalHandlers.ts:168 cleanupTerminalHandlers;terminalRegistry.ts:111 |
两个上限都由同一个 环境变量 HAPI_TERMINAL_MAX_TERMINALS 控制(server.ts:86-88 把同一个值
赋给两个字段),idle 超时是 HAPI_TERMINAL_IDLE_TIMEOUT_MS。
"活动"的定义很宽:create / write / resize 会刷新,CLI 回来的 ready / output 也会刷新
(cli/terminalHandlers.ts:67 与 :68 都调 markActivity)。所以一个正在刷日志的终端不会被误杀。
浏览器侧断开也一样清:hub/src/socket/handlers/terminal.ts:331 的 disconnect 处理器把该
socket 名下所有终端删掉,并逐个给 CLI 发 terminal:close,不留孤儿 shell。
还有一条容易忽略的校验:resolveEntryForSocket(terminal.ts:42)要求
entry.socketId === socket.id,resolveCliSocket(:48)要求 CLI socket 的 namespace 一致。
两条加起来意味着"知道 terminalId"本身不足以往别人的终端里写字。
5.4 CLI 侧:pty 与被删掉的环境变量
TerminalManager.open 用 Bun.spawn 起一个带 terminal: { cols, rows, data, exit } 的子进程
(cli/src/terminal/TerminalManager.ts:180-206),shell 的选择在 :76 resolveShellCommand
($SHELL → macOS /bin/zsh → /bin/bash;Windows 优先 pwsh)。
两个细节值得抄:
- 敏感环境变量不进 shell。
SENSITIVE_ENV_KEYS(TerminalManager.ts:30)把CLI_API_TOKEN、ANTHROPIC_API_KEY、OPENAI_API_KEY、TELEGRAM_BOT_TOKEN等 8 个键从继承的环境里剔掉 (:112),再补上TERM=xterm-256color/COLORTERM/LANG让 TUI 正常显示。 远程终端能开,不等于远程终端该看得见宿主的密钥。 - 换行符按平台归一。
normalizeTerminalInputForHost(:89)只在 Windows 上把裸\n补成\r\n,其它平台原样透传。
CLI 侧还有一份独立的数量上限(TerminalManager.ts:164),读同一个环境变量。Hub 限一次、
CLI 再限一次——中继被绕过时最后一道还在本机。
6. 公网可达:tunwg 隧道与两个二维码
6.1 要解决的问题
Hub 默认只听本机端口。要让手机在外网访问,得有一条从公网打回来的通道,而且不能要求用户去配 路由器或买域名。
HAPI 的做法是拉起一个外部二进制 tunwg(WireGuard 加 TLS 的隧道工具),把本地端口暴露成一个
带证书的 https 地址。
6.2 生命周期
startHub ──(--relay)──► TunnelManager.start()
│
▼ spawn tunwg --json --forward=http://localhost:<port>
逐行读 stdout,JSON.parse
│
┌────────────┴─────────────┐
{event:'ready', url} 进程 exit≠0
│ │
resolve(url) 一次 有过成功?──否─► reject
└─是─► 指数退避重启,最多 5 次
真实位置:hub/src/tunnel/tunnelManager.ts:74 TunnelManager,:101 spawnTunwg,
:128 拼命令行,:161 认 event === 'ready' 拿 URL,:236-247 退避重启
(maxRetries = 5,retryDelayMs = 3000,延迟 3000 × 2^(n-1)),:256 30 秒还没拿到 URL 就
reject。二进制路径按平台在 :41 getTunwgPath 里解析(编译产物和开发模式两套路径)。
重启只在"曾经成功过"之后发生(:229-233:还没 resolve 就直接 reject)。首启失败是配置
问题,重试没意义;跑着跑着掉了才是网络抖动,值得退避重连。
6.3 为什么要等 TLS 就绪再打印二维码
隧道进程说 ready 只代表转发链路通了,不代表这个域名的证书已经签发并可信。如果这时就
把二维码打出来,用户一扫,浏览器给一个证书错误页——而且很多人不会再扫第二次。
hub/src/tunnel/tlsGate.ts:160 的 waitForTunnelTlsReady 就是这道闸门。它每 1.5 秒对
host:443 发起一次 TLS 连接(:116 checkTunnelCertificate,rejectUnauthorized: false 自己判),
三项都过才返回 true:
| 检查 | 内容 | 位置 |
|---|---|---|
| 链可信 | socket.authorized 为真 | tlsGate.ts:146 |
| 时间有效 | valid_from/valid_to 覆盖当前时刻,容 5 分钟时钟偏差 | :82 isCertificateTimeValid |
| 名字匹配 | SAN 里有匹 配的 DNS(支持单层通配)或 IP,退化到 CN | :50 hostMatchesCertificate、:29 dnsNameMatchesHost |
循环条件是 while (tunnelManager.isConnected())——隧道中途死了就返回 false,不会无限等
(tlsGate.ts:189-201)。非 https 的 URL 直接放行(:165)。
6.4 两个二维码
TLS 过闸后,hub/src/startHub.ts:329 的 announceTunnelAccess 连着打印两个:
| 用途 | 形态 | 参数 | 位置 |
|---|---|---|---|
| 浏览器 / PWA 直连 | https://<官方 web>/?hub=<隧道URL>&token=<CLI_API_TOKEN> | hub + token | startHub.ts:339-345 |
| 原生伴侣 App 配对 | hapicompanion://bind?hub=…&code=… | hub + code | startHub.ts:366-371 |
同一份信息、两个键名(token vs code)、两种 scheme。深链那份的注释说明了原因:PWA 用户扫到
会忽略,原生 App(Android / Wear OS)才会接管(startHub.ts:365)。二维码渲染失败被吞掉,
纯文本链接照常打印(:326、:346)——外围能力失败不该拖垮主流程。
整个 announceTunnelAccess 是 void 调用(startHub.ts:386),不阻塞 "HAPI Hub is ready!" 的
输出。等 TLS 可能要几十秒,Hub 本身早就能用了。
7. 通知:一次事件,多通道扇出
7.1 结构
hub/src/notifications/notificationHub.ts:7 的 NotificationHub 订阅 SyncEngine 的事件流
(:24),把"要不要通知"的判断做完,再把"往哪儿发"交给一组通道。
通道是一个只有四个方法的接口(hub/src/notifications/notificationTypes.ts:10
NotificationChannel:sendReady / sendPermissionRequest / sendTaskNotification /
可选的 sendSessionCompletion)。当前实现:
| 通道 | 说明 | 启用条件 | 位置 |
|---|---|---|---|
FcmNotificationChannel | 原生伴侣 App 推送 | 配了 FCM 项目与服务账号 | hub/src/fcm/fcmNotificationChannel.ts:10(注册于 startHub.ts:228) |
PushNotificationChannel | Web Push(VAPID)+ SSE toast | 总是启用 | hub/src/push/pushNotificationChannel.ts:9(startHub.ts:249) |
ServerChanChannel | Server 酱 | 配了 serverChanSendKey | hub/src/serverchan/channel.ts:16(startHub.ts:258) |
HappyBot | Telegram | 配了 bot token | hub/src/telegram/bot.ts:30 |
VAPID 密钥由 hub/src/config/vapidKeys.ts 的 getOrCreateVapidKeys 自动生成并持久化
(startHub.ts:181),用户不用手动配。
扇出本身是"逐个 try/catch,一个通道炸了不影响别的":见 notificationHub.ts:185 notifyReady
及其后三个同构方法。
7.2 两级抑制闸门
问题:用户正盯着网页看,还要给他弹一条系统推送吗?已经收到手机原生推送了,还要再来一条 Web Push 吗?
两级闸门解决:
一次 notify* 调用
│ ctx = { nativeGate: { sent: false } } ← 本次分发共享的一个小对象
│
├─► FcmNotificationChannel 投递成功 → nativeGate.sent = true
│
└─► PushNotificationChannel
nativeGate.sent ? ── 是 ─► 直接返回(defer-to-native)
└─ 否 ─► hasVisibleConnection(namespace) ?
是 → SSE toast(页面内提示)
否 → Web Push(系统级推送)
第一级 NativeDeliveryGate(hub/src/notifications/notificationSendContext.ts:8):
一个只有 sent: boolean 的小对象,在一次分发里被两个通道共享。FCM 先跑并置位,Web Push 后跑
读它(pushNotificationChannel.ts:110-113)。注释特意写明:只有真正投递成功才置位,
"注册表陈旧"或"健康探测"不算——否则会两边都静音。
第二级 VisibilityTracker(hub/src/visibility/visibilityTracker.ts:3):按 namespace 记录
哪些 SSE 连接当前处于前台。hasVisibleConnection(:40)为真就发页内 toast,为假才发系统推送
(pushNotificationChannel.ts:115-128)。
还有一个兜底:toast 发出去但 delivered === 0(连接刚断),会记一条 sse-toast-zero 日志并
继续往下走发 Web Push(pushNotificationChannel.ts:127-134),不会因为"以为前台"而丢消息。
NotificationHub 自己还有两个降噪参数:readyCooldownMs 默认 5000、permissionDebounceMs
默认 500(notificationHub.ts:21-22)。
8. 边界与局限(诚实一节)
这一节列的都是在源码里能直接读到的,不是猜测。
| 局限 | 实情 | 依据 |
|---|---|---|
| Hub ↔ CLI 没有端到端加密 | 传的是明文 JSON,身份靠共享密钥 CLI_API_TOKEN;加密只发生在隧道那一层的 TLS | cli/src/runner/README.md:530(仓库自述 "No end-to-end encryption (TLS only); CLI auth is a shared secret");hub/src/socket/server.ts:113-121 校验逻辑 |
| Runner 控制服务无鉴权 | 只是 Fastify 监听在 127.0.0.1 的随机端口,没有 token / header 校验 | cli/src/runner/README.md:148("Host: 127.0.0.1 (localhost only)");同文件 :551 自己把这条列为待改进 |
| 协议可随时破坏性变更 | 仓库明确写 "No backward compatibility: breaking old formats freely" | AGENTS.md:57 |
| gemini 已被拒绝启动 | spawnSession 与 buildCliArgs 两处显式抛错,只保留只读查看 | cli/src/runner/run.ts:502、:1310;cli/src/commands/registry.ts:29 的 tombstone 命令 |
AgentRegistry 尚未接线 | register / runAgentSession 在非测试代码里无调用点 | 见 §2.6 |
| cursor 留着一条旧协议 | legacy stream-json 路径带 TODO,等迁移窗口结束才删 | cli/src/cursor/cursorLegacyRemoteLauncher.ts:16-19 |
两点需要更正常见的说法:
-
不同命名空间用的不是同一套凭据。
/cli命名空间校验CLI_API_TOKEN(常量时间比较,hub/src/socket/server.ts:117),而/terminal命名空间校验的是 JWT (server.ts:133-152,HS256)。所以"CLI_API_TOKEN 是唯一凭据"是不准确的——它是 CLI 侧的凭据,浏览器侧走 JWT。但注意直连二维码里带的正是token=<CLI_API_TOKEN>(startHub.ts:341-343),所以拿到那个二维码等于拿到 CLI 身份。 -
web 包是有自动化测试的。 本 commit 下
web/src有 209 个*.test.ts(x)文件 (含web/src/routes/sessions/terminal.test.tsx),外加两个 Playwright 规约 (web/e2e/mermaid-lightbox*.spec.ts),web/package.json里"test": "vitest run"。 "web 没有测试"这个说法在此 commit 不成立。
其它已知弱点:
- 远程终端依赖 Bun 的
terminal能力,Windows 需要 Bun ≥ 1.3.14,否则报明确错误 (cli/src/terminal/TerminalManager.ts:239-244);非 Bun 运行时直接不可用(:170-173)。 - 隧道重启上限 5 次,超了就彻底放弃并打日志,不会再自愈(
tunnelManager.ts:293-295)。 - ACP transport 一旦遇到无法解析的 stdout 行,会把整条连接判死并杀进程
(
AcpStdioTransport.ts:461-476)——对 agent 往 stdout 混打日志的情况零容忍。
9. 横向对比:包装既有 CLI,还是自建 agent 循环
同一个货架上的项目,在"agent 循环归谁"这个问题上分成三派:
| 项目 | 取舍 | 后果 |
|---|---|---|
| HAPI(本篇) | 全包装。自己不跑模型循环,只当 7 个官方 CLI 的遥控器 | 加一个 agent 成本极低(kimi 的后端只有 7 行),但能力天花板完全由上游 CLI 决定——上游没有的旋钮就是没有,FLAVOR_CAPS 只能如实反映 |
| happy | 同为包装派,是 HAPI 的上游 | 差别在信任模型:happy 走端到端加密、服务器读不懂内容;HAPI 换成 local-first + 共享密钥 + 隧道 TLS,少一层加密换来自己掌控 Hub |
| opencode / crush / goose | 自建循环。自己实现工具系统、权限链、上下文压缩 | 能力上限自己定,但每个机制都要自己造一遍;反过来它们成了 HAPI 的被包装对象——opencode 正是 HAPI 的 ACP 后端之一 |
这里有个有意思的闭环: opencode 自己实现了完整的 agent 循环,同时又对外暴露 acp 子命令;
HAPI 通过 createOpencodeBackend(cli/src/opencode/utils/opencodeBackend.ts:49)把它当成一个
后端接进来。所以"自建循环"和"包装 CLI"不是对立的两派,而是同一条栈的上下两层——ACP 就是
这两层之间正在成形的接缝。
HAPI 押的注是:接缝会标准化,包装层的边际成本会趋近于零。 从代码上看这个注已经赢了一半——
四家 ACP agent 共用一套 transport 加一个 handler,新增一家只要填 command 和 args;
而三家原生 agent 仍各背一套客户端代码(query.ts 500+ 行、codexAppServerClient.ts 594 行)。
10. 代码地图
| 主题 | 文件 | 关键符号 |
|---|---|---|
| 统一消息模型 | cli/src/agent/types.ts | AgentMessage、PermissionRequest、AgentBackend |
| 后端注册表(未接线) | cli/src/agent/AgentRegistry.ts | AgentRegistry.register / .create / .list |
| 泛型会话循环(未接线) | cli/src/agent/runners/runAgentSession.ts | runAgentSession |
| ACP 传输层 | cli/src/agent/backends/acp/AcpStdioTransport.ts | AcpStdioTransport、handleStdout、handleLine、sendRequest |
| ACP 协议语义 | cli/src/agent/backends/acp/AcpSdkBackend.ts | AcpSdkBackend、initialize、newSession、prompt、handlePermissionRequest |
| ACP 内容归一 | cli/src/agent/backends/acp/AcpMessageHandler.ts | AcpMessageHandler、handleUpdate、flushText、flushReasoning |
| ACP 事件名常量 | cli/src/agent/backends/acp/constants.ts | ACP_SESSION_UPDATE_TYPES |
| 线格式转换 | cli/src/agent/messageConverter.ts | convertAgentMessage、CodexMessage |
| Codex 原生客户端 | cli/src/codex/codexAppServerClient.ts | CodexAppServerClient、startThread、startTurn |
| Claude 原生客户端 | cli/src/claude/sdk/query.ts | query、Query |
| Pi 原生传输 | cli/src/pi/piTransport.ts | PiTransport |
| 能力表 | shared/src/flavors.ts | FLAVOR_CAPS、hasCapability、supportsModelChange、supportsEffort |
| flavor 清单 | shared/src/modes.ts | AGENT_FLAVORS、CREATABLE_AGENT_FLAVORS |
| 前端能力开关 | web/src/components/AssistantChat/HappyComposer.tsx | showModelSettings、showEffortSettings |
| 后端能力校验 | hub/src/web/routes/sessions.ts | 模型 / effort 路由里的 supportsModelChange / supportsEffort |
| cursor 双协议选路 | cli/src/cursor/cursorRemoteLauncher.ts、utils/cursorProtocol.ts | cursorRemoteLauncher、resolveCursorRemoteProtocol、isLegacyCursorSession |
| cursor ACP 后端 | cli/src/cursor/utils/cursorAcpBackend.ts | createCursorAcpBackend、buildCursorAcpArgs |
| 可复用基类 | cli/src/agent/sessionBase.ts、cli/src/agent/loopBase.ts、cli/src/modules/common/remote/RemoteLauncherBase.ts | AgentSessionBase、runLocalRemoteSession、RemoteLauncherBase |
| 终端:Web 侧 | web/src/hooks/useTerminalSocket.ts | useTerminalSocket |
| 终端:Hub 记账 | hub/src/socket/terminalRegistry.ts | TerminalRegistry、scheduleIdle、removeByCliSocket |
| 终端:Hub 转发 | hub/src/socket/handlers/terminal.ts、handlers/cli/terminalHandlers.ts | registerTerminalHandlers、resolveEntryForSocket、resolveCliSocket、cleanupTerminalHandlers |
| 终端:CLI pty | cli/src/terminal/TerminalManager.ts | TerminalManager、resolveShellCommand、SENSITIVE_ENV_KEYS |
| 隧道进程 | hub/src/tunnel/tunnelManager.ts | TunnelManager、spawnTunwg、parseTunwgEvent |
| TLS 闸门 | hub/src/tunnel/tlsGate.ts | waitForTunnelTlsReady、checkTunnelCertificate |
| 二维码与深链 | hub/src/startHub.ts | announceTunnelAccess、companionDeeplink |
| 通知扇出 | hub/src/notifications/notificationHub.ts | NotificationHub、notifyReady、notifyPermission |
| 通知抑制 | hub/src/notifications/notificationSendContext.ts、hub/src/visibility/visibilityTracker.ts | NativeDeliveryGate、VisibilityTracker.hasVisibleConnection |
| gemini 下线 | cli/src/runner/run.ts、cli/src/commands/registry.ts | spawnSession、buildCliArgs、removedGeminiCommand |