跳到主要内容

数据截至 (上游 commit eb980a5c9eea)

远程回合:SDK 驱动、工具授权与消息成型

30 秒导读: 上一章讲了会话怎么从终端切到手机。这一章讲切过去之后: 你在手机上敲一句话,到 Claude 真的动手改文件、你在手机上看到一条条气泡冒出来——中间这一整趟 在 CLI 进程里到底发生了什么。三件事:谁在驱动对话工具凭什么被允许执行消息怎么被 塑形成手机能渲染的样子


1. 先说清楚:"一个远程回合"指什么

回合(turn)= 从你发出一条消息,到 Claude 说完、停下来等你的下一句。 中间可能有十几次工具调用。

远程模式和本地模式最根本的差别在谁在跑 Claude

本地模式远程模式(本章)
Claude 怎么跑终端里的 Claude Code 子进程,人对着终端打字CLI 用 Claude Agent SDK 在进程内起一个 query
谁提供下一条用户消息键盘手机 → 服务器 → CLI 的消息队列
工具授权谁答终端里按 y/n手机上弹卡片,点一下
CLI 看到的对话内容事后读 .jsonl 转录文件实时拿到结构化的 SDKMessage

所以远程模式下 CLI 不是旁观者,而是司机:它握着 SDK 的方向盘,自己决定什么时候喂下一条消息、 什么时候放行一个工具、什么时候把消息推给手机。


2. 顶层全景:一个回合的三条流水线

先给一句"怎么读这张图":中间那根横条是 SDK,上面是"往里推的东西",下面是"往外流的东西"。

[手机] [手机]
│ ▲
加密消息 │ │ 加密信封
▼ │
┌────────────────┐ ┌──────────────────┐
│ 消息队列 │ │ 线协议信封 │
│ MessageQueue2 │ │ SessionEnvelope │
└────────┬───────┘ └─────────▲────────┘
│ nextMessage() │ 映射
▼ │
┌────────────────────────────────────────────────────────────────────┐
│ claudeRemote —— SDK 驱动器 │
│ query({ prompt: 可推送的异步迭代器, options }) │
└───────┬───────────────────────┬────────────────────────┬───────────┘
│ canUseTool │ for await (SDKMessage) │
▼ ▼ ▼
┌───────────────┐ ┌─────────────────┐ ┌──────────────────┐
│ 授权闸门 │ │ onMessage 管线 │ │ 250ms 节流队列 │
│ 挂起 → 手机点 │ │ 转换/补字段/补造 │ │ OutgoingMsgQueue │
└───────┬───────┘ └─────────────────┘ └──────────────────┘
│ 挂到 agentState.requests → 手机弹卡片 → RPC 回来 resolve
└──────────────────────────────────────────────────────────►

各部件一句话职责:

部件干什么文件
claudeRemote起 SDK query、喂消息、消费消息、管会话 idpackages/happy-cli/src/claude/claudeRemote.ts
query() 包装把 Happy 自己的 QueryOptions 翻译成官方 SDK 的 Optionspackages/happy-cli/src/claude/sdk/query.ts
claudeRemoteLauncher外层长循环 + onMessage 成型管线packages/happy-cli/src/claude/claudeRemoteLauncher.ts
PermissionHandler工具授权判定 + 挂起 + RPC 回收packages/happy-cli/src/claude/utils/permissionHandler.ts
SDKToLogConverterSDK 消息 → Claude 转录文件(JSONL)形状packages/happy-cli/src/claude/utils/sdkToLogConverter.ts
OutgoingMessageQueue保序 + 250ms 延迟 + 提前释放packages/happy-cli/src/claude/utils/OutgoingMessageQueue.ts
mapClaudeLogMessageToSessionEnvelopesJSONL 形状 → 线协议事件信封packages/happy-cli/src/claude/utils/sessionProtocolMapper.ts

3. 机制一:SDK 驱动 —— 对话是"拉"出来的,不是"推"进去的

3.1 它要解决的小问题

官方 SDK 的 query() 接受一个 prompt。如果 prompt 是一个字符串,那就是一问一答、跑完就结束。 但远程会话要长活:用户可能十分钟后才发下一句,期间进程不能退。

3.2 思路:prompt 传一个"能随时往里塞东西"的异步迭代器

Happy 的做法是把 prompt 传成一个自制的可推送异步迭代器 PushableAsyncIterable, 先塞进初始消息,然后 query() 就一直挂在那儿等下一条(claudeRemote.ts:157-171,符号 messages / query)。

// 示意,非源码 —— 演示"可推送的 prompt 流"这个想法
const messages = new PushableAsyncIterable() // 一个永不主动结束的队列
messages.push(firstUserMessage) // 先塞第一句
const response = query({ prompt: messages, options })

for await (const m of response) { // SDK 吐出的每条消息
if (m.type === 'result') { // 这一轮说完了
const next = await waitForPhoneMessage() // 等手机发下一句(可能等很久)
next ? messages.push(next) : messages.end()// 塞进去 → 下一轮自动开始
}
}

重点看:result 是回合的分界线,也是"去拉下一条用户消息"的触发点

3.3 真实实现:result 分支干了四件事

claudeRemote.ts:326-359if (message.type === 'result') 里,顺序是:

  1. updateThinking(false) —— 手机上的"思考中"转圈停掉;
  2. scheduleUsageFlush() —— 把这一轮攒的额度事件刷成一次 agentState 写入(claudeRemote.ts:192-253,符号 flushUsageLimits);
  3. opts.onReady() —— 通知外层"回合结束";
  4. 不 await 地调 opts.nextMessage(),拿到就 messages.push,拿到 nullmessages.end()

第 4 步刻意不 await,源码注释点明了原因:后台任务消息(task_started 等)要在等用户输入期间继续流过来 ——await 会把整个消息循环卡住。

3.4 什么时候必须重启 query:mode hash

nextMessage() 的实现在 launcher 里(claudeRemoteLauncher.ts:324-389)。它做一件关键的事: 比对模式指纹

新消息到达

├─ hash 与本次 query 启动时相同 ──► 直接返回,继续同一个 query

└─ hash 不同 / 消息标了 isolate ──► 存进 pending,返回 null
└─► query 结束 → 外层 while 重开一个 query

指纹怎么算见 runClaude.ts:517-526new MessageQueue2<EnhancedMode>(mode => hashObject({...})))。 注意它放进 hash 的是 isPlan: mode.permissionMode === 'plan'不是完整的 permissionMode

这个取舍的后果很实在:

模式变化hash 变吗结果
defaultplan重启 query(plan 模式要改 SDK 启动参数)
defaultbypassPermissions不变不重启——必须用别的办法热切
换 model / 改 appendSystemPrompt / 改 effort重启 query

"别的办法"就是 §4.6 的 setPermissionMode

3.5 会话 id:等文件真的落盘

SDK 的 system/init 消息里已经带了 session_id,但 Happy 不马上信它——它要等 ~/.claude/projects/<项目>/<id>.jsonl 这个转录文件真的出现在磁盘上,最多等 30 秒 (claudeRemote.ts:292-309,符号 awaitFileExist / onSessionFound)。

原因是本地模式靠读这个文件工作(见 第 3 章 §3.5),id 先行注册会让后续逻辑扑空。 等超时了也不硬扛:注册 id、同时给手机推一条 ⚠️ Claude session did not produce a transcript 的服务消息, 让"卡住"这件事在 UI 上可见,而不是变成一个沉默的死实例。

另一头,/clear 走的是完全不同的路径:claudeRemote.ts:104-113 直接触发 onSessionReset() (launcher 里 session.clearSessionId())然后立刻 return,连 query 都不起——上下文重置本来就不需要问模型。


4. 机制二:工具授权 —— 把一次工具调用变成手机上的一张卡片

这是本章工程含量最高的一段。

4.1 它要解决的小问题

Claude 想跑 rm -rf build/。终端模式下你按 y;远程模式下,批准的人和跑代码的机器隔着一个加密中继。 CLI 必须:把这次调用冻住 → 让手机知道有事要批 → 等一个可能永远不来的回答 → 醒来后放行或拒绝。

4.2 挂钩点:canUseTool

官方 SDK 提供 canUseTool 回调。Happy 在 sdk/query.ts:72-78 把自家的 canCallTool 直接桥接过去, claudeRemote.ts:139 再把当前 mode 闭包进去,最终指向 PermissionHandler.handleToolCall (launcher 里 claudeRemoteLauncher.ts:320 完成接线)。

这个回调返回 {behavior:'allow'} 之前,那个工具就不会执行。 整个授权体系全部立在这一点上。

4.3 判定顺序(这张表就是策略本身)

handleToolCallpermissionHandler.ts:146-205,自上而下短路:

#条件结果行号
1toolName === 'AskUserQuestion'强制挂起,即使 bypass 模式:151-153
2Bash 且 command 命中字面量白名单放行:160-162
3Bash 且 command 命中前缀白名单放行:164-168
4非 Bash 且工具名在 allowedTools放行:170-172
5descriptor.exitPlan(ExitPlanMode)强制挂起:177-180
6当前模式 bypass 等价(bypassPermissions/yolo放行:186-188
7acceptEdits 模式 且 descriptor.edit放行:190-192
8plan 模式 且 工具不 dangerous放行:196-198
9以上都不中挂起,等手机:204

两个"强制挂起"的位置各有讲究,它们不在同一档

  • AskUserQuestion(规则 1)排在所有白名单之前,任何配置都绕不过去。源码注释说这是在对齐 SDK 内部的 requiresUserInteraction() 判断。
  • ExitPlanMode(规则 5)排在白名单之后、模式类自动放行(规则 6–8)之前, 所以它挡的是 bypassPermissions / acceptEdits / plan 这三种"整段模式自动放行", 但仍然可以被用户显式加进 allowedTools 的白名单放过去。

共同的道理是一样的:这两个工具语义上就是"要人回答的问题",模式级的自动批准等于把问题吞掉。

"dangerous / edit / exitPlan"这三个标签由一个只有 11 行的纯函数给出 (getToolDescriptor.ts:1-12,符号 getToolDescriptor):

工具editexitPlandangerous
ExitPlanMode / exit_plan_mode
Edit MultiEdit Write NotebookEdit
Bash
其它(Read/Glob/Grep…)

Bash 白名单的语法解析在 permissionHandler.ts:285-309(符号 parseBashPermission): Bash(npm test) 进字面量集合,Bash(git:*) 剥掉 :* 进前缀集合,光秃秃的 Bash 直接忽略 ——否则一次"允许 Bash"就等于永久放开所有命令。

4.4 挂起长什么样:pendingRequests ↔ agentState.requests

挂起的实现是一个手工保存 resolve 的 PromisepermissionHandler.ts:214-279,符号 handlePermissionRequest), 同时向外做三件事:

handlePermissionRequest(id, toolName, input, signal, toolUseId)

├─(1) pendingRequests.set(id, { resolve, reject, toolName, input }) ← 进程内的"钥匙"

├─(2) onPermissionRequestCallback(id) ──► 立刻冲掉节流队列里这条工具调用(§6)

├─(3) push().sendSessionNotification({ kind: 'permission', ... }) ← 手机推送

└─(4) updateAgentState(s => ({ ...s, requests: { ...s.requests, [id]: {...} } }))

└── 这一份是「双端共享的待办清单」

pendingRequestsagentState.requests同一件事的两个副本,分工明确:

存在哪存了什么谁读
pendingRequestsCLI 进程内存resolve / reject 函数只有本进程
agentState.requests加密同步到服务器工具名、参数、createdAt手机(渲染卡片)

请求 id 有个细节:子 agent 里的工具调用会被加上前缀,getPermissionRequestId 返回 `${agentID}:${toolUseID}`permissionHandler.ts:207-209)。为了让 App 仍能把卡片挂到正确的 工具气泡上,agentState 条目里额外带一个原始的 toolUseId 字段(:270-273)。 反向查找则用后缀匹配,并且匹配到两条就返回 undefined——宁可查不到,不敢猜错 (:417-435,符号 getResponseForToolUseId)。

4.5 回答从哪来:RPC handler

手机点"允许"之后,答案经加密 RPC 中继落到 permissionHandler.ts:366-407 注册的 'permission' handler 上。它:

  1. pendingRequests 取出钥匙,取不到就静默返回(说明已经被 reset 或超时清掉了);
  2. 把响应存进 responses(带 receivedAt 时间戳,后面成型管线要用);
  3. handlePermissionResponse 真正 resolve;
  4. agentState.requests[id] 挪到 completedRequests[id],带上 status: 'approved' | 'denied'

handlePermissionResponsepermissionHandler.ts:85-140)里有两条不同的分支:

  • 普通工具approved{behavior:'allow', updatedInput}updatedInput 是"原参数 ∪ 手机改过的参数", 也就是手机可以在批准的同时改工具入参。拒绝时给一段很长的 deny 文案,明确告诉模型"编辑没写进去,停下来等人"。
  • ExitPlanMode:107-127):批准要先把 SDK 的权限模式切成手机选的那个(默认 default),切完再 allow ——顺序反了的话,退出 plan 模式后的第一个工具调用会撞上还没更新的旧策略。

响应里如果带 allowTools,会在 resolve 之前先并进白名单(:91-99),这就是 App 上"总是允许"的落地方式。

4.6 热切换:setPermissionMode

§3.4 说过 default → bypassPermissions 不改 hash、不重启 query。那怎么让 SDK 真的进入 bypass?

claudeRemote.ts:174-178 在 query 建好之后,把 response.setPermissionMode 交给 onQueryReady 回调;launcher 在 claudeRemoteLauncher.ts:413-417 把它注册进 PermissionHandler。 于是 handleModeChangepermissionHandler.ts:60-72)在检测到映射后的模式变了时,直接推给活着的 query:

// permissionHandler.ts:67-71 —— 真实源码片段
if (this.setPermissionModeCallback && mapToClaudeMode(previousMode) !== mapToClaudeMode(mode)) {
this.setPermissionModeCallback(mapToClaudeMode(mode)).catch((err) => {
logger.debug('Failed to sync permission mode via SDK:', err);
});
}

这行 mapToClaudeMode 很关键:Happy 内部有 7 种模式(要同时描述 Claude 和 Codex), Claude SDK 只认 4 种。映射表是唯一收口点(permissionMode.ts:19-26):

Happy 模式Claude SDK 模式
default / acceptEdits / bypassPermissions / plan原样透传
yolobypassPermissions
safe-yolodefault
read-onlydefault

注意这里"比较映射后的值"而不是原值——yolobypassPermissions 映射结果相同,来回切不该白白惊动 SDK。 同一个文件里的 resolveRemoteClaudePermissionModepermissionMode.ts:117-132)处理另一个坑: 老版本 App 会给每条消息都带上 permissionMode: "default",不拦住的话会把一个 yolo 会话默默降级。

4.7 reset():清理上一个进程留下的鬼魂

reset()permissionHandler.ts:327-361)做四件事:清白名单、清 responses、把 permissionMode 打回 defaultreject 所有挂起的 Promise,并把 agentState 里的 requests 全部挪进 completedRequests'canceled'

它被调用的地方有讲究:

调用点时机理由
claudeRemoteLauncher.ts:115进程刚启动时上个 CLI 进程死在半路,服务器上还留着 requests 条目,手机永远在转圈——新进程内存里没这个 id,点什么都没用
claudeRemoteLauncher.ts:298检测到会话 id 变了新会话不继承旧会话的白名单
claudeRemoteLauncher.ts:486每次 query 结束的 finally收尾

第一条那个 reset('Previous CLI process exited before responding') 是纯粹的"跨进程垃圾回收", 源码里配了 9 行注释解释这个幽灵态,值得一读。

4.8 同一套骨架:Codex / Gemini / ACP

Claude 的 PermissionHandler 是独立类,但另外三个后端共用一个抽象基类 BasePermissionHandlerpackages/happy-cli/src/utils/BasePermissionHandler.ts:46), 被 CodexPermissionHandlerGeminiPermissionHandler 和 ACP 的 GenericAcpPermissionHandlerpackages/happy-cli/src/agent/acp/runAcp.ts:407)继承。

骨架相同、细节不同:

能力Claude PermissionHandlerBasePermissionHandler
结果类型{behavior:'allow' | 'deny'}(SDK 定义){decision: 'approved' | 'approved_for_session' | 'denied' | 'abort'}
判定逻辑有(9 级短路链)无——由子类实现
挂起 + agentState 双写有(:132-148 addPendingRequestToState
abortAll()(用户按停止)有(:157-193),resolve 成 'abort' 而不是 reject
重连后换 session 引用有(:65-70 updateSession
重入保护有(isResetting 旗标,:199-206

基类里有个注释很值得抄:addPendingRequestToState先把同 id 的 completed 条目删掉再写 pending, 因为 App 端 reducer 给 completed 更高优先级——Codex 一个 item 可能因沙箱升级连续发起多次审批, 不删的话第二次请求永远不渲染,provider 就永久挂死(BasePermissionHandler.ts:123-131 的注释)。


5. 机制三:onMessage 管线 —— 把 SDK 消息塑成手机能吃的形状

SDK 每吐一条消息,claudeRemote 就调一次 launcher 的 onMessageclaudeRemoteLauncher.ts:139-276)。 这个函数做了七件事,按执行顺序:

onMessage(SDKMessage)

① 写终端 UI 缓冲 formatClaudeMessageForInk

② assistant 里的 tool_use → 登记 ongoingToolCalls.set(id, {parentToolCallId})

③ AskUserQuestion → 推送(去重一次) notifiedQuestionToolCalls

④ user 里的 tool_result → 注销 + 释放 ongoingToolCalls.delete / releaseToolCall

⑤ 转成 JSONL 形状 sdkToLogConverter.convert
│ └─ 给 tool_result 补 permissions 字段

⑥ 入队(工具调用延迟 250ms) messageQueue.enqueue

⑦ Task 工具 → 补造一条 sidechain 首消息 convertSidechainUserMessage

5.1 ② + ④:ongoingToolCalls 是给"中断"准备的

这个 Map 记录"已经发出、还没收到结果"的工具调用及其父调用 id(claudeRemoteLauncher.ts:136:145-155:176-188)。

正常情况下它进进出出、始终清空。它真正的用处在 finally 块(claudeRemoteLauncher.ts:466-473): 用户按了停止、或 query 抛异常时,里面剩下的每一条都会被补造一条中断结果:

// claudeRemoteLauncher.ts:466-472 —— 真实源码节选(略去中间一行 logger.debug)
for (let [toolCallId, { parentToolCallId }] of ongoingToolCalls) {
const converted = sdkToLogConverter.generateInterruptedToolResult(toolCallId, parentToolCallId);
if (converted) { session.client.sendClaudeSessionMessage(converted); }
}

generateInterruptedToolResultsdkToLogConverter.ts:291-334)造的是一条 tool_result + is_error: true + 内容 [Request interrupted by user for tool use] 的用户消息。

为什么必须补: 手机 UI 是靠"tool_use 开始 / tool_result 结束"配对渲染的。不补的话,那个工具气泡 会永远停在转圈状态,而且第 7 节讲的线协议里那条 tool-call-end 永远不会发出去。 这是"用状态机记账,异常路径上手工平账"的典型做法。

5.2 ③:AskUserQuestion 的额外推送

getAskUserQuestionToolCallIdspackages/happy-cli/src/claude/utils/questionNotification.ts)扫出 assistant 消息里所有 name === 'AskUserQuestion' 的 tool_use id,每个 id 只推一次 (notifiedQuestionToolCalls 去重,claudeRemoteLauncher.ts:157-174)。

这条和 §4.3 第 1 条是同一件事的两面:授权闸门负责"卡住不让它自动过",这里负责"戳一下用户的手机"。

5.3 ⑤:sdkToLogConverter 与 permissions 字段

SDKToLogConverter.convertsdkToLogConverter.ts:113-134)把 SDK 的内存消息塞回 Claude 转录文件的 JSONL 形状: 补 uuid / timestamp / cwd / sessionId / gitBranch,并维护父子链 parentUuid

它维护两条链:主链用 lastUuid,子 agent 链用 sidechainLastUUID(按 parent_tool_use_id 分桶)。 新会话开始时 launcher 调 resetParentChain() 把主链清零(claudeRemoteLauncher.ts:299)。

转换之后,launcher 在 claudeRemoteLauncher.ts:194-228 做一件 converter 自己做不了的事: 给每个 tool_result 内容块补一个 permissions 字段,内容取自 PermissionHandler 的 responses:

字段来源
dateresponse.receivedAt(手机点击的时刻)
result'approved' / 'denied'
mode手机批准时顺带选的权限模式(可选)
allowedTools手机勾的"总是允许"列表(可选)

这样一条工具结果消息自带"它当初是怎么被批准的"这段审计信息,App 端不用再去别处对账。

5.4 ⑦:给 Task 工具补造一条 sidechain 首消息

Claude 用 Task 工具起子 agent 时,SDK 不会把"派给子 agent 的那段 prompt"作为一条独立消息发出来 ——它只在 Taskinput.prompt 里。手机上就会看到子 agent 凭空开始说话,没有开场白。

Happy 手工补一条(claudeRemoteLauncher.ts:262-275 + sdkToLogConverter.ts:262-289): 造一条 isSidechain: trueparent_tool_use_id = Task 的 id、内容就是那段 prompt 的用户消息, 并顺手把它的 uuid 登记成该 sidechain 的链头。

5.5 图片附件:信魔数,不信 mimeType

用户从手机发图片过来时,nextMessage 要把附件拼成 Anthropic API 的 image 内容块 (claudeRemoteLauncher.ts:349-379)。这里有个踩过的坑:

线协议上的 att.mimeType ─✗ 不信任

解密后的字节 att.data ───────┴──► detectClaudeImageMime(bytes)
├ 89 50 4E 47 → image/png
├ FF D8 FF → image/jpeg
├ 47 49 46 38 → image/gif
├ RIFF....WEBP → image/webp
└ 都不匹配 → null → 丢弃这张图 + debug 日志

原因写在 claudeRemoteLauncher.ts:530-538 的注释里:iOS 图片选择器会报 image/heic、 或者干脆不给 mimeType,而 Anthropic API 对 media_type 有严格枚举校验,不匹配就是 HTTP 400 ——整条消息都发不出去。宁可丢一张图,也不能让整个回合失败。判定函数本体在 :539-557


6. 机制四:250 毫秒的节流

6.1 它要解决的小问题

Claude 发出 tool_use 之后,工具往往几十毫秒就跑完了。如果 CLI 立刻把 tool_use 推给手机, 手机会先渲染一个"正在执行"的转圈,再几乎立刻替换成结果——闪一下,很难看。

6.2 思路:工具调用消息压 250ms 再发,但可以被提前放行

OutgoingMessageQueue 是一个严格保序的队列(OutgoingMessageQueue.ts:20)。每条消息拿一个自增 id, 只有队头被"释放"了才能发,队头没释放就整队停住(:109-133,符号 processQueueInternal)—— 这保证了延迟机制不会把消息顺序打乱。

顶层工具调用消息入队时带 250ms 延迟(claudeRemoteLauncher.ts:243-255); 其余消息 delay 为 0,入队即视为已释放。

注意 sidechain 的工具调用不延迟:245 判断 parent_tool_use_id !== undefined 就走无延迟分支。

6.3 两个提前释放的触发点

enqueue(tool_use, delay 250ms)

├─ 250ms 定时器到 → 释放
├─ 收到该 tool_use 的 tool_result → 释放 (launcher:184)
└─ 该 tool_use 触发了权限请求(要给用户看卡片了) → 释放 (launcher:123-125)

第三条是这个设计的点睛之笔:PermissionHandler.setOnPermissionRequest 注册的回调直接调 messageQueue.releaseToolCall(toolCallId)既然马上要让用户批准这个工具,那这个工具的卡片必须立刻可见 ——否则手机上会出现"有一张授权卡片,但对应的工具调用还没显示出来"的错位。

其余细节:flush():147-163)在每次 query 结束时把所有延迟一次性放掉; type === 'system' 的消息只出队不发送(:124),它们只是本地日志。


7. 机制五:线协议成型 —— turn / tool-call 的事件流

7.1 为什么还要再变一次形

到这里消息还是"Claude 转录文件 JSONL"的形状,它是 Claude 特有的。但 Happy 要同时接 Claude、Codex、Gemini、ACP 四种后端,App 不想为每种后端写一套渲染。

所以最后一道工序把它压成一套与后端无关的事件信封SessionEnvelopepackages/happy-wire/src/sessionProtocol.ts:109-148)。

7.2 事件类型

sessionEventSchema 是一个九选一的可辨识联合(sessionProtocol.ts:95-105):

事件 t含义关键字段
turn-start / turn-end一个回合的开合turn-endstatus: completed | failed | cancelled
text一段文字textthinking?: true 表示这是思考内容
service系统提示条(强制 role: 'agent'text
tool-call-start / tool-call-end工具调用的开合call(= tool_use id)配对
start / stop子 agent 的开合start 可带 title
file图片/文件附件refsizeimage.thumbhash

信封外层则携带路由信息:turn(cuid2)、subagentclaudeUuid / codexItemId(供 App 做精确 fork 点)、 以及可选的 usage(上下文占用表)。createEnvelope:162-174)是唯一的构造入口, 它内部直接 sessionEnvelopeSchema.parse(...)——不合法的信封在生产侧就抛,不会流到线上。

7.3 回合边界在哪里合上

sessionProtocolMapper.ts 里维持一个 currentTurnId 状态,规则很简单:

第一条 agent 消息到达 ──► ensureTurn():没有 turn 就生成一个 + 发 turn-start (mapper:399-408)

│ 期间所有 agent 事件都挂在这个 turn 上

合上的三种方式:
① 一条真正的用户消息(非 sidechain、非 tool_result)到达 → closeTurn('completed') (mapper:633 / :655)
② 回合正常结束 → launcher 的 onReady 调 closeClaudeSessionTurn('completed') (launcher:431)
③ 中断/异常 → closeClaudeSessionTurn('cancelled' | 'failed') (launcher:451 / :457)

closeTurnmapper:450-463)不只发 turn-end:它先给所有还活着的子 agent 补 stop 事件, 再清空全部 subagent 追踪表。又是一次"异常路径手工平账",和 §5.1 的 ongoingToolCalls 同构。

映射器还负责几个"不该出现在聊天里"的过滤:

被丢弃的东西判据行号
summary / system 消息类型判断:524-536
/compact 生成的摘要isCompactSummary(由 claudeRemote.ts:266-269 打标):538-543
SDK 注入的合成用户消息isMeta(如 Skill 工具把 skill 正文喂回模型):621-626

最后那条注释写得很直白:不过滤的话,一段 10–20k 字符的 skill 正文会以 agent 文本的形式糊在聊天里。

Task 工具本身的 tool-call 事件也被刻意隐藏(shouldHideParentToolCallmapper:32-34 / :576-585) ——App 用子 agent 的 start/stop 来表达它,不需要再画一个工具气泡。

7.4 这个协议自己承认还没定型

文件顶部有一段少见的自陈(sessionProtocol.ts:1-14):

UNDER REVIEW - NEEDS MORE CAREFUL DESIGNTypes are kept here for reference but are frozen. Do not add new consumers.

它明说这套信封"是当前生产者/消费者之间的兼容契约,不是最终的跨 agent 标准",并建议在继续投入之前 先看 pi.dev 的 agent 协议是否值得对齐。读这个仓库时值得记住:这一层是可替换的胶水,不是地基。


8. 巧妙之处(可以直接抄走的)

① 把"待批准"做成双端共享状态,而不是一条消息。 一条通知会丢、会重复、会在客户端重启后消失;agentState.requests 是一份幂等的、会同步的清单。 新进程一上来先 reset() 清鬼魂,UI 状态就永远和进程真实状态一致 (permissionHandler.ts:264-275 + claudeRemoteLauncher.ts:115)。

② 用"模式指纹"决定重启还是热切。 能热切的(权限模式)走 setPermissionMode,不能热切的(model / systemPrompt / plan)才重开 query。 指纹函数 runClaude.ts:517-526 就是这条边界的唯一定义处,一眼可审。

③ 授权请求可以顺手改工具入参。 updatedInput 与原参数浅合并(permissionHandler.ts:130-133),于是"批准"和"批准但把命令改一下"是同一条路径。

④ 节流的提前释放挂在权限请求上。 250ms 是为了防闪,但一旦这个工具要弹卡片,防闪就必须让位于因果一致性。用一个回调把两个模块接起来, 比在队列里塞条件判断干净(claudeRemoteLauncher.ts:123-125)。

⑤ 异常路径全部手工平账。 中断时补 tool_result:466-473)、关回合时补子 agent stopmapper:459)—— 凡是"开了必须合"的成对事件,都在 finally 里兜一遍。

⑥ 不信任上游给的元数据,信字节。 图片类型按魔数判定(:539-557)。同理,会话 id 按转录文件真的落盘判定,不按 SDK 说的算(claudeRemote.ts:295)。

⑦ 模糊匹配宁可放弃也不猜。 getResponseForToolUseId 的后缀匹配一旦命中两条就返回 undefinedpermissionHandler.ts:429-431)。


9. 边界与局限

  • 协议未定型。 sessionProtocol.ts 自己写着"frozen / do not add new consumers"(:13)。

  • 权限判定的 plan 模式偏宽。 第 8 条规则放行一切 !descriptor.dangerous 的工具 (permissionHandler.ts:196-198),而 getToolDescriptor 只把 Bash 和四个编辑工具标为 dangerous ——任何 MCP 工具在 plan 模式下都会被自动放行,不管它其实会不会写外部系统。

  • 白名单只活在进程内。 allowedTools / allowedBashLiterals / allowedBashPrefixes 都是内存 Set, reset() 一调就没(:329-331)。CLI 重启后"总是允许"要重新点。

  • --resume 不带 id 时无解。 远程模式必须给 SDK 一个具体 session id, 裸 --resume 只能打条日志然后忽略(claudeRemote.ts:71-79)。

  • 额度采集是尽力而为。 用的是名字里写满 EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET 的 SDK 方法,并且 typeof 判断后 try/catch 吞掉一切错误(claudeRemote.ts:196-224)。

  • 30 秒转录文件超时后仍继续。 会话文件没落盘时只推一条警告,不中止(:297-307)—— 换来的是"不因为磁盘慢就误杀会话",代价是可能出现半死不活的实例。


10. 和其它章的关系


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

路径均相对克隆根 packages/happy-cli/src/(最后一行例外,已写全路径)。

主题文件符号
SDK 驱动主循环claude/claudeRemote.tsclaudeRemote
官方 SDK 适配claude/sdk/query.tsquery(内部 canUseTool 桥接)
SDK 选项类型claude/sdk/types.tsQueryOptionsCanCallToolOptions
远程外层循环 + 成型管线claude/claudeRemoteLauncher.tsclaudeRemoteLauncheronMessage
图片魔数判定claude/claudeRemoteLauncher.tsdetectClaudeImageMime
授权判定链claude/utils/permissionHandler.tsPermissionHandler.handleToolCall
挂起 + 双端待办claude/utils/permissionHandler.tshandlePermissionRequest
授权响应处理claude/utils/permissionHandler.tshandlePermissionResponsesetupClientHandler
权限模式热切claude/utils/permissionHandler.tshandleModeChangesetPermissionModeUpdater
跨进程清理claude/utils/permissionHandler.tsPermissionHandler.reset
Bash 白名单解析claude/utils/permissionHandler.tsparseBashPermission
7 模式 → 4 模式映射claude/utils/permissionMode.tsmapToClaudeModeisClaudeBypassEquivalent
远程模式降级保护claude/utils/permissionMode.tsresolveRemoteClaudePermissionMode
工具危险性标签claude/utils/getToolDescriptor.tsgetToolDescriptor
其它后端共用骨架utils/BasePermissionHandler.tsBasePermissionHandleraddPendingRequestToStateabortAll
SDK → 转录文件 JSONL 转换claude/utils/sdkToLogConverter.tsSDKToLogConverter.convert
中断补收尾claude/utils/sdkToLogConverter.tsgenerateInterruptedToolResult
Task 首消息补造claude/utils/sdkToLogConverter.tsconvertSidechainUserMessage
250ms 节流队列claude/utils/OutgoingMessageQueue.tsOutgoingMessageQueue.enqueuereleaseToolCall
线协议映射claude/utils/sessionProtocolMapper.tsmapClaudeLogMessageToSessionEnvelopesensureTurncloseTurn
回合强制关闭claude/utils/sessionProtocolMapper.tscloseClaudeTurnWithStatus
发送入口api/apiSession.tssendClaudeSessionMessagecloseClaudeSessionTurn
模式指纹定义claude/runClaude.tsnew MessageQueue2<EnhancedMode>(...)
问题工具识别claude/utils/questionNotification.tsgetAskUserQuestionToolCallIds
线协议 schemapackages/happy-wire/src/sessionProtocol.tssessionEventSchemasessionEnvelopeSchemacreateEnvelope

继续读: 05 守护进程与机器(会话还不存在时谁来造) · 06 多种 agent 后端与 App 端的消息归一(同一套信封的另外三个生产者) · 返回索引