跳到主要内容

数据截至 (上游 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:32AgentMessage:64PermissionRequest; :97AgentBackend 接口规定了一个后端必须提供的十来个方法(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 起进程)
cursorACP(新会话)/ stream-json(旧会话)spawn agent … acpcli/src/cursor/utils/cursorAcpBackend.ts:80 createCursorAcpBackend
grokACPspawn grok …cli/src/grok/utils/grokBackend.ts:39 createGrokBackend
kimiACPspawn kimi acpcli/src/kimi/utils/kimiBackend.ts:20 createKimiBackend
opencodeACPspawn 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:1ACP_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:54convertAgentMessage 完成。有意思的是那个线格式类型 叫 CodexMessage(messageConverter.ts:6)——历史上 Codex 路径先落地,后来的 agent 复用了它 的形状,名字就留下来了。

2.5 权限:一次被挂起的反向请求

第三章讲过手机上"允许"按下去之后的那条链路。ACP 这边的入口在 AcpSdkBackend.ts:190:后端在 initialize 时向 transport 注册了 session/request_permission 的处理器。

真正的巧妙处在 AcpSdkBackend.ts:1114handlePermissionRequest——它不立即回,而是把 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 typeAgentRegistry.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:12FLAVOR_CAPS 是唯一事实来源,值是 Set<Capability>,能力名收在 :4Capabilities 常量里(model-change / effort),避免字面量散落。

flavor显示名换模型 model-change思考强度 effort
agyAntigravity
claudeClaude
codexCodex
dshDeepSeek Harness
copilotCopilot
cursorCursor
geminiGemini
grokGrok Build
kimiKimi
opencodeOpenCode
piPi

依据:shared/src/flavors.ts:12-24(能力),:27-39 FLAVOR_LABELS(显示名)。 gemini 仍列在表里是刻意的——它已不能启动,但历史会话还要能在 UI 里正确渲染; shared/src/modes.ts:10-12AGENT_FLAVORS 保留它,:18CREATABLE_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:1558showModelSettings:1563showEffortSettings;连快捷键都受管——:1342Cmd/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:41COMMANDS 里登记
顶层编排cli/src/cursor/runCursor.ts:28 runCursor建/续会话、注册 RPC、组装消息队列
双模循环cli/src/cursor/loop.ts:38 looprunLocalRemoteSession,在 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 / runLocalRemoteLoopcli/src/agent/loopBase.ts:8 / :27每家的 loop.ts
RemoteLauncherBase(抽象类)cli/src/modules/common/remote/RemoteLauncherBase.ts:41grok / kimi / opencode / cursor 的远程 launcher
BaseLocalLaunchercli/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. 注册 flavorshared/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.tsACP 的话就一个 new AcpSdkBackend({ command, args, env })
4. 会话对象cli/src/<f>/session.ts继承 AgentSessionBase
5. 双模循环cli/src/<f>/loop.tsrunLocalRemoteSession
6. 两个 launcher<f>LocalLauncher.ts / <f>RemoteLauncher.ts后者继承 RemoteLauncherBase
7. 命令入口cli/src/commands/<f>.ts + registry.ts:41
8. Runner 侧 argvcli/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:createterminal:openterminal:ready开一个终端 / 已就绪
terminal:writeterminal:output键盘输入 / 屏幕输出
terminal:resize改窗口大小
terminal:closeterminal: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:errorhub/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:331disconnect 处理器把该 socket 名下所有终端删掉,并逐个给 CLI 发 terminal:close,不留孤儿 shell。

还有一条容易忽略的校验:resolveEntryForSocket(terminal.ts:42)要求 entry.socketId === socket.id,resolveCliSocket(:48)要求 CLI socket 的 namespace 一致。 两条加起来意味着"知道 terminalId"本身不足以往别人的终端里写字。

5.4 CLI 侧:pty 与被删掉的环境变量

TerminalManager.openBun.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_TOKENANTHROPIC_API_KEYOPENAI_API_KEYTELEGRAM_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 拼命令行,:161event === '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:160waitForTunnelTlsReady 就是这道闸门。它每 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:329announceTunnelAccess 连着打印两个:

用途形态参数位置
浏览器 / PWA 直连https://<官方 web>/?hub=<隧道URL>&token=<CLI_API_TOKEN>hub + tokenstartHub.ts:339-345
原生伴侣 App 配对hapicompanion://bind?hub=…&code=…hub + codestartHub.ts:366-371

同一份信息、两个键名(token vs code)、两种 scheme。深链那份的注释说明了原因:PWA 用户扫到 会忽略,原生 App(Android / Wear OS)才会接管(startHub.ts:365)。二维码渲染失败被吞掉, 纯文本链接照常打印(:326:346)——外围能力失败不该拖垮主流程

整个 announceTunnelAccessvoid 调用(startHub.ts:386),不阻塞 "HAPI Hub is ready!" 的 输出。等 TLS 可能要几十秒,Hub 本身早就能用了。


7. 通知:一次事件,多通道扇出

7.1 结构

hub/src/notifications/notificationHub.ts:7NotificationHub 订阅 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)
PushNotificationChannelWeb Push(VAPID)+ SSE toast总是启用hub/src/push/pushNotificationChannel.ts:9(startHub.ts:249)
ServerChanChannelServer 酱配了 serverChanSendKeyhub/src/serverchan/channel.ts:16(startHub.ts:258)
HappyBotTelegram配了 bot tokenhub/src/telegram/bot.ts:30

VAPID 密钥由 hub/src/config/vapidKeys.tsgetOrCreateVapidKeys 自动生成并持久化 (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;加密只发生在隧道那一层的 TLScli/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 已被拒绝启动spawnSessionbuildCliArgs 两处显式抛错,只保留只读查看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

两点需要更正常见的说法:

  1. 不同命名空间用的不是同一套凭据。 /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 身份。

  2. 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,新增一家只要填 commandargs; 而三家原生 agent 仍各背一套客户端代码(query.ts 500+ 行、codexAppServerClient.ts 594 行)。


10. 代码地图

主题文件关键符号
统一消息模型cli/src/agent/types.tsAgentMessagePermissionRequestAgentBackend
后端注册表(未接线)cli/src/agent/AgentRegistry.tsAgentRegistry.register / .create / .list
泛型会话循环(未接线)cli/src/agent/runners/runAgentSession.tsrunAgentSession
ACP 传输层cli/src/agent/backends/acp/AcpStdioTransport.tsAcpStdioTransporthandleStdouthandleLinesendRequest
ACP 协议语义cli/src/agent/backends/acp/AcpSdkBackend.tsAcpSdkBackendinitializenewSessionprompthandlePermissionRequest
ACP 内容归一cli/src/agent/backends/acp/AcpMessageHandler.tsAcpMessageHandlerhandleUpdateflushTextflushReasoning
ACP 事件名常量cli/src/agent/backends/acp/constants.tsACP_SESSION_UPDATE_TYPES
线格式转换cli/src/agent/messageConverter.tsconvertAgentMessageCodexMessage
Codex 原生客户端cli/src/codex/codexAppServerClient.tsCodexAppServerClientstartThreadstartTurn
Claude 原生客户端cli/src/claude/sdk/query.tsqueryQuery
Pi 原生传输cli/src/pi/piTransport.tsPiTransport
能力表shared/src/flavors.tsFLAVOR_CAPShasCapabilitysupportsModelChangesupportsEffort
flavor 清单shared/src/modes.tsAGENT_FLAVORSCREATABLE_AGENT_FLAVORS
前端能力开关web/src/components/AssistantChat/HappyComposer.tsxshowModelSettingsshowEffortSettings
后端能力校验hub/src/web/routes/sessions.ts模型 / effort 路由里的 supportsModelChange / supportsEffort
cursor 双协议选路cli/src/cursor/cursorRemoteLauncher.tsutils/cursorProtocol.tscursorRemoteLauncherresolveCursorRemoteProtocolisLegacyCursorSession
cursor ACP 后端cli/src/cursor/utils/cursorAcpBackend.tscreateCursorAcpBackendbuildCursorAcpArgs
可复用基类cli/src/agent/sessionBase.tscli/src/agent/loopBase.tscli/src/modules/common/remote/RemoteLauncherBase.tsAgentSessionBaserunLocalRemoteSessionRemoteLauncherBase
终端:Web 侧web/src/hooks/useTerminalSocket.tsuseTerminalSocket
终端:Hub 记账hub/src/socket/terminalRegistry.tsTerminalRegistryscheduleIdleremoveByCliSocket
终端:Hub 转发hub/src/socket/handlers/terminal.tshandlers/cli/terminalHandlers.tsregisterTerminalHandlersresolveEntryForSocketresolveCliSocketcleanupTerminalHandlers
终端:CLI ptycli/src/terminal/TerminalManager.tsTerminalManagerresolveShellCommandSENSITIVE_ENV_KEYS
隧道进程hub/src/tunnel/tunnelManager.tsTunnelManagerspawnTunwgparseTunwgEvent
TLS 闸门hub/src/tunnel/tlsGate.tswaitForTunnelTlsReadycheckTunnelCertificate
二维码与深链hub/src/startHub.tsannounceTunnelAccesscompanionDeeplink
通知扇出hub/src/notifications/notificationHub.tsNotificationHubnotifyReadynotifyPermission
通知抑制hub/src/notifications/notificationSendContext.tshub/src/visibility/visibilityTracker.tsNativeDeliveryGateVisibilityTracker.hasVisibleConnection
gemini 下线cli/src/runner/run.tscli/src/commands/registry.tsspawnSessionbuildCliArgsremovedGeminiCommand