数据截至 (上游 commit c76af90d88f4)
HAPI — 架构与原理
30 秒导读: HAPI 是一层包在官方 coding agent CLI 外面的遥控壳。你照常在终端里跑
claude/codex/cursor,HAPI 在旁边把这个会话注册到一个本机小服务(hub)上;人离开电脑后,用手机或浏览器接着聊同一个会话、批准同一批工具权限,回到座位敲两下空格就把控制权抢回终端。它不重写 agent,也不托管你的代码。
1. 这是什么(零基础也能懂)
一句话定义: HAPI 是 coding agent CLI 的本地优先远程遥控器——agent 还是官方那个 agent,只是多了一个手机端。
解决什么问题、给谁用。 场景很具体:你在终端里让 Claude Code 改一个大仓库,它跑了二十分钟,中途要你批准一次写文件。这时你在楼下买咖啡。传统做法是回来才发现它卡在等待确认上。HAPI 让这次确认弹到你手机上,一按通过,任务继续跑。
用户是在终端里用 agent、但不想被拴在座位上的工程师。
它能做什么:
- 把一个正在跑的本地 agent 会话镜像到手机 / 浏览器 / Telegram Mini App。
- 在两端之间无缝交接控制权:终端 ⇄ 远程,会话不重启、上下文不丢。
- 远程审批工具权限(读文件、写文件、跑命令)。
- 从手机上凭空在这台机器的某个目录里开一个新会话(靠 runner 守护进程)。
- 远程开一个真终端,直连这台工作机。
- 支持七种可启动的 agent:Claude Code、Codex、Cursor Agent、Grok Build、OpenCode、Kimi、Pi;gemini 只剩一条报错用的墓碑命令,历史会话仍可只读查看(
cli/src/commands/registry.ts:29、:38-58)。
用起来什么样:
npx @twsxtd/hapi hub --relay # 起 hub,并打通一条公网隧道
npx @twsxtd/hapi # 起一个 Claude Code 会话,照常在终端里用
第二条命令打完,终端里会打出一个 URL 和二维码。手机扫码就进了同一个会话。
一处措辞要澄清: README 把
--relay写成 "E2E encrypted relay"(README.md:24),但实际的安全边界是 传 输层 TLS + 数据不出本机,Hub ↔ CLI 之间传的是明文 JSON、身份只靠一个共享密钥。仓库自己的 runner 文档写得更准确:"No end-to-end encryption (TLS only)"(cli/src/runner/README.md:530)。详见第 6 章 §8。
一句话直觉: 把它想成给终端会话装了一个"投屏 + 遥控器"——屏幕内容双向同步,遥控器上的每个按键最终都要打回这台机器上真正执行。真正的运算、文件、密钥,一步都没离开你的电脑。
2. 顶层全景(它大概怎么转)
2.1 三方拓扑
怎么读这张图: 从左到右是一次远程操控的链路。注意 ② → ① 那条回程箭头——hub 主动往 CLI 打请求,这就是后面反复出现的"反向 RPC"。
┌──────────────── 你的机器 ────────────────┐
│ │
┌──────────────┐ │ Socket.IO /cli ┌──────────────────┐ │ SSE(下行推送) ┌──────────────┐
│ ① HAPI CLI │──┼──────────────────►│ ② HAPI Hub │───┼──────────────────►│ ③ 客户端 │
│ 包着官方 CLI │ │ │ SQLite + REST │ │ │ 手机/浏览器 │
│ │◄─┼───────────────────│ + Socket.IO │◄──┼───────────────────│ /Telegram │
└──────┬───────┘ │ rpc-request └──────────────────┘ │ REST(上行动作) └──────────────┘
│ │ (反向 RPC) │
│ spawn / --resume │
▼ └──────────────────────────────────────────┘
┌──────────────────────────┐
│ ④ 官方 agent 进程 │ claude / codex / cursor / grok / opencode …
└──────────────────────────┘
hub 默认就跑在你自己这台机器上;要从外网访问才需要隧道(hub --relay 或自建 Cloudflare Tunnel / Tailscale)。
2.2 部件一句话职责
| 部件 | 干什么 | 在哪 |
|---|---|---|
| HAPI CLI | 包住官方 agent CLI,跑本地/远程双模循环,注册反向 RPC 处理器 | cli/src/ |
| Hub | 会话与消息账本(SQLite)、Socket.IO 服务端、REST API、SSE 扇出 | hub/src/ |
| Web / PWA | React 单页应用,被打包内嵌进 hub 二进制 | web/src/ |
| Runner 守护进程 | 常驻后台,代表这台机器接受"远程开新会话"的请求 | cli/src/runner/ |
| 协议包 | CLI 与 hub 共享的类型、schema、RPC 方法名(包名 @hapi/protocol) | shared/src/ |
startHub() 是把这些串起来的地方:先建 Store(SQLite),再建 socket 服务,再建 SyncEngine1。装配顺序里的循环依赖与先后讲究,见第 1 章 §8。
2.3 主线走一遍(三条真实路径,不进代码)
主线 ① 一次会话诞生。 你敲 hapi → CLI 启动一个 Session 对象,连上 hub 的 /cli 命名空间 → hub 在 SQLite 建一行 session → SSE 把"新会话"广播给所有在线客户端 → 手机上列表里多了一条。
主线 ② 手机发一句话。 手机 POST 到 hub 的 REST → hub 写进消息账本(分配 seq)并投递到该会话的 socket 房间 → CLI 的消息队列收到 → 正在跑本地模式的启动器立刻中止本地进程,循环翻到远程模式 → 远程模式用 SDK 流式会话续上同一个 agent 会话,把这句话喂进去。
主线 ③ 一次权限审批。 agent 要写文件 → CLI 的权限处理器把请求挂进 agentState.requests 并同步给 hub → hub 经 SSE / 推送通知到手机 → 你按"允许" → 手机 POST 到 hub → hub 反向 RPC 打回 CLI → CLI 解开那个挂起的 Promise → agent 继续执行。
反方向还有一条不那么显眼但同样承重的路:你在终端里跟原生 agent 的对话,是怎么出现在手机上的。答案不是劫持 stdout,而是扫 agent 自己写的会话日志——见 §4.3。
2.4 最核心的那一招:双模接管
整个项目的价值锚点是下面这个循环。它只有三十行,但决定了 HAPI 的产品形态。
┌──────────── 全程复用同一个 agent 自己的 session id ────────────┐
│ │
┌────▼──────────────┐ 收到远程消息 / RPC switch ┌────────────────▼────┐
│ 本地模式 │ ────────────────────────────► │ 远程模式 │
│ 原生 agent 进程 │ │ SDK 流式会话 │
│(你在终端里敲) │ ◄──────────────── ──────────── │(手机/浏览器在敲) │
└───────────────────┘ 终端里连按两下空格 └─────────────────────┘
runLocalRemoteLoop() 就是一个 while(true):跑本地启动器,返回 'switch' 就翻到远程;跑远程启动器,返回 'switch' 就翻回本地;返回 'exit' 才结束2。Claude 那条线把 claudeLocalLauncher / claudeRemoteLauncher 塞进这两个槽3。
两个模式怎么共用一个会话? 靠 agent 自己的 session id。本地模式启动原生 CLI 时带 --resume <sessionId>4;远程模式把同一个 id 传给 SDK 的 resume 选项5。id 从两个地方捞:本地模式靠 Claude 的 SessionStart hook 回调一个本机 HTTP 端点6,远程模式靠 SDK 的 system/init 事件、并等 transcript 文件真的落盘才认7。
3. 阅读地图
各章按"由浅入深"排;如果你只想搞懂 HAPI 凭什么存在,读完本页直接跳第 2 章。
| 顺序 | 章节 | 讲什么 | 什么时候读 |
|---|---|---|---|
| 0 | 本页 | 全景、三条主线、精华、边界、代码地图 | 入口 |
| 1 | 三方拓扑与协议底座 | CLI / Hub / 客户端的边界,Socket.IO + REST + SSE 三条链路各承担什么,namespace 多租户与鉴权 | 想先建立骨架认知 |
| 2 | 本地/远程双模接管——HAPI 最核心的一招 | runLocalRemoteLoop、两种启动器、session id 的获取与 --resume 续接、JSONL 扫描、交接的边界情况 | 最该读的一章 |
| 3 | 反向 RPC 与权限审批:手机上按下允许之后发生了什么 | rpc-register / rpc-request 的寻址与超时、权限自动放行规则、审批 REST 路由 | 关心"控制流怎么反向流动" |
| 4 | Hub 的状态与同步:缓存、版本号、消息账本 | SessionCache、乐观并发的版本号 CAS、seq 单调消息账本、心跳与判死 | 关心一致性与数据模型 |
| 5 | Runner 守护进程:从手机上凭空开一个新会话 | 单实例锁、心跳、按二进制 mtime 自升级交接、本地控制面 HTTP、buildCliArgs | 关心进程生命周期 |
| 6 | 多 agent 抽象、远程终端与外围能力 | ACP 与原生两条路线、flavor 能力表、PTY 转发、隧道与推送通道 | 关心可扩展性与边界 |
三条推荐路线:
- 想学思想 → 本页 → 第 2 章 → 第 3 章。
- 想改代码 → 本页 §6 代码地图 → 第 1 章 → 对应专章。
- 想加一个新 agent → 本页 → 第 1 章 → 第 2 章 → 第 6 章 §4.3。
4. 巧妙之处(可借鉴的技术)
4.1 把"模式切换"降级成一个函数返回值
大多数遥控类项目会用状态机 + 事件总线管理"谁在控制"。HAPI 没有:启动器是一个返回 'switch' | 'exit' 的 async 函数,外层就是个 while 循环2。
妙在哪:控制权归属被压缩成一个纯粹的顺序问题,任何时刻只有一个启动器在跑,天然排除了两端同时驱动 agent 的竞态。
触发器也统一在一处:本地启动器把 abort / switch 两个 RPC 方法和消息队列的 onMessage 回调全挂到同一个 doSwitch 上8——手机发消息、手机点切换、手机点中止,走的是同一条退出路径。
4.2 用 agent 自己的 session id 当交接凭证
HAPI 没有自己发明"会话状态快照"。它认的是 agent 原生的 session id + --resume:本地模式给原生 CLI 加 --resume4,远程模式给 SDK 传 resume5。
妙在哪:上下文的持久化责任完全外包给了 agent 自己。HAPI 不需要理解 Claude 的 transcript 格式,也不承担丢上下文的风险。代价是它必须精确捕获这个 id——于是有了 hook HTTP 端点6 和"等 .jsonl 落盘再认"的保守判定7。
4.3 本地模式读 agent 自己的 JSONL,而不是劫持 stdout
本地模式下终端归原生 agent 独占,HAPI 一个字节都不碰它的 stdout。手机上之所以还能看到这段对话,是因为 CLI 侧另起了一个扫描器,去 tail agent 本来就在写的结构化会话日志(<projectDir>/<sessionId>.jsonl),按字节游标增量读、按消息 uuid 去重,再上报给 hub9。
妙在哪:原生 TUI 有全屏重绘和 ANSI 控制序列,解析它既脆弱又会破坏用户看到的画面;而 JSONL 是 agent 本来就要写的、结构化的、稳定的。代价是最长约 3 秒的轮询延迟(文件 watcher 通常更快),以及对上游日志格式和落盘目录的依赖。这条取舍是"本地优先"路线的题眼,展开见第 2 章 §2.6。