数据截至 (上游 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、喂消息、消费消息、管会话 id | packages/happy-cli/src/claude/claudeRemote.ts |
query() 包装 | 把 Happy 自己的 QueryOptions 翻译成官方 SDK 的 Options | packages/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 |
SDKToLogConverter | SDK 消息 → Claude 转录文件(JSONL)形状 | packages/happy-cli/src/claude/utils/sdkToLogConverter.ts |
OutgoingMessageQueue | 保序 + 250ms 延迟 + 提前释放 | packages/happy-cli/src/claude/utils/OutgoingMessageQueue.ts |
mapClaudeLogMessageToSessionEnvelopes | JSONL 形状 → 线协议事件信封 | 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-359 的 if (message.type === 'result') 里,顺序是:
updateThinking(false)—— 手机上的"思考中"转圈停掉;scheduleUsageFlush()—— 把这一轮攒的额度事件刷成一次 agentState 写入(claudeRemote.ts:192-253,符号flushUsageLimits);opts.onReady()—— 通知外层"回合结束";- 不 await 地调
opts.nextMessage(),拿到就messages.push,拿到null就messages.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-526(new MessageQueue2<EnhancedMode>(mode => hashObject({...})))。
注意它放进 hash 的是 isPlan: mode.permissionMode === 'plan',不是完整的 permissionMode。
这个取舍的后果很实在:
| 模式变化 | hash 变吗 | 结果 |
|---|---|---|
default → plan | 变 | 重启 query(plan 模式要改 SDK 启动参数) |
default → bypassPermissions | 不变 | 不重启——必须用别的办法热切 |
换 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 判定顺序(这张表就是策略本身)
handleToolCall 在 permissionHandler.ts:146-205,自上而下短路:
| # | 条件 | 结果 | 行号 |
|---|---|---|---|
| 1 | toolName === 'AskUserQuestion' | 强制挂起,即使 bypass 模式 | :151-153 |
| 2 | Bash 且 command 命中字面量白名单 | 放行 | :160-162 |
| 3 | Bash 且 command 命中前缀白名单 | 放行 | :164-168 |
| 4 | 非 Bash 且工具名在 allowedTools 里 | 放行 | :170-172 |
| 5 | descriptor.exitPlan(ExitPlanMode) | 强制挂起 | :177-180 |
| 6 | 当前模式 bypass 等价(bypassPermissions/yolo) | 放行 | :186-188 |
| 7 | acceptEdits 模式 且 descriptor.edit | 放行 | :190-192 |
| 8 | plan 模式 且 工具不 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):
| 工具 | edit | exitPlan | dangerous |
|---|---|---|---|
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 的 Promise(permissionHandler.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]: {...} } }))
▲
└── 这一份是「双端共享的待办清单」
pendingRequests 和 agentState.requests 是同一件事的两个副本,分工明确:
| 存在哪 | 存了什么 | 谁读 | |
|---|---|---|---|
pendingRequests | CLI 进程内存 | 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 上。它:
- 从
pendingRequests取出钥匙,取不到就静默返回(说明已经被 reset 或超时清掉了); - 把响应存进
responses(带receivedAt时间戳,后面成型管线要用); - 调
handlePermissionResponse真正 resolve; - 把
agentState.requests[id]挪到completedRequests[id],带上status: 'approved' | 'denied'。
handlePermissionResponse(permissionHandler.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。
于是 handleModeChange(permissionHandler.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 | 原样透传 |
yolo | bypassPermissions |
safe-yolo | default |
read-only | default |
注意这里"比较映射后的值"而不是原值——yolo 和 bypassPermissions 映射结果相同,来回切不该白白惊动 SDK。
同一个文件里的 resolveRemoteClaudePermissionMode(permissionMode.ts:117-132)处理另一个坑:
老版本 App 会给每条消息都带上 permissionMode: "default",不拦住的话会把一个 yolo 会话默默降级。
4.7 reset():清理上一个进程留下的鬼魂
reset()(permissionHandler.ts:327-361)做四件事:清白名单、清 responses、把 permissionMode 打回 default、
reject 所有挂起的 Promise