跳到主要内容

数据截至 (上游 commit c76af90d88f4)

第 2 章 · 本地/远程双模接管——HAPI 最核心的一招

本章讲什么: 你在终端里正跟 Claude Code 聊得好好的,起身去楼下买咖啡,掏出手机接着聊同一个会话;回到工位再接着在终端里聊。HAPI 让这件事成立的那套机制,就是本章的全部内容。

拓扑与协议见 第 1 章;手机上按「允许」之后的权限链路见 第 3 章;hub 怎么判断会话还活着见 第 4 章


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)。

于是"交班"要解决三件事:

  1. 控制权:什么信号让本地进程停下、切到远程?反过来又怎么切回来?(§2.4)
  2. 上下文:两种跑法必须落在同一个 Claude 会话上,不能各聊各的。(§2.7)
  3. 可见性:本地模式下 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:46looprunLocal: 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-53hasUserSessionControl),把会话控制权还给用户。

② 模型参数: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,注释原文解释了整条因果链)后面 envcleanEnv 加上 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)

  1. 先发中止信号——abortController.signal 一路传到 claudeLocalspawnWithTerminalGuard,子进程被杀。
  2. 再等 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/sdkquery(),拿到一条 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 diskawaitFileExist(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)先把它们摘出来:

命令处理位置
/clearContext was reset 完成事件 → 调 onSessionReset()直接 return,根本不 spawnclaudeRemote.ts:129-138
/compact照常发给 Claude,但标记 isCompactCommand,先发一条 Compaction startedclaudeRemote.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 completedCompaction failed: …(claudeRemote.ts:354-361)。

/clearonSessionReset 回调在上层把 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)。

三件事让它不至于把日志重复推一遍:

  1. 字节游标增量读。 readSessionLog(filePath, startByte)(sessionScanner.ts:201)只读游标之后的字节,成本与"新增内容"成正比、与会话已经多长无关。尾部半行(正在写入)留到下次扫描;但若尾段本身已经是一段完整 JSON(进程退出时刷盘没带换行),就当场消费掉,不让它烂在那儿(sessionScanner.ts:253-257)。
  2. 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)。
  3. 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 里混着一堆内部事件。过滤发生在 claudeLocalLauncheronMessage(cli/src/claude/claudeLocalLauncher.ts:14-38):

消息处理理由
ai-titleapplySessionTitleFallback,不进聊天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:3MAX_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)只有 SessionStartremote 模式的 SDK 进程
session-hook-local-<pid>.json(runClaude.ts:208,trackPermissionMode: true)SessionStart + UserPromptSubmit + PreToolUselocal 模式的交互式 TUI

理由写在 generateHookSettings.ts:41-48runClaude.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_foundsyncEngine.ts:3352-3358
会话已不活跃直接返回成功——本来就没人占着syncEngine.ts:3360-3362
前置检查 controlledByUser已经是某个终端在控制,返回 already_local(HTTP 409)syncEngine.ts:3365-3371
发 RPC + 等真死rpcGateway.handoffSessionToLocalwaitForSessionInactivesyncEngine.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)

诚实备注: pushImmediatepush 除方法名和三条 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
}));

上面注释解释了那个三元表达式:planautoClaude 自己强制执行的模式,改它就必须带着新的 --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 巧妙之处(可以搬走的几招)

  1. 让位而不劫持。 本地模式完全放弃 stdout,改读 agent 自己的结构化日志(sessionScanner.ts:201 的字节游标增量读 + :157messageKey 去重 + BaseSessionScanner.ts:84 的历史播种)。要给一个交互式 CLI 加远程能力,先问一句"能不能不碰它的终端"。

  2. 把"来消息"和"按切换"变成同一个动作。 queue.setOnMessage(() => { void doSwitch() })(BaseLocalLauncher.ts:94-96)一行代码,消掉了一整类"用户既在手机上发消息、又在终端里干活"的竞态。

  3. 中止响应要等真死。 abortProcessabort()await exitFuture.promise(BaseLocalLauncher.ts:72-77),保证 RPC 回执发生在子进程收尾之后。同样的思路在 hub 侧是 waitForSessionInactive(syncEngine.ts:3830)。

  4. 拿到 id 不等于可以用 id。 awaitFileExist(<id>.jsonl)(claudeRemote.ts:307)等落盘才 onSessionFound——因为下游 claudeCheckSession 校验的是文件不是内存。跨进程共享的标识符,要以「接收方能验证的那个形态」为准生效时机。

  5. 状态权威只能有一个。 withoutTrackedModelArgs(claudeLocal.ts:14)剔掉用户传的 --model 再补上 hub 侧的值;runClaude.ts:180-184 把 hook 里的 permission_mode 继承 gate 在 mode === 'local'。两处都是同一条纪律:同一个状态不许有两个写入方向同时生效。

  6. 失败策略取决于"有没有人看得见错误"。 getLocalLaunchExitReason(localLaunchPolicy.ts:10)用 startedBy / startingMode 判断终端前有没有人,再决定 exit 还是退回远程。

  7. 只信明确的失败信号。 /compact 的结果判定只在 compact_result === 'failed' 时记失败(claudeRemote.ts:329-338),认不出的状态一律走成功路径——解析第三方状态时,不许凭"没看懂"造出一个错误。


2.11 边界与局限

诚实地说几处:

  • 本地模式的消息有延迟。 轮询周期 3 秒(sessionScanner.ts:54),文件 watcher 会让常见情况更快,但没有"立即"保证。
  • 依赖 Claude Code 的日志格式与落盘位置。 getProjectPath 推出的 <projectDir>/<sessionId>.jsonl 一旦上游改动,本地模式的消息回传就会静默失效——解析器对认不出的行是静默跳过的(sessionScanner.ts:272-279)。
  • --resume 依赖会话文件仍在。 用户清理了 ~/.claude/projects,claudeCheckSession 就返回 false,交班退化成"开新会话",上下文断掉。代码里没有对此的补救路径。
  • awaitFileExist 超时不阻断。 10 秒没等到文件也照样 onSessionFound(claudeRemote.ts:307-312),此后一次本地交班可能验尸失败。
  • 反向交班有 15 秒硬超时。 旧进程收尾慢过 15 秒,handoffSessionToLocal 返回 handoff_failed(syncEngine.ts:3383-3388),需要用户重试。
  • CLAUDE_CODE_ENTRYPOINT 这类坑是"外部约定"。 上游改了变量名或语义,claudeLocal.ts:113 的解构就白做了,而且是静默失效——表现为"--resume 莫名其妙找不到会话"。

2.12 代码地图

主题文件关键符号
双模状态机骨架cli/src/agent/loopBase.tsrunLocalRemoteSessionrunLocalRemoteLoop
Claude 的两个启动器接线cli/src/claude/loop.tsloopclaudeLocalLauncherclaudeRemoteLauncher
本地子进程参数拼装cli/src/claude/claudeLocal.tsclaudeLocalwithoutTrackedModelArgs
会话文件验尸cli/src/claude/utils/claudeCheckSession.tsclaudeCheckSession
切换触发与收尾cli/src/modules/common/launcher/BaseLocalLauncher.tsBaseLocalLauncherdoAbortdoSwitchabortProcess
启动失败的 exit/switch 策略cli/src/agent/localLaunchPolicy.tsgetLocalLaunchExitReason
远程 SDK 会话cli/src/claude/claudeRemote.tsclaudeRemotescheduleNextMessage
可推送 prompt 流cli/src/utils/PushableAsyncIterable.tsPushableAsyncIterable
等会话文件落盘cli/src/modules/watcher/awaitFileExist.tsawaitFileExist
本地消息回传(扫描)cli/src/claude/utils/sessionScanner.tscreateSessionScannerClaudeSessionScannermessageKeyreadSessionLog
扫描器基类(去重/游标)cli/src/modules/common/session/BaseSessionScanner.tsBaseSessionScannerseedProcessedKeys
本地消息过滤规则cli/src/claude/claudeLocalLauncher.tsclaudeLocalLauncher
聊天可见性口径shared/src/messages.tsisClaudeChatVisibleMessage
标题兜底cli/src/claude/utils/sessionTitleFallback.tsapplySessionTitleFallbackcreateSessionTitleFallback
session id 回传服务器cli/src/claude/utils/startHookServer.tsstartHookServerreadHookToken
hook 配置文件生成cli/src/modules/common/hooks/generateHookSettings.tsbuildHookSettingsgenerateHookSettingsFile
session id 汇聚点cli/src/agent/sessionBase.tsonSessionFoundonModeChange
模式变更副作用cli/src/agent/runnerLifecycle.tssetControlledByUsercreateModeChangeHandler
反向交班(CLI 侧)cli/src/agent/localHandoff.tsregisterLocalHandoffHandler
反向交班(hub 侧)hub/src/sync/syncEngine.tshandoffSessionToLocalwaitForSessionInactive
反向交班(HTTP 入口)hub/src/web/routes/cli.tsPOST /sessions/:id/handoff-local
反向交班(发起方)cli/src/commands/resume.tsresumeCommand
消息队列与合批cli/src/utils/MessageQueue2.tsMessageQueue2collectBatchpushIsolateAndClear
modeHasher 口径cli/src/claude/runClaude.tsnew MessageQueue2<EnhancedMode>(...)

下一章: 换班时那些"手机上按下允许"的工具调用是怎么走完一整圈的 —— 第 3 章 · 反向 RPC 与权限审批