数据截至 (上游 commit c76af90d88f4)
第 2 章 · 本地/远程双模接管——HAPI 最核心的一招
本章讲什么: 你在终端里正跟 Claude Code 聊得好好的,起身去楼下买咖啡,掏出手机接着聊同一个会话;回到工位再接着在终端里聊。HAPI 让这件事成立的那套机制,就是本章的全部内容。
2.1 要解决的小问题
先说清楚难在哪。
同一个 agent 会话,有两种完全不同的跑法。
| 跑法 | 谁在跑 | 界面 | 消息怎么出来 |
|---|---|---|---|
| local(本地) | claude 二进制作为子进程,直接接管终端 TTY | 用户看到的是 Claude Code 原生 TUI | 只写进它自己的 JSONL 日志 |
| remote(远程) | HAPI 进程内调 SDK 的 query() | 终端里是 HAPI 自己画的 ink 界面 | 以 SDK 消息流的形式逐条拿到 |
两种跑法的 I/O 通道、渲染方式、消息来源全都不一样。
天真的做法是劫持 stdout——让 HAPI 夹在用户和 claude 之间,转发按键、解析终端输出。HAPI 没这么干:原生 TUI 有全屏重绘、有 ANSI 控制序列,解析它既脆又会把交互体验搞坏。
HAPI 的取舍是「让位」:本地模式下 HAPI 完全不碰 stdout,把终端整个让给 claude 子进程,自己退到旁边读 agent 自己写的会话日志来同步消息(§2.6)。
于是"交班"要解决三件事:
- 控制权:什么信号让本地进程停下、切到远程?反过来又怎么切回来?(§2.4)
- 上下文:两种跑法必须落在同一个 Claude 会话上,不能各聊各的。(§2.7)
- 可见性:本地模式下 HAPI 不碰 stdout,手机怎么还能看到对话?(§2.6)
2.2 状态机骨架:一个 while(true)
先看骨架。整套双模接管的控制流,浓缩在一个不到 40 行的循环里。
怎么读下图:两个模式互为对方的"下一站",只有返回 exit 才能跳出循环。
startingMode(默认 'local')
│
▼
┌────────────────────┐ runLocal 返回 'switch' ┌────────────────────┐
│ local 模式 │ ───────────────────────────►│ remote 模式 │
│ 终端里的 │ │ 进程内 SDK │
│ claude 子进程 │◄─────────────────────────── │ query() 流式会话 │
└────────────────────┘ runRemote 返回 'switch' └────────────────────┘
│ │
│ 返回 'exit' 返回 'exit' │
└───────────────► 整个 loop return ◄─────────────────┘
真实实现是 runLocalRemoteLoop(cli/src/agent/loopBase.ts:31):
while (true) {
if (mode === 'local') {
const reason = await opts.runLocal(opts.session);
if (reason === 'exit') { return; }
mode = 'remote';
opts.session.onModeChange(mode);
continue;
}
// remote 分支对称
}
上面这段是 loopBase.ts:41-84 的压缩片段(省了 debug 日志和对称的 remote 分支)——注意它只认两个返回值:'switch' 换边,'exit' 收工。所有复杂度都被推到 runLocal / runRemote 两个函数里去了。
外层包装 runLocalRemoteSession(loopBase.ts:8)只多做一件事:在进循环前回调 onSessionReady。
这个骨架是共享的。 Claude 只是它的一个实例——cli/src/claude/loop.ts:46 的 loop 把 runLocal: claudeLocalLauncher / runRemote: claudeRemoteLauncher 塞进去(接线在 :78-79);codex、cursor、grok、kimi、opencode 各自的 loop.ts 用同一个函数换自己的两个启动器(多 agent 抽象见 第 6 章)。
换边时还有一个副作用:session.onModeChange(mode)(cli/src/agent/sessionBase.ts:97-110)会立刻推一次 keepAlive,并经 createModeChangeHandler(cli/src/agent/runnerLifecycle.ts:244-249)向 hub 发 switch 事件、把 agentState 里的 controlledByUser 置成 mode === 'local'(runnerLifecycle.ts:224-242)。手机上那个「这个会话现在被终端占着」的状态,就来自这个字段——它在 §2.8 还会再登场。
2.3 本地模式:把子进程参数拼对
本地模式的全部工作,就是拼一条 claude 命令行并 spawn 它。函数是 claudeLocal(cli/src/claude/claudeLocal.ts:34)。
看着简单,但每个参数都在解决一个具体问题:
| 参数 | 解决什么 | 位置 |
|---|---|---|
--resume <sessionId> | 接上远程模式聊到一半的那个会话 | claudeLocal.ts:71-74 |
--append-system-prompt | 追加 HAPI 自己的系统提示(教 Claude 用 mcp__hapi__change_title 等工具) | claudeLocal.ts:76 |
--settings <hookSettingsPath> | 注入 hook 配置,让 Claude 把 SessionStart 回调打到 HAPI 的本地端口 | claudeLocal.ts:98 |
--add-dir <blobs 目录> | 把手机上传的附件目录(tmpdir()/hapi-blobs)加进可访问范围 | claudeLocal.ts:102 |
--allowedTools | 放行 HAPI 自己注入的 MCP 工具 | claudeLocal.ts:82-84 |
--model | 由 hub 侧状态决定,覆盖用户启动时传的值 | claudeLocal.ts:86-95 |
下面挑三处不显然的说。
① --resume 之前先验尸
不能拿着一个 sessionId 就往 --resume 上怼——会话文件可能已经被清掉了。所以先验证:
let startFrom = opts.sessionId;
if (opts.sessionId && !claudeCheckSession(opts.sessionId, opts.path)) {
startFrom = null;
}
(claudeLocal.ts:58-61)claudeCheckSession(cli/src/claude/utils/claudeCheckSession.ts:6)做两级检查:<projectDir>/<sessionId>.jsonl 存在吗?文件里至少有一行能解析出 uuid 吗?两条都过才算"这个会话是真的"。验不过就退化成"开个新会话",而不是让 claude 启动失败。
另外,如果用户自己在命令行里传了 --continue 或 --resume,HAPI 就不再插手(claudeLocal.ts:51-53 的 hasUserSessionControl),把会话控制权还给用户。
② 模型参数:hub 侧状态是权威
withoutTrackedModelArgs(args)(claudeLocal.ts:14-32)把用户 claudeArgs 里的 --model X 和 --model=X 整个剔掉,然后在末尾追加 hub 侧记录的模型:
const claudeArgs = opts.model === undefined || !opts.claudeArgs
? opts.claudeArgs
: withoutTrackedModelArgs(opts.claudeArgs);
if (claudeArgs) { args.push(...claudeArgs); }
if (opts.model) { args.push('--model', opts.model); }
(claudeLocal.ts:86-95)注释一句话说明了理由:Once model state is available, it is authoritative over startup args. 用户可能在手机上改过模型,这个改动存在 hub 的会话状态里;如果启动参数还带着旧模型,切回本地时就会悄悄退回旧模型。所以剔掉再补。
③ 那个非平凡的坑:必须删掉 CLAUDE_CODE_ENTRYPOINT
这是本章最值得记住的一条实现细节。
现象链条: HAPI 在启动早期为了拿 SDK 元数据,调过一次 SDK 的 query()。SDK 内部会在当前进程的环境变量里设 CLAUDE_CODE_ENTRYPOINT='sdk-ts'。如果之后 spawn 本地 claude 时把整个 process.env 原样继承下去,子进程就会认为自己也是被 SDK 启动的——而 Claude Code 会把 SDK 启动的会话排除在 claude --resume 的候选列表之外。结果就是:会话文件明明在,--resume 却找不到它。
修法是一行解构:
const { CLAUDE_CODE_ENTRYPOINT: _, ...cleanEnv } = process.env
(claudeLocal.ts:105-118,注释原文解释了整条因果链)后面 env 由 cleanEnv 加上 DISABLE_AUTOUPDATER: '1' 和用户自定义变量拼成(claudeLocal.ts:114-118),再交给 spawnWithTerminalGuard(claudeLocal.ts:128-140,shell: false,走绝对路径)。
这类坑的普遍教训: 进程内 SDK 和子进程 CLI 共用同一套环境变量命名空间时,SDK 对当前进程的副作用会顺着 process.env 泄漏给子进程。凡是"进程内调 SDK + 又要 spawn 同一个 CLI"的架构,都要专门扫一遍环境变量。
2.4 切换触发器:一条消息就等于换班
上一节讲了本地模式怎么启动,这节讲它怎么停下来、并告诉外层要换到 remote。
这层逻辑在 BaseLocalLauncher(cli/src/modules/common/launcher/BaseLocalLauncher.ts:39),所有 agent 共享。它的 run() 返回值就是 §2.2 里那个 'switch' | 'exit'。
三个触发源,一个出口
怎么读下图:三条触发路径汇到同一个 exitReason,然后统一走一次收尾。
手机发来一条消息 ──► queue.push ──► setOnMessage 回调 ─┐
│
手机按「切到远程」──► RPC 'switch' ──► doSwitch ────────┼─► exitReason = 'switch'
│
手机按「中断」────► RPC 'abort' ──► doAbort ───────────┘ (外加 queue.reset())
│
▼
abortController.abort()
│
▼
子进程被信号打断,launch() 返回/抛出
│
▼
run() 的 finally:exitFuture.resolve()
│
▼
RPC handler 此刻才返回给手机
注册代码只有五行:
rpcHandlerManager.registerHandler(RPC_METHODS.Abort, doAbort)
rpcHandlerManager.registerHandler(RPC_METHODS.Switch, doSwitch)
queue.setOnMessage(() => {
void doSwitch()
})
(BaseLocalLauncher.ts:92-96)第三行是整章最"举重若轻"的一处:把消息队列的到达回调直接挂成 doSwitch。于是"手机上发来一条消息"和"手机 上按了切换按钮"在实现上是同一件事——本地子进程被中止,run() 返回 'switch',外层循环切到 remote 模式,remote 模式的第一步就是从队列里取出那条消息。用户看到的效果是:在手机上打一句话,终端里的 Claude 就自动交班给远程会话去回答。
doAbort 与 doSwitch 的唯一区别
| 设 exitReason | 清空消息队列 | 语义 | |
|---|---|---|---|
doAbort(BaseLocalLauncher.ts:79-84) | 'switch' | 是(queue.reset()) | 用户按中断:手上的活和排队的消息都不要了 |
doSwitch(BaseLocalLauncher.ts:86-90) | 'switch' | 否 | 换个模式接着干:排队消息要留给 remote 模式消费 |
两者都通向 'switch'——abort 并不会让 HAPI 退出,只是把本地子进程停掉、回到远程模式待命。
收尾顺序:exitFuture 与 AbortController
abortProcess 的两步顺序很关键:
const abortProcess = async () => {
if (!this.abortController.signal.aborted) {
this.abortController.abort()
}
await this.exitFuture.promise
}
(BaseLocalLauncher.ts:72-77)
- 先发中止信号——
abortController.signal一路传到claudeLocal的spawnWithTerminalGuard,子进程被杀。 - 再等
exitFuture——这个 Future 只在run()的finally里 resolve(BaseLocalLauncher.ts:141-142)。
结果是:RPC handler 只有在本地子进程真的收完尾之后才返回。手机端拿到 abort 的成功回执时,终端里的 claude 已经确实退出了,而不是"信号已发出,死活不知"。
同一个 finally 还把两个 RPC handler 换成空实现、把 queue.setOnMessage(null)(BaseLocalLauncher.ts:143-145)——避免 remote 模式期间,本地模式的旧回调还在偷偷响应。
两个抢跑检查
进 launch 循环之前有两道短路(BaseLocalLauncher.ts:98-104):
if (this.exitReason) return this.exitReason—— 信号在注册 handler 的间隙就到了,别白启动一次子进程。if (queue.size() > 0) return 'switch'—— 队列里已经有消息在等,直接去 remote 模式,别让用户先看到一闪而过的本地 TUI。
启动失败时:exit 还是 switch?
如果 launch() 抛异常(claude 没装、参数非法、认证过期……),该退出整个 HAPI,还是退回远程模式?答案取决于这个会话背后有没有人守着终端:
export function getLocalLaunchExitReason(context: LocalLaunchContext): LocalLaunchExitReason {
if (context.startedBy === 'runner' || context.startingMode === 'remote') {
return 'switch';
}
return 'exit';
}
(cli/src/agent/localLaunchPolicy.ts:10-16)
- runner 起的会话(手机上凭空开的,见 第 5 章)或以 remote 起步的会话:终端前面没人,
exit等于把用户手机上的会话直接搞没。所以退回'switch',remote 模式继续待命。 - 用户自己在终端敲
hapi起的会话:exit是对的——报错信息在终端里,用户看得见。
失败信息也会经 sendFailureMessage 发到会话事件流(BaseLocalLauncher.ts:126),手机上能看到「Local Claude process failed: …」。
反过来,如果 launch() 正常返回且没人设过 exitReason,说明用户在终端里自己退出了 Claude——那就是 'exit',整个 HAPI 跟着收工(BaseLocalLauncher.ts:118-121)。
2.5 远程模式:一条能持续追加的 prompt 流
远程模式的入口是 claudeRemote(cli/src/claude/claudeRemote.ts:16)。它不 spawn 终端进程,而是调 @/claude/sdk 的 query(),拿到一条 SDK 消息流。
骨架:可推送的 prompt 流
普通用法里 query() 的 prompt 是一个字符串。HAPI 要的是一个能不断追加新用户消息的流,于是用 PushableAsyncIterable(cli/src/utils/PushableAsyncIterable.ts:10):
let messages = new PushableAsyncIterable<SDKUserMessage>();
messages.push({ type: 'user', message: { role: 'user', content: initial.message } });
const response = query({ prompt: messages, options: sdkOptions });
(claudeRemote.ts:107-226,此处压缩了中间的初始消息处理与 sdkOptions 组装)
PushableAsyncIterable 就是"队列 + 等待者列表"的最小实现:push() 时若有消费者在等就直接投递,否则入队(PushableAsyncIterable.ts:25-42);end() 结束流,setError() 让消费者抛错(:47-67)。它只允许被迭代一次(:126-132)。
scheduleNextMessage:为什么不能同步等下一条
拿到 result 消息之后,直觉写法是 await opts.nextMessage() 再继续消费流。HAPI 没这么写:
const scheduleNextMessage = () => {
if (nextMessageFetchInFlight || inputEnded) { return; }
nextMessageFetchInFlight = true;
void (async () => {
const next = await opts.nextMessage();
if (!next) { inputEnded = true; messages.end(); return; }
mode = next.mode;
messages.push({ type: 'user', message: { role: 'user', content: next.message } });
})();
};
(claudeRemote.ts:234-278,此处压缩了日志与错误分支)
调用点在 result 分支(claudeRemote.ts:370),源码注释给了理由:
Claude may emit autonomous async messages (e.g. scheduled tasks) after a result, and we must keep consuming those messages immediately.
Claude 在返回 result 之后仍可能自己再冒消息出来。 如果在 for await 循环体里同步 await nextMessage()(那是个可能永久阻塞的"等用户输入"),流的消费就会 卡住,这些自主消息只能干等着。所以用 void (async () => {...})() 起一个后台拉取,nextMessageFetchInFlight 做重入保护,主循环立刻回去继续消费流。
session id:等文件真的落盘
system / init 消息里已经带 session_id 了,但 HAPI 不马上用:
const found = await awaitFileExist(join(projectDir, `${systemInit.session_id}.jsonl`));
opts.onSessionFound(systemInit.session_id);
(claudeRemote.ts:296-323)注释写明了原因:Session id is still in memory, wait until session file is written to disk。awaitFileExist(cli/src/modules/watcher/awaitFileExist.ts:4)每秒 access() 一次,最多等 10 秒。
为什么非等不可: 下一次切回本地模式时,claudeLocal 要靠 claudeCheckSession 检查这个 .jsonl 存在才肯 --resume(§2.3)。若在文件落盘前就把 id 记进 metadata,本地模式很可能验尸失败、退化成开新会话——上下文就断了。这是"交班不丢上下文"链条上最容易被忽略的一环。 注意 awaitFileExist 超时也不阻断流程,只是 found 为 false 仍照常 onSessionFound(claudeRemote.ts:307-312)。
两个斜杠命令的特判
/clear 和 /compact 不能当普通 prompt 发出去,parseSpecialCommand(cli/src/parsers/specialCommands.ts,调用点 claudeRemote.ts:128)先把它们摘出来:
| 命令 | 处理 | 位置 |
|---|---|---|
/clear | 发 Context was reset 完成事件 → 调 onSessionReset() → 直接 return,根本不 spawn | claudeRemote.ts:129-138 |
/compact | 照常发给 Claude,但标记 isCompactCommand,先发一条 Compaction started | claudeRemote.ts:139-145 |
/compact 的结果判定有个细节:Claude 把压缩结果放在 result 之前的一条 system/status 消息里,所以要先把它接住暂存:
if (message.type === 'system' && message.subtype === 'status' && isCompactCommand) {
if (systemStatus.compact_result === 'failed') {
compactFailure = reason.length > 0 ? reason : 'Compaction failed';
}
}
(claudeRemote.ts:329-338)注释点出了这个设计的保守之处:只有明确报了 failed 才记失败,没看见状态或状态形状不认识,都走成功路径——"看不懂的状态不许凭空造出一个失败"。等到 result 到达时再据此发 Compaction completed 或 Compaction failed: …(claudeRemote.ts:354-361)。
/clear 的 onSessionReset 回调在上层把 session.sessionId 清空(claudeRemoteLauncher.ts:499-509),并消费掉一次性的 --resume 标志——否则用户刚清掉的会话,下次启动又被 resume 回来了。
2.6 本地模式下,消息怎么回传
回到 §2.1 提出的第三个问题:本地模式里 HAPI 完全不碰 stdout,手机上的对话是怎么实时更新的?
答案:读 agent 自己写的 JSONL 会话日志。
怎么读下图:从上到下是一条消息从子进程到手机的完整路径。
claude 子进程 ──写──► ~/.claude/projects/<项目 slug>/<sessionId>.jsonl
│
每 3s 轮询 + 文件 watcher 触发
▼
ClaudeSessionScanner(按字节游标增量读)
│ messageKey 去重
▼
claudeLocalLauncher 的 onMessage 过滤
│
▼
client.sendClaudeSessionMessage ──► hub ──► 手机
扫描器本体
createSessionScanner(cli/src/claude/utils/sessionScanner.ts:19)包一个 ClaudeSessionScanner(:45),后者继承通用的 BaseSessionScanner,构造时定 3 秒轮询:super({ intervalMs: 3000 })(sessionScanner.ts:54)。
三件事让它不至于把日志重复推一遍:
- 字节游标增量读。
readSessionLog(filePath, startByte)(sessionScanner.ts:201)只读游标之后的字节,成本与"新增内容"成正比、与会话已经多长无关。尾部半行(正在写入)留到下次扫描;但若尾段本身已经是一段完整 JSON(进程退出时刷盘没带换行),就当场消费掉,不让它烂在那儿(sessionScanner.ts:253-257)。 messageKey去重。messageKey(message)(sessionScanner.ts:157-171)给每类消息定一个稳定键:user/assistant/system 用uuid,summary 用leafUuid + summary,ai-title 用标题文本。基类拿它查processedEventKeys集合(cli/src/modules/common/session/BaseSessionScanner.ts:31、:169-194)。seedProcessedKeys播种历史。 启动时initialize()先把当前会话文件已有的消息全部标记为已处理(sessionScanner.ts:81-91):
const keys = events.map((entry) => messageKey(entry.event));
this.seedProcessedKeys(keys);
this.setCursor(sessionFile, nextCursor);
没有这一步,每次切回本地模式都会把整段历史当"新消息"重发一遍。
会话 id 中途变了(用户在 TUI 里 /clear),onNewSession(sessionId)(sessionScanner.ts:60-79)把旧 id 挪进 pendingSessions 再扫最后一轮,确保旧会话的收尾消息不丢,然后才归入 finishedSessions。
过滤规则:哪些不该出现在聊天里
扫出来的行不能原样往手机上推——JSONL 里混着一堆内部事件。过滤发生在 claudeLocalLauncher 的 onMessage(cli/src/claude/claudeLocalLauncher.ts:14-38):
| 消息 | 处理 | 理由 |
|---|---|---|
ai-title | 走 applySessionTitleFallback,不进聊天 | Claude 原生 TUI 生成的会话标题,是元数据 |
summary | 同上 | 老版本 transcript 把标题写成 summary |
isMeta / isCompactSummary | 丢弃 | skill 注入、压缩摘要等内部消息 |
!isClaudeChatVisibleMessage(...) | 丢弃 | init / stop_hook_summary 之类,推上去只会显示成一坨裸 JSON |
| 其余 | session.client.sendClaudeSessionMessage(message) | 真正的对话内容 |
applySessionTitleFallback(cli/src/claude/utils/sessionTitleFallback.ts:13)只在 metadata 里既没 name 也没 summary 时才写入,且截断到 80 字符(sessionTitleFallback.ts:3 的 MAX_FALLBACK_TITLE_LENGTH)——不覆盖用户手动改过的标题。
可见性判定收敛在 shared 层的 isClaudeChatVisibleMessage(shared/src/messages.ts:48):rate_limit_event / tool_progress 直接不可见;非 system 类型一律可见;system 则只放行白名单里的 subtype。CLI 侧只是薄薄一层转发(cli/src/claude/utils/chatVisibility.ts:4),保证 hub、web、CLI 对"什么算聊天消息"的口径一致。
这个取舍值多少
| 劫持 stdout | 读 agent 日志(HAPI 的选择) | |
|---|---|---|
| 原生 TUI 体验 | 被破坏 | 完全保留 |
| 实现复杂度 | 要解析 ANSI/全屏重绘 | 解析结构化 JSONL |
| 实时性 | 即时 | 最长约 3 秒延迟(文件 watcher 通常更快) |
| 耦合点 | 终端渲染格式 | 会话日志格式与落盘目录 |
代价是延迟和对日志格式的依赖,换来的是"本地模 式下 Claude Code 就是原汁原味的 Claude Code"。 这是整个 HAPI 最核心的产品取舍之一。
2.7 session id 从哪来:一个 loopback hook 服务器
remote 模式的 session id 来自 SDK 的 system/init(§2.5)。local 模式呢? 子进程的 stdout 没人读,--resume 时若本来就没有 id,新会话的 id 根本无从得知。
HAPI 的办法:起一个本地 HTTP 服务器,让 Claude 自己把 id 送上门。
HAPI 进程 claude 子进程
│ │
│ startHookServer() → 127.0.0.1:<随机端口> │
│ generateHookSettingsFile(port, token) │
│ ↓ 写出 settings JSON │
│ --settings <path> ──────────────────────────►│
│ SessionStart 触发
│ │
│◄── POST /hook/session-start ─────────────────┘
│ header: x-hapi-hook-token: <token>
│ body: { session_id, cwd, permission_mode?, ... }
▼
onSessionHook → currentSession.onSessionFound(id)
服务器侧(cli/src/claude/utils/startHookServer.ts:106):
- 只听环回地址、端口交给系统分配:
server.listen(0, '127.0.0.1', ...)(startHookServer.ts:265)。 - 每个请求校验
x-hapi-hook-token请求头,不匹配直接 401(startHookServer.ts:95-101、:61-67)——token 默认是randomBytes(16)随机生成(:55)。 - 只认
POST /hook/session-start,5 秒读体超时,JSON 解析失败 400,没有session_id则 422(startHookServer.ts:143-171)。 - 先派发
onSessionHook再回 200(startHookServer.ts:173-182),注释说明是为了让 HAPI 先记下事件、再让 agent 继续往下写。
配置侧(cli/src/modules/common/hooks/generateHookSettings.ts:105):把端口和 token 编进一条 hapi hook-forwarder --port … --token … 命令,写成 Claude 的 settings JSON 文件,路径就是 §2.3 里那个 --settings 参数。
这里有个细节值得记:HAPI 生成了两份 settings 文件。
| 文件 | 注册的 hook | 给谁用 |
|---|---|---|
session-hook-<pid>.json(runClaude.ts:198) | 只有 SessionStart | remote 模式的 SDK 进程 |
session-hook-local-<pid>.json(runClaude.ts:208,trackPermissionMode: true) | SessionStart + UserPromptSubmit + PreToolUse | local 模式的交互式 TUI |
理由写在 generateHookSettings.ts:41-48 和 runClaude.ts:202-207:后两个 hook 的 payload 带 permission_mode,于是用户在原生 TUI 里按 shift+tab 换的权限档位能被 HAPI 抓到,切到 remote 时继续沿用(runClaude.ts:174-193,并且专门 gate 在 currentSession?.mode === 'local',防止远程进程反过来跟 hub 抢状态)。而这两个 hook 每条 prompt / 每次工具调用都会阻塞 Claude,remote 模式用不上——权限状态本来就归 hub/RPC 管(见 第 3 章)。
两路 session id 最终汇到同一个入口。 无论是 hook 送来的还是 SDK init 报的,都调 session.onSessionFound(id)(cli/src/agent/sessionBase.ts:112-120):更新 this.sessionId、写进 metadata、通知所有注册回调(本地模式下扫描器就是靠这个回调换扫描目标,claudeLocalLauncher.ts:41-44)。
local 模式 remote 模式
claude 子进程 SDK query()
│ SessionStart hook │ system/init
▼ ▼
127.0.0.1:<port>/hook/session-start awaitFileExist(<id>.jsonl)
│ │
└─────────► session.onSessionFound(id) ◄─────┘
│
▼
session.sessionId ← 交班时唯一的上下文锚点
│
┌───────────────┴────────────────┐
▼ ▼
claudeLocal: --resume <id> claudeRemote: resume: <id>
这张图是本章的题眼:所谓"交班不丢上下文",落到实处就是两条采集路径喂同一个变量,两个启动器再从这个变量续上。
2.8 反向的交班:从手机把会话还给终端
前面讲的都是"本地 → 远程"。反过来——一个正在被手机操控的会话,怎么还给终端?
用户在某台机器上敲 hapi resume <sessionId>,链路是:
hapi resume hub 正在跑的 CLI 进程
│ │ │
│ POST /cli/sessions/:id/handoff-local │
├───────────────────►│ │
│ │ ① 不 active?直接成功 │
│ │ ② controlledByUser?409 already_local │
│ │ ③ 反向 RPC 'handoff-local' ─────────►│
│ │ setSessionEndReason('handoff')
│ │ cleanupAndExit(0)
│ │◄──── 会话变为 inactive ──────────────┘
│ │ ④ waitForSessionInactive(15s)
│◄── { ok: true } ───┤
│
▼
dispatchLocalResume(target) ← 在本机重新起一个本地会话
CLI 侧只有十行(cli/src/agent/localHandoff.ts:17-29):
rpcHandlerManager.registerHandler(RPC_METHODS.HandoffLocal, () => {
lifecycle.setArchiveReason('Handed off to local terminal')
lifecycle.setSessionEndReason('handoff')
setImmediate(() => { void lifecycle.cleanupAndExit(0) })
return { ok: true }
})
注意 setImmediate:先把 RPC 响应返回去,再退出进程。反过来写的话,进程可能在响应发出前就死了,hub 那边看到的是一次 RPC 失败。
hub 侧是 handoffSessionToLocal(hub/src/sync/syncEngine.ts:3351),四个阶段:
| 阶段 | 行为 | 位置 |
|---|---|---|
| 解析权限 | 命名空间对不上就 access_denied / session_not_found | syncEngine.ts:3352-3358 |
| 会话已不活跃 | 直接返回成功——本来就没人占着 | syncEngine.ts:3360-3362 |
前置检查 controlledByUser | 已经是某个终端在控制,返回 already_local(HTTP 409) | syncEngine.ts:3365-3371 |
| 发 RPC + 等真死 | rpcGateway.handoffSessionToLocal 后 waitForSessionInactive | syncEngine.ts:3374、:1914-1921 |
controlledByUser 这个字段,正是 §2.2 里 createModeChangeHandler 每次换模式都会更新的那个——双模状态机的输出,在这里变成了交班的准入条件:不能把一个已经被别的终端占着的会话再抢一次。
waitForSessionInactive(syncEngine.ts:3830)默认 15 秒、每 250ms 轮询一次会话的 active;超时就返回 handoff_failed。这一步是必须的:不等旧进程真死就去起新的本地会话,两个进程会同时读写同一个会话文件。
调用方 hapi resume(cli/src/commands/resume.ts:257-274)也做了同样 的前置判断,先本地拦一道 already controlled by a local terminal,再 await api.handoffSessionToLocal(...),最后才 dispatchLocalResume(target)。
2.9 消息队列:什么能合批,什么必须单独跑
MessageQueue2(cli/src/utils/MessageQueue2.ts:28)是手机消息进入 CLI 的第一站,也是 §2.4 里那个"一条消息 = 一次换班"的触发点。它只做一件不平凡的事:决定哪些排队消息可以合成一条 prompt 发出去。
四种推入方式
| 方法 | 清空已有队列 | 强制独占 | 用在哪 |
|---|---|---|---|
push(:42) | 否 | 否 | 普通用户消息(runClaude.ts:471)、/plan 带的 prompt(:423) |
pushImmediate(:78) | 否 | 否 | 见下方注 |
pushIsolated(:117) | 否 | 是 | 保留前面排队的消息,但自己绝不与人合批(cursor 侧在用) |
pushIsolateAndClear(:152) | 是 | 是 | /compact、/clear(runClaude.ts:401、:388) |
诚实备注:
pushImmediate与push除方法名和三条 debug 日志字符串外逐行等价(逐行对比MessageQueue2.ts:59-92与:78-108),差别只存在于注释声明的意图;当前源码里除测试外没有生产调用方。
合批规则:同 mode 才合批
取消息走 waitForMessagesAndGetAsString(MessageQueue2.ts:554),核心在 collectBatch(:341-392):
队头元素 firstItem
│
├─ firstItem.isolate === true ─► 只取这一条,立即返回
│
└─ 否则 ─► 一直取,直到遇到:
· modeHash 与队头不同的,或
· isolate === true 的
取到的若干条用 '\n' 拼成一条 message
modeHash 由构造时传入的 modeHasher 算出。Claude 侧的哈希口径(cli/src/claude/runClaude.ts:272-281)值得看一眼——它只把必须重启进程才能改变的东西算进哈希:
const messageQueue = new MessageQueue2<EnhancedMode>(mode => hashObject({
agentEnforcedMode: mode.permissionMode === 'plan' || mode.permissionMode === 'auto' ? mode.permissionMode : null,
model: mode.model,
effort: mode.effort,
// …fallbackModel / 系统提示 / allowedTools / disallowedTools
}));
上面注释解释了那个三元表达式:plan 和 auto 是 Claude 自己强制执行的模式,改它就必须带着新的 --permission-mode 重开进程;而 acceptEdits / bypassPermissions 是 HAPI 在 canCallTool 里模拟的,同一个进程内就能切,所以不该进哈希——否则每次权限档位微调都会白白重启一次会话。
为什么斜杠命令必须独占
/compact 会重写整个上下文,/clear 会丢弃它。要是跟别的 prompt 拼成 "/compact\n顺便把测试也跑一下" 发出去,Claude 面对的就是一条语义混乱的输入。所以这两条走 pushIsolateAndClear——不但自己独占,还把队列里已排的消息一起清掉(§2.5 里 claudeRemote 对它们的特判,正是建立在"到达时一定是单条"这个前提上)。
一条消息的完整旅程
手机 hub CLI(local 模式)
│ 消息 │ │
├──────────────────►│───── 推送 ────────►│ messageQueue.push()
│ │ │ └─► setOnMessage → doSwitch
│ │ │ exitReason='switch'
│ │ │ abort 子进程 → runLocal 返回
│ │ ▼
│ │ loopBase: mode = 'remote'
│ │ │
│ │ ▼
│ │ claudeRemote: nextMessage()
│ │ └─► collectBatch() 取出这条消息
│ │ ▼
│◄─── 回复流 ────────┤◄───── SDK 消息 ────┘
注意闭环: 触发换班的那条消息,正是换班后被消费的第一条消息——因为 doSwitch 有意不调 queue.reset()(§2.4)。这两处代码相隔很远,靠的是同一个队列对象把它们串起来。
2.10 巧妙之处(可以搬走的几招)
-
让位而不劫持。 本地模式完全放弃 stdout,改读 agent 自己的结构化日志(
sessionScanner.ts:201的字节游标增量读 +:157的messageKey去重 +BaseSessionScanner.ts:84的历史播种)。要给一个交互式 CLI 加远程能力,先问一句"能不能不碰它的终端"。 -
把"来消息"和"按切换"变成同一个动作。
queue.setOnMessage(() => { void doSwitch() })(BaseLocalLauncher.ts:94-96)一行代码,消掉了一整类"用户既在手机上发消息、又在终端里干活"的竞态。 -
中止响应要等真死。
abortProcess先abort()再await exitFuture.promise(BaseLocalLauncher.ts:72-77),保证 RPC 回执发生在子进程收尾之后。同样的思路在 hub 侧是waitForSessionInactive(syncEngine.ts:3830)。 -
拿到 id 不等于可以用 id。
awaitFileExist(<id>.jsonl)(claudeRemote.ts:307)等落盘才onSessionFound——因为下游claudeCheckSession校验的是文件不是内存。跨进程共享的标识符,要以「接收方能验证的那个形态」为准生效时机。 -
状态权威只能有一个。
withoutTrackedModelArgs(claudeLocal.ts:14)剔掉用户传的--model再补上 hub 侧的值;runClaude.ts:180-184把 hook 里的 permission_mode 继承 gate 在mode === 'local'。两处都是同一条纪律:同一个状态不许有两个写入方向同时生效。 -
失败策略取决于"有没有人看得见错误"。
getLocalLaunchExitReason(localLaunchPolicy.ts:10)用startedBy/startingMode判断终端前有没有人,再决定 exit 还是退回远程。 -
只信明确的失败信号。
/compact的结果判定只在compact_result === 'failed'时记失败(claudeRemote.ts:329-338),认不出的状态一律走成功路径——解析第三方状态时,不许凭"没看懂"造出一个错误。