跳到主要内容

数据截至 (上游 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 / PWAReact 单页应用,被打包内嵌进 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 路由关心"控制流怎么反向流动"
4Hub 的状态与同步:缓存、版本号、消息账本SessionCache、乐观并发的版本号 CAS、seq 单调消息账本、心跳与判死关心一致性与数据模型
5Runner 守护进程:从手机上凭空开一个新会话单实例锁、心跳、按二进制 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 回调全挂到同一个 doSwitch8——手机发消息、手机点切换、手机点中止,走的是同一条退出路径。

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

4.4 一行环境变量剔除,换回原生 --resume 能力

远程模式用 SDK 时会把 CLAUDE_CODE_ENTRYPOINT='sdk-ts' 写进当前进程的环境。如果这个变量泄漏进后续本地 spawn,Claude Code 会认为该会话是 SDK 起的,从而把它从 claude --resume 的候选列表里排除——交接直接断掉。

修法是在 spawn 前把它解构掉10:

const { CLAUDE_CODE_ENTRYPOINT: _, ...cleanEnv } = process.env

妙在哪:这是"包别人的 CLI"这条路线最典型的税——你得知道宿主的每一个隐性开关。这一行是整份代码里性价比最高的注释之一。

4.5 反向 RPC:把方法名当路由键

hub 需要主动调 CLI,但 CLI 在 NAT 后面、没有可访问地址。HAPI 的做法是让 CLI 在连上后把自己能处理的方法名注册到 hub,方法名带 scope 前缀(${sessionId}:permission)11;hub 侧维护 方法名 → socketId 的映射表12,调用时按方法名找到那条 socket,用 emitWithAckrpc-request 并等 ack13

手机 REST ──► hub.rpcCall("sess-42:permission")

├─ 查表:sess-42:permission → socket #7

socket#7.emitWithAck('rpc-request', {...})
│ 30s 超时

CLI 的 RpcHandlerManager 分发 → 解开挂起的 Promise

妙在哪:没有引入任何 RPC 框架,socket.io 的 ack 机制就当 request/response 用了;断线时表项随 socket 一起清掉,调用方拿到一个语义明确的 RpcTargetMissingError("CLI 不在" ≠ "调用超时")14

4.6 版本号 CAS:多写者下的 metadata 不打架

session 的 metadataagentState 可能被 CLI、hub、手机同时改。HAPI 用每字段一个版本号做乐观并发:SQL 更新语句里带 AND version = @expectedVersion,命中就版本 +1,没命中就把当前值和版本回给调用方15;CLI 侧收到 version-mismatch 就先写回最新值再抛错,外层 backoff 基于新值重跑一遍 handler16

妙在哪:整套并发控制没有锁、没有事务嵌套,只有一条带 WHERE 的 UPDATE,而且冲突时服务端顺手把最新值捎回来了——客户端不必再发一次 GET。

4.7 消息账本:per-session 单调 seq + localId 幂等

消息表里每条消息有一个在该 session 内单调递增seq(插入时算 MAX(seq)+1),以及一个可选的客户端生成 localId;带 localId 的插入会先查重,重复直接返回已有行17

妙在哪:两个字段各解决一件事——seq 让 CLI 重连后可以"取 seq 大于游标的消息"做增量补发;localId 让手机在网络抖动下重发不会造成重复消息,同时它也是这条消息唯一的 ack 路径(所以没有 localId 的消息插入时就直接盖上 invoked_at,否则会永远卡在"排队中")。

4.8 SSE 扇出按订阅面过滤,并结合前台可见性

hub 广播时逐连接判断该不该发:先比 namespace(事件没带 namespace 一律丢弃),再看这条连接订阅的是"全部"还是某个 sessionId / machineId18。此外还有一个 VisibilityTracker 记录每条 SSE 连接当前是不是前台可见19,toast 类事件只发给可见连接,不可见时才升级成系统级推送。

妙在哪:推送优先级的判断放在服务端而不是客户端,省掉了手机在后台白白解析事件流的开销,也顺手解决了"页内提示 + 系统推送重复弹两次"的问题。

4.9 spawn 前后守住终端状态

包别人的 TUI 有个恶心的副作用:子进程可能留下未复位的 ANSI 转义状态。HAPI 的 spawnWithTerminalGuard 在 spawn 前 process.stdin.pause(),finally 里 resume 并调 restoreTerminalState()20

妙在哪:双模来回切换意味着一个终端里反复 spawn/kill 原生 TUI,没有这层保护,切几次终端就花了。这是"本地优先"路线必须自己扛的成本。


5. 边界与局限(诚实说)

这几条是通读全篇后能立住的判断,每条都在专章里有展开:

  • 落盘是明文的。 会话与消息存在本机 SQLite,项目自己在对比表里写的是 "Plaintext (protected by OS)"(docs/guide/why-hapi.md:139)。安全边界是"数据不离开你的机器",不是加密存储;--relay 加密的是传输,不是落盘。
  • Hub ↔ CLI 只有共享密钥。 /cli 命名空间认的是长期的 CLI_API_TOKEN,浏览器侧才是 4 小时期限的 JWT。直连二维码里带的正是那个长期 token,拿到二维码等于拿到 CLI 身份(第 1 章 §6、第 6 章 §8)。
  • 单进程、单 Hub,不做水平扩展。 房间路由和 SSE 连接表都是进程内的 Map,版本号 CAS 也建立在"只有一个 hub 进程在写"这个前提上(第 4 章 §10)。
  • RPC 要求目标此刻在线。 路由键查不到 socket 就直接抛 RpcTargetMissingError,没有离线队列语义(第 3 章 §10)。
  • 本地模式镜像有延迟。 扫描器 3 秒轮询,且依赖上游 agent 的日志格式与落盘目录;上游一改,消息回传会静默失效(第 2 章 §11)。
  • Runner 的本地控制面没有鉴权。 五个端点全靠"只绑 127.0.0.1"防护;不配 --workspace-root 时,远程可以指定这台机器上的任意路径开会话(第 5 章 §10)。
  • 它不是"更好的 agent"。 HAPI 完全不碰模型、提示词、工具实现,能力天花板由上游 CLI 决定。

6. 代码地图(导航索引)

按符号名 grep 比按行号更抗上游漂移;行号 as-of 9d07857

6.1 双模接管(最核心)

主题文件路径符号名锚点
本地/远程交替主循环cli/src/agent/loopBase.tsrunLocalRemoteLooprunLocalRemoteSession:27(外层 :6)
Claude 线的双模装配cli/src/claude/loop.tsloop:46(两个启动器接线在 :74-81)
本地启动器通用骨架cli/src/modules/common/launcher/BaseLocalLauncher.tsBaseLocalLauncherdoAbortdoSwitch:39(doAbort :79doSwitch :86)
本地 spawn 原生 CLIcli/src/claude/claudeLocal.tsclaudeLocal:34(--resume:71-74)
远程 SDK 流式会话cli/src/claude/claudeRemote.tsclaudeRemote:16(resume 选项在 :135)
本地模式的 JSONL 扫描cli/src/claude/utils/sessionScanner.tscreateSessionScannerClaudeSessionScanner:19:45(3 秒轮询在 :54)
会话基类(心跳、模式变更)cli/src/agent/sessionBase.tsAgentSessionBaseonModeChange:33(onModeChange:106)
远程 → 本地交棒 RPCcli/src/agent/localHandoff.tsregisterLocalHandoffHandler:17
终端里连按两下空格cli/src/ui/ink/useSwitchControls.tsuseSwitchControls:13(双空格确认在 :99-110)
session id 的 hook 捕获cli/src/claude/utils/startHookServer.tsstartHookServer:53(路由 :60、取值 :111)

6.2 协议与反向 RPC

主题文件路径符号名锚点
RPC 方法名总表shared/src/rpcMethods.tsRPC_METHODS:1-41
CLI 侧处理器注册cli/src/api/rpc/RpcHandlerManager.tsRpcHandlerManagerregisterHandlergetPrefixedMethod:18:29:89
CLI 侧接收入口cli/src/api/apiSession.tsApiSessionClient(rpc-request 监听):315-317
Hub 侧方法→socket 表hub/src/socket/rpcRegistry.tsRpcRegistrygetSocketIdForMethod:3:51
Hub 侧发起调用hub/src/sync/rpcGateway.tsRpcGateway.rpcCallRpcTargetMissingError:391:45
Socket 命名空间与鉴权hub/src/socket/server.tscreateSocketServer:50(/cli/terminal:89-90)
CLI 连接上的处理器装配hub/src/socket/handlers/cli/index.tsregisterCliHandlers:56(断线清表 :142)

6.3 权限审批

主题文件路径符号名锚点
自动放行规则(按 permission mode)cli/src/modules/common/permission/BasePermissionHandler.tsresolveToolAutoApprovalDecision:57
挂起请求的通用基类cli/src/modules/common/permission/BasePermissionHandler.tsBasePermissionHandleraddPendingRequest:127:158
审批 REST 路由hub/src/web/routes/permissions.tscreatePermissionsRoutes:29
审批下发到 CLIhub/src/sync/rpcGateway.tsRpcGateway.approvePermission:88

6.4 Hub 状态、同步与扇出

主题文件路径符号名锚点
Hub 装配入口hub/src/startHub.tsstartHub:102(Store :170、socket :179、SyncEngine :200)
同步引擎(对外唯一门面)hub/src/sync/syncEngine.tsSyncEngine:145
内存会话缓存hub/src/sync/sessionCache.tsSessionCachehandleSessionAlive:17:215
版本号 CAS 更新hub/src/store/versionedUpdates.tsupdateVersionedField:20-61
CLI 侧版本 ack 处理cli/src/api/versionedUpdate.tsapplyVersionedAck:23
CLI 侧 metadata 更新cli/src/api/apiSession.tsupdateMetadataupdateAgentState:913:958
消息账本hub/src/store/messages.tsaddMessagegetDeliverableMessagesAfter:42:210
SSE 扇出与过滤hub/src/sse/sseManager.tsSSEManager.broadcastshouldSendsendToast:107:150:73
SSE 订阅端点hub/src/web/routes/events.tscreateEventsRoutes:36
前台可见性追踪hub/src/visibility/visibilityTracker.tsVisibilityTracker.isVisibleConnection:45(hasVisibleConnection :40)

6.5 Runner 与远程开会话

主题文件路径符号名锚点
Runner 主体(信号、锁、心跳、自升级)cli/src/runner/run.tsstartRunner:32
真正拉起新会话进程的那段cli/src/runner/run.tsspawnSession:406
远程 spawn 的命令行拼装cli/src/runner/run.tsbuildCliArgs:1305
远程会话的启动标记cli/src/runner/run.ts--hapi-starting-mode remote --started-by runner:1339
本地控制面 HTTPcli/src/runner/controlServer.tsstartRunnerControlServer:14(/session-started :38/spawn-session :107、只绑 127.0.0.1 :194)
单实例锁(wx 独占 + PID 自愈)cli/src/persistence.tsacquireRunnerLockreleaseRunnerLock:232(open(..., 'wx') :239、陈旧锁自愈 :244-253、释放 :273)
Runner 身份哈希与漂移判定cli/src/runner/runnerIdentity.tshashRunnerCliApiTokenisRunnerStateCompatibleWithIdentity:11:31

6.6 多 agent 抽象与终端

主题文件路径符号名锚点
Agent 后端注册表(尚未接线,见第 6 章 §2.6)cli/src/agent/AgentRegistry.tsAgentRegistry:3
Agent 后端接口与统一消息模型cli/src/agent/types.tsAgentBackendAgentMessage:97:31
统一 agent 会话跑法(尚未接线)cli/src/agent/runners/runAgentSession.tsrunAgentSession:31
flavor 能力表shared/src/flavors.tsFLAVOR_CAPShasCapability:12:40
CLI 侧终端进程cli/src/terminal/TerminalManager.tsTerminalManagerSENSITIVE_ENV_KEYS:129:30
Hub 侧终端路由hub/src/socket/terminalRegistry.tsTerminalRegistry:14(idle 定时器 :113)
终端状态守卫cli/src/utils/spawnWithTerminalGuard.tsspawnWithTerminalGuard:8-14

Footnotes

  1. hub/src/startHub.ts:109(startHub 本体)、:170(new Store(config.dbPath))、:179(createSocketServer({...}))、:200(new SyncEngine(store, socketServer.io, socketServer.rpcRegistry, sseManager))。

  2. cli/src/agent/loopBase.ts:31,runLocalRemoteLoop:while (true) 里按 moderunLocal / runRemote,返回 'exit' 才 return,否则翻转 mode 并调 session.onModeChange(mode);外层 runLocalRemoteSession:6 2

  3. cli/src/claude/loop.ts:74-81,把 claudeLocalLauncherclaudeRemoteLauncher 作为 runLocal / runRemote 传给 runLocalRemoteSession

  4. cli/src/claude/claudeLocal.ts:71-74:if (startFrom && !hasUserSessionControl) { args.push('--resume', startFrom) };startFrom 先经 claudeCheckSession 校验(cli/src/claude/claudeLocal.ts:58-61)。 2

  5. cli/src/claude/claudeRemote.ts:164:resume: startFrom ?? undefined 2

  6. cli/src/claude/utils/startHookServer.ts:106(startHookServer)、:60(POST /hook/session-start)、:111(从 session_id / sessionId 取值);hook settings 路径由 --settings 传给原生 CLI(cli/src/claude/claudeLocal.ts:98)。 2

  7. cli/src/claude/claudeRemote.ts:302-313:注释写明 "Session id is still in memory, wait until session file is written to disk",先 awaitFileExist(...)<sessionId>.jsonl 落盘,才 opts.onSessionFound(...) 2

  8. cli/src/modules/common/launcher/BaseLocalLauncher.ts:92-96:registerHandler(RPC_METHODS.Abort, doAbort)registerHandler(RPC_METHODS.Switch, doSwitch)queue.setOnMessage(() => { void doSwitch() })

  9. cli/src/claude/utils/sessionScanner.ts:19(createSessionScanner)、:45(ClaudeSessionScanner)、:54(super({ intervalMs: 3000 }));上报走 cli/src/api/apiSession.ts:881(sendClaudeSessionMessage)。

  10. cli/src/claude/claudeLocal.ts:105-118,注释原文说明 SDK 元数据提取会设 CLAUDE_CODE_ENTRYPOINT='sdk-ts',泄漏后该会话会被 claude --resume 排除;解构在 :113

  11. cli/src/api/rpc/RpcHandlerManager.ts:89-91(getPrefixedMethod${scopePrefix}:${method})、:38(socket.emit('rpc-register', ...));session 客户端的 scopePrefix 就是 sessionId(cli/src/api/apiSession.ts:295),machine 客户端是 machineId(cli/src/api/apiMachine.ts:135)。

  12. hub/src/socket/rpcRegistry.ts:12(methodToSocketId.set(method, socket.id))、:51(getSocketIdForMethod);断线时 registerCliHandlersrpcRegistry.unregisterAll(socket)(hub/src/socket/handlers/cli/index.ts:142)。

  13. hub/src/sync/rpcGateway.ts:496(rpcCall)、:402-405(socket.timeout(timeoutMs).emitWithAck('rpc-request', { method, params: JSON.stringify(params) }));默认超时 30s(:35),模型列表类调用放宽到 120s(:36)。CLI 侧接收在 cli/src/api/apiSession.ts:344-346

  14. hub/src/sync/rpcGateway.ts:50-62,RpcTargetMissingError 区分 handler-not-registeredsocket-disconnected

  15. hub/src/store/versionedUpdates.ts:20-61,updateVersionedField:UPDATE ... WHERE id = @id AND namespace = @namespace AND <versionField> = @expectedVersion(:31);changes === 1 才算成功(:40-41),否则回读当前行并返回 version-mismatch 加当前值(:44-58)。

  16. cli/src/api/versionedUpdate.ts:23(applyVersionedAck:version-mismatch 时先写回最新值再抛错)配合 cli/src/api/apiSession.ts:1276(updateMetadata)里的 metadataLock.inLock(... backoff(...))

  17. hub/src/store/messages.ts:103-178,addMessage:SELECT COALESCE(MAX(seq), 0) + 1 取下一个 seq,带 localId 时先按 (session_id, local_id) 查重,无 localId 的消息插入时直接盖 invoked_at;增量补发见 getDeliverableMessagesAfter(:210)。

  18. hub/src/sse/sseManager.ts:251(broadcast)与 :150(shouldSend:事件缺 namespace 直接丢,再按 all / sessionId / machineId 匹配)。

  19. hub/src/visibility/visibilityTracker.ts:45(isVisibleConnection),在 hub/src/sse/sseManager.ts:217sendToast 里被用到;hasVisibleConnection(:40)决定通知走页内 toast 还是系统推送。

  20. cli/src/utils/spawnWithTerminalGuard.ts:8-14:spawn 前 process.stdin.pause(),finallyprocess.stdin.resume() + restoreTerminalState()