数据截至 (上游 commit eb980a5c9eea)
本地与远程双模态:怎么把终端里的会话抢过来
30 秒导读: Happy 最独特的能力不是"手机上跑 agent",而是同一条会话可以在终端和手机之间来回换手——你在电脑前敲一半,起身去厨房,掏出手机接着说;回到座位按两下空格,终端里的
claude又回来了,上下文一条不丢。这一章讲这个换手是怎么做到的。
前置:加密同步流与不可读的 RPC 中继 解释了 switch / abort 这类 RPC 怎么在服务器上"不可读地"中继过来;本章只关心收到之后 CLI 做了什么。远程回合里模型怎么跑、工具怎么授权,留给 远程回合:SDK 驱动、工具授权与消息成型。
1. 先讲清楚:为什么"换手"是件难事
一句话定义: 双模态 = 同一个 Happy 进程,在两种"谁在驱动 Claude"的形态之间切换。
全章只用三个动作词,一词一义,先钉死:
| 词 | 指什么 |
|---|---|
| 换手 | 两种模式之间的一次控制权转移(方向不限,本章统称) |
| 夺权 | 本地 → 远程 方向的换手:手机把控制权拿走 |
| 交还 | 远程 → 本地 方向的换手:终端把控制权要回来 |
(手机界面上那个"接管"按钮是夺权的三个触发器之一,见 §4。)
两种形态差别极大,不是同一件事的两个皮肤:
| 本地模式 local | 远程模式 remote | |
|---|---|---|
| 谁在跑 Claude | 真的 claude CLI 子进程 | Claude Agent SDK 在 Happy 进程内跑 |
| 终端 TTY 归谁 | 归子进程(stdio: 'inherit') | 归 Ink 渲染的只读状态屏 |
| 用户输入从哪来 | 你的键盘,Happy 看不见 | 手机消息队列 MessageQueue2 |
| Happy 怎么知道说了什么 | 事后扫 Claude 写的 JSONL 转录文件 | SDK 消息流,逐条拿到 |
| 工具授权谁批 | Claude CLI 自己弹框问你 | 手机弹框(见 第 4 章) |
| 退出码 | 透传子进程的退出码 | 恒为 0 |
难点就藏在第三、第四行:本地模式下 Happy 是个瞎子。 它把终端完整让给了子进程,既拦不到你的输入,也拦不到 Claude 的输出。可手机那端还得实时看到这场对话。
于是本地模式必须靠三条"侧信道"把自己的眼睛补回来:
- 会话 id —— 靠 Claude 的
SessionStart钩子回调一个本地 HTTP 服务器; - 对话内容 —— 靠监视
~/.claude/projects/下那个.jsonl转录文件; - 忙/闲状态 —— 靠在子进程里劫持
global.fetch,从第 3 号文件描述符把事件吐回来。
一句话直觉: 本地模式的 Happy 像一台装在录音室外面的监听设备——它不进屋,只从门缝(fd 3)看灯亮不亮,从纸篓(转录文件)捡稿子。远程模式则是它自己坐进了录音室。
2. 顶层全景:一个 while 循环,两个 launcher
整个双模态的骨架小得出奇——loop() 就是一个二态状态机(packages/happy-cli/src/claude/loop.ts:52,符号 loop):
┌──────────────────────────────────────┐
│ loop() while(true) │
└───────────────┬──────────────────────┘
│ mode
┌──────────────────┴──────────────────┐
▼ ▼
┌─────────────────────┐ ┌─────────────────────┐
│ claudeLocalLauncher │ │ claudeRemoteLauncher │
│ 起真 claude 子进程 │ │ 起 Ink + SDK 回合 │
└─────────┬───────────┘ └──────────┬──────────┘
│ 返回 {type:'switch'} │ 返回 'switch'
└──────────────► mode=remote ─────────┘
┌──── ────────── mode=local ◄────────┘
│
│ 返回 {type:'exit', code} 或 'exit'
▼
loop 返回退出码,进程结束
怎么读这张图:两个 launcher 都是"跑到它自己想结束为止"的长任务,唯一的出口语义就是「换手」还是「退出」。 谁也不知道对方存在,loop 只负责把返回值翻译成下一轮的 mode。
两条出口用了两个不对称的类型:
- 本地侧是结构体
LauncherResult = { type: 'switch' } | { type: 'exit', code: number }(packages/happy-cli/src/claude/claudeLocalLauncher.ts:8)——要带退出码,因为happy要伪装成claude,子进程非零退出必须原样透传(loop.ts:89)。 - 远程侧只返回字符串
'switch' | 'exit',loop.ts:100里直接return 0——SDK 回合没有"进程退出码"这回事。
两边的 switch 分支都会调 opts.onModeChange(loop.ts:86、loop.ts:103),上层据此把 controlledByUser 写进加密的 agent state,手机端的界面就知道该显示"终端在用"还是"你在控制"(packages/happy-cli/src/claude/runClaude.ts:937-944)。
部件职责一览:
| 部件 | 干什么 | 文件 |
|---|---|---|
loop | 二态状态机,翻译 launcher 的返回值 | src/claude/loop.ts |
Session | 跨模式共享的状态:sessionId、队列、claudeArgs | src/claude/session.ts |
claudeLocalLauncher | 本地模式:注册夺权 RPC、拉起子进程、收尾 | src/claude/claudeLocalLauncher.ts |
claudeLocal | 真正 spawn,改写会话标志,读 fd 3 | src/claude/claudeLocal.ts |
createSessionScanner | 扫 JSONL 转录文件,把本地对话补给服务器 | src/claude/utils/sessionScanner.ts |
startHookServer | 收 Claude 的 SessionStart 回调 | src/claude/utils/startHookServer.ts |
claudeRemoteLauncher | 远程模式:Ink 状态屏 + SDK 回合 | src/claude/claudeRemoteLauncher.ts |
Session 是唯一跨模式活着的对象(packages/happy-cli/src/claude/session.ts:8)。它在 loop 开头 new 出来,之后两个 launcher 轮流拿它——换手能保住上下文,本质上就是因为 session.sessionId 从没被丢过。
3. 本地模式:Happy 只是个"旁观者 + 遥控开关"
这节讲本地模式的五个零件,从"怎么起进程"一路走到"怎么补消息",最后用一张图收束。
3.1 不直接跑 claude,先套一层 .cjs
Happy 不是 spawn('claude'),而是 spawn('node', ['<happy>/scripts/claude_local_launcher.cjs', ...args])(packages/happy-cli/src/claude/claudeLocal.ts:31,常量 claudeCliPath)。
那个 .cjs 干两件事就结束了:装一个 fetch 探针,然后把真正的 Claude CLI require 进来跑(packages/happy-cli/scripts/claude_local_launcher.cjs:71-72,runClaudeCli(getClaudeCliPath()))。同进程加载,不是再 fork 一层,所以终端交互的手感完全没变。
spawn 的两个细节值得单独看:
// packages/happy-cli/src/claude/claudeLocal.ts:312-323 —— 真实源码节选
const child = crossSpawn(
spawnWithShell && spawnCommand ? spawnCommand : 'node',
spawnWithShell ? [] : spawnArgs,
{ stdio: ['inherit', 'inherit', 'inherit', 'pipe'], signal: opts.abort, /* … */ },
);
- 用
cross-spawn而不是node:child_process的spawn,是为了让node在 Windows 上解析成node.exe(注释里点名 issue #1082)。 stdio四个槽位的分工是整个本地模式的设计核心:
| fd | 配置 | 含义 |
|---|---|---|
| 0 stdin | inherit | 键盘直通子进程,Happy 完全不插手 |
| 1 stdout | inherit | Claude 的全屏 UI 直接画在你的终端上 |
| 2 stderr | inherit | 同上 |
| 3 | pipe | Happy 唯一的观察窗:结构化事件回传 |
signal: opts.abort 让"夺权"变得很便宜——只要 abort 那个 AbortController,Node 就替你给子进程发 SIGTERM。
沙箱是可选的外壳:开启时把整条命令交给 wrapCommand 包一层,并强行补上 --dangerously-skip-permissions(claudeLocal.ts:285-294;Windows 上直接跳过,claudeLocal.ts:279-281)。失败不致命,会降级成无沙箱继续跑(claudeLocal.ts:300-306)。
3.2 fd 3 上的 fetch 探针:把"在思考"这件事捞出来
要解决的小问题: 手机上要显示"Claude 正在忙"的转圈,可本地模式下 Happy 看不见任何输出。
思路: Claude CLI 是 Node 程序,它调模型走的是全局 fetch。那就在它被加载之前把 global.fetch 换成一个会打点的版本。
原理演示:
// 示意,非源码
const original = global.fetch;
let n = 0;
global.fetch = (...args) => {
const id = ++n;
report({ type: 'fetch-start', id }); // 有请求飞出去了 = 在思考
const p = original(...args);
p.then(() => report({ type: 'fetch-end', id }),
() => report({ type: 'fetch-end', id })); // 成功失败都要收尾
return p; // 原样返回,绝不改变行为
};
真实实现在 packages/happy-cli/scripts/claude_local_launcher.cjs:19-64。两个讲究:
- 只上报主机名和 path,不上报完整 URL(
claude_local_launcher.cjs:24-35,注释写着Parse URL for privacy)。 - 事件用
fs.writeSync(3, …)写 fd 3(claude_local_launcher.cjs:7-13,writeMessage)——不占 stdout,绝不会污染 Claude 的 TUI 画面;fd 3 不存在时静默失败。
父进程那侧用 readline 逐行解析 fd 3(packages/happy-cli/src/claude/claudeLocal.ts:326-378),维护一张 activeFetches 表:有请求就 thinking = true;全部结束后再等 500ms 才熄灯(claudeLocal.ts:361-368),避免多轮请求之间的缝隙让手机上的转圈疯狂闪烁。
3.3 会话 id 是 Claude 自己报的:SessionStart 钩子回环
要解决的小问题: Happy 必须知道当前这条 Claude 会话的 id——不然它不知道该去盯哪个 .jsonl 文件。可 id 是 Claude 自己铸的,而且 /compact、双击 Esc 分叉、--resume 都会换一个新 id。
思路: 别猜,让 Claude 主动说。Claude CLI 支持 --settings <file> 挂钩子,SessionStart 事件会把会话信息从 stdin 喂给一个命令。Happy 就临时生成这么一个 settings 文件。
happy 进程启动
│
├─► startHookServer() ── 监听 127.0.0.1 的随机端口(例 52290)
│
├─ ► generateHookSettingsFile(52290)
│ └─► ~/.happy/tmp/hooks/session-hook-<pid>.json
│ { hooks: { SessionStart: [ … node session_hook_forwarder.cjs 52290 ] } }
│
└─► spawn claude --settings <上面那个文件>
│
└─ Claude 开新会话/resume/compact 时触发 SessionStart
└─► session_hook_forwarder.cjs 把 stdin 的 JSON
POST 到 127.0.0.1:52290/hook/session-start
└─► session.onSessionFound(sessionId)
├─ 写进加密 metadata.claudeSessionId
└─ 通知 sessionScanner 换文件
四段真实代码,一段一句话:
generateHookSettingsFile(port)按 pid 生成唯一文件名,内容就是一条SessionStart钩子命令(packages/happy-cli/src/claude/utils/generateHookSettings.ts:20-51)。session_hook_forwarder.cjs极简:读完 stdin、POST 到本地端口、出错一律静默(packages/happy-cli/scripts/session_hook_forwarder.cjs:41-43,注释don't break Claude)——钩子挂了不能把用户的 Claude 也带崩。startHookServer只认POST /hook/session-start,兼容session_id和sessionId两种拼法,并给请求体加了 5 秒超时防止挂死(packages/happy-cli/src/claude/utils/startHookServer.ts:100-135)。- 回调落到
Session.onSessionFound:更新this.sessionId、写进加密 metadata、再广播给所有注册过的回调(packages/happy-cli/src/claude/session.ts:113-127)。
为什么不用文件监视来猜 id? 源码注释直接给了答案:多个 Happy 进程同时跑时,"哪个新文件是我的"是个竞态;钩子是 Claude 点名告诉这一个进程的,天然 1:1(startHookServer.ts:54-57)。
3.4 三种会话标志的拦截与改写
要解决的小问题: 用户会写 happy --continue、happy --resume <id>、happy --session-id <uuid>。Happy 想让这些标志看起来完全透明,同时又要用自己那套会话账本。
做法是先把这些标志从 claudeArgs 里抠出来,算出一个 startFrom,再按需要重新拼回去。抠的动作由 extractFlag 完成(packages/happy-cli/src/claude/claudeLocal.ts:75-100),它会连带把标志的值一起从数组里删掉。
优先级是自上而下的三步(claudeLocal.ts:103-137):
| 顺序 | 用户写的 | Happy 的动作 |
|---|---|---|
| 1 | --session-id <uuid> | 抠掉,强制走"新会话"分支 |
| 2 | --resume <id> / -r <id> | 抠掉,startFrom = <id> |
| 2' | --resume 不带值 | 抠掉,用 claudeFindLastSession 找最近一条 |
| 3 | --continue / -c | 抠掉,同样找最近一条 |
拼回去时分两种世界(claudeLocal.ts:212-230):
- 有钩子(正常路径): 只有
startFrom存在时才补一个--resume <startFrom>;全新会话什么都不传,让 Claude 自己铸 id,然后靠钩子回报。 - 无钩子(离线/测试路径): Happy 自己
randomUUID()生成 id 并用--session-id钉死,因为没人会来告诉它(claudeLocal.ts:145-161)。
固定追加的两个参数:--append-system-prompt(塞进 Happy 自己的系统提示,要求 Claude 主动调 mcp__happy__change_title 起标题,claudeLocal.ts:232 + src/claude/utils/systemPrompt.ts)和 --settings <hookSettingsPath>(claudeLocal.ts:249)。
最容易忽略的一步:consumeOneTimeFlags。 子进程一起来,claudeLocalLauncher.ts:129 立刻把 --resume / --continue 从 session.claudeArgs 里永久摘除(实现在 packages/happy-cli/src/claude/session.ts:158-195)。
理由很实在:这些是一次性标志。假如不摘,那么"本地 → 远程 → 本地"转一圈后,第二次 spawn 会带着最初那个 --resume <老 id> 再来一遍,把你刚在手机上聊的部分整段丢掉。远程侧同样在每轮之后调它(claudeRemoteLauncher.ts:448)。
3.5 看不见消息,那就扫转录文件
要解决的小问题: 本地模式下 Claude 的输出直接进了终端,Happy 一个字都没拿到,但手机上得能看。
思路: Claude CLI 会把整场对话逐行写成 JSONL,路径由工作目录推出来——把 cwd 里所有非字母数字字符换成 -,拼到 ~/.claude/projects/ 下(packages/happy-cli/src/claude/utils/path.ts:4-8,getProjectPath)。那就盯这个文件。
补一句精确的:那个根目录不是写死的,CLAUDE_CONFIG_DIR 环境变量存在时优先用它,否则才回落到 ~/.claude(path.ts:6)。所以下文一切说"~/.claude/projects/"的地方,严格讲都是"转录根目录"。
createSessionScanner(packages/happy-cli/src/claude/utils/sessionScanner.ts:27)是个"文件监视 + 3 秒兜底轮询"的双保险(watcher 在 sessionScanner.ts:129,定时器在 sessionScanner.ts:154),每次触发就整文件重读、逐行解析、把没见过的条目喂给回调。
它要解决的坑比想象中多,逐个看:
坑一:重复推送。 整文件重读意味着每次都会看到全部历史。解法是一个全局去重集合 processedEntryKeys(sessionScanner.ts:48),key 由 messageKey 生成——普通消息用 uuid,summary 行用 'summary: ' + leafUuid + ': ' + summary(sessionScanner.ts:216-228)。
关键在于这个集合不按会话分区。claude --resume 会铸一个新 id、写一个新文件,并把全部历史复制进去;因为去重按 uuid 走,历史条目在新文件里仍然命中旧 key,不会重播 (inferred:源码只体现"按 uuid 去重","resume 后 uuid 不变"是从这个设计意图反推的)。
坑二:刚进本地模式时的历史回放。 scanner 构造时如果已经知道 sessionId,会先把文件里现有条目全部预标记为已处理(sessionScanner.ts:57-67)。这正是"远程 → 本地"交还时不会把整段历史再刷一遍到手机上的原因。远程侧那个常驻 scanner 走的是同一招的显式版本 treatExistingAsProcessed: true(sessionScanner.ts:192-198,调用点 runClaude.ts:487)。
坑三:幽灵会话空转。 某些 id(比如一次没写盘就结束的远程启动)对应的 .jsonl 永远不会出现。老实现里 fs.watch() 对不存在的路径同步抛 ENOENT,配合 scanner 每轮重建 watcher,就变成每秒一次的死循环——CPU 跑满、日志涨到几 MB,作者管它叫 "dead Happy instance" bug(packages/happy-cli/src/modules/watcher/startFileWatcher.ts:22-37)。
现在的解法是两段式:startFileWatcher 指数退避,超过 missingFileTimeoutMs(默认 60 秒)就彻底放弃并回调 onGaveUp;scanner 在这个回调里把会话拉进 deadSessions 黑名单(sessionScanner.ts:54、sessionScanner.ts:134-145),此后每一轮的收集逻辑都会跳过它(sessionScanner.ts:74-88)。只有调用方显式重新宣告这个 id 时才复活(sessionScanner.ts:174-176)。
坑四:噪声行。 Claude 会往转录文件里写 file-history-snapshot / change / queue-operation 之类的内部事件,scanner 有一张白名单式的跳过表(sessionScanner.ts:15-19,INTERNAL_CLAUDE_EVENT_TYPES),解析不出来的行也一律静默丢弃(sessionScanner.ts:274-278)。
坑五:上传乱序。 回调里不是直接 await,而是把每条消息挂到一条 promise 链 scannerMessageChain 上串行发送(claudeLocalLauncher.ts:12、claudeLocalLauncher.ts:21-27),并在收尾时等它排空(claudeLocalLauncher.ts:179)。另外 scanner 产出的 summary 类型消息会被本地 launcher 直接拦掉——Happy 自己生成标题(claudeLocalLauncher.ts:19-20)。
3.6 一张图收束本地模式
你的键盘 Happy 进程 服务器/手机
│ │
│ stdio 0/1/2 = inherit │
└───────► claude 子进程 ◄────────┤ spawn(node launcher.cjs …)
│ │ │ │
终端画面 ◄────┘ │ └─ fd 3 ─────► fetch-start/end → thinking → keepAlive
│ │
└─ 写 JSONL ─────► sessionScanner ──加密──► 手机看到对话
│
SessionStart 钩子 ─► hookServer → session.sessionId
三条侧信道(fd 3 / 转录文件 / 钩子)分别补上了状态、内容、身份——这就是本地模式的全部。
4. 夺权:三条路进入远程模式
本地模式跑着的时候,claudeLocalLauncher 注册了三个"外部开关"(packages/happy-cli/src/claude/claudeLocalLauncher.ts:84-90):
| 触发源 | 处理函数 | 语义 | 副作用 |
|---|---|---|---|
| 手机点"停止" | abort RPC → doAbort | 中断并夺权 | 关 turn 为 cancelled、清空消息队列 |
| 手机点"接管" | switch RPC → doSwitch | 平和夺权 | 只发 SIGTERM,不动队列 |
| 手机直接发消息 | queue.setOnMessage → doSwitch | 隐式夺权 | 同上,消息留在队列里等远程模式消费 |
前两行那两个方法名在服务器上带会话作用域前缀,实际是 <sessionId>:abort / <sessionId>:switch,靠 Socket.IO 房间路由过来(见 第 2 章 §5.2);参数与返回值全程是密文,服务器只认房间名。
第三条是体验上最妙的一条:你在手机上打字这个动作本身就是接管请求,不需要先点任何按钮(claudeLocalLauncher.ts:86-90,注释 Remote messages request control from the app)。
两个 do* 函数的差别只有几行,但语义相差很远:
// packages/happy-cli/src/claude/claudeLocalLauncher.ts:57-73 —— 真实源码节选
async function doAbort() {
session.onAbort();
if (!exitReason) { exitReason = { type: 'switch' }; }
session.client.closeClaudeSessionTurn('cancelled');
session.queue.reset(); // ← 丢掉排队中的手机消息
await abort();
}
doAbort 直接把 exitReason 钉成 switch 并清队列;doSwitch 只置一个 switchRequested = true 再 abort(claudeLocalLauncher.ts:75-81),把"到底算 switch 还是算 exit"的判断留到子进程真的退出之后。
(这里的 closeClaudeSessionTurn 关的是线协议里的一个回合,completed / failed / cancelled 三种状态会变成一条 turn-end 事件发给 App,回合模型见 第 4 章 §7。)
子进程退出后的分诊逻辑(claudeLocalLauncher.ts:131-160):
claudeLocal() 返回/抛出
│
├─ 已有 exitReason ────────────────────────► 用它(doAbort 走这里)
│
├─ 正常返回 + switchRequested 或队列非空 ──► {type:'switch'},turn = completed
├─ 正常返回 + 都没有 ─────────────────────► {type:'exit', code:0} ← 你在终端按了 Ctrl-D
│
└─ ExitCodeError(非零退出)
├─ switchRequested 或队列非空 ────────► {type:'switch'},turn = failed
└─ 否则 ──────────────────────────────► {type:'exit', code},退出码透传
注意 SIGTERM 不算错误:claudeLocal.ts:404-406 专门判了"信号是 SIGTERM 且 abort 已触发",此时按正常结束处理。这样"夺权"就不会被误判成"Claude 崩了"。
还有个 0 成本的快捷路径:进入本地模式前先看一眼队列,队列里已经有手机消息就根本不启动子进程,直接返回 switch(claudeLocalLauncher.ts:93-95)。
最后是收尾。finally 块(claudeLocalLauncher.ts:164-180)按固定顺序拆干净:把两个 RPC 处理器换成空函数、解绑 queue.setOnMessage、摘掉 scanner 的会话回调、await scanner.cleanup()、再等消息链排空。换手之所以不会串台,靠的就是这段无条件执行的解绑。
5. 远程模式怎么把控制权交还回来
远程模式里终端不再有 claude,取而代之的是 Ink 渲染的只读状态屏 RemoteModeDisplay(packages/happy-cli/src/claude/claudeRemoteLauncher.ts:41-63),同时 stdin 被设成 raw 模式(claudeRemoteLauncher.ts:65-71)——这样按键不会被终端回显,全部交给 Ink 解释。
会话是接着上一条跑的:SDK 参数里 resume: startFrom(packages/happy-cli/src/claude/claudeRemote.ts:129),而 startFrom 就是 session.sessionId,并且先用 claudeCheckSession 验一遍那个 .jsonl 确实存在、且至少有一条带 id 的消息(claudeRemote.ts:53-55,实现在 src/claude/utils/claudeCheckSession.ts:6)。验不过就退化成新会话——宁可开新的,也不要拿一个坏 id 去 resume。
5.1 双空格与 Ctrl-T
键盘解释被抽成了一个纯函数 interpretRemoteModeKeypress(packages/happy-cli/src/ui/ink/RemoteModeDisplay.tsx:20-50),方便脱离 React 单测:
| 按键 | 结果 |
|---|---|
| 空格(第一次) | confirm-switch,界面提示"再按一次空格" |
| 空格(第 二次) | switch,真的切回本地 |
Ctrl-T | 立即 switch,不需要确认 |
Ctrl-C ×2 | 退出整个客户端 |
| 其他键 | 取消确认态 |
Ctrl-T 这条捷径不是为了少按一下。源码注释说得很直白:它是为了绕开"空格连击"这个失败模式——多按的那几个空格会漏进下一个交互式子进程(RemoteModeDisplay.tsx:32-37)。这就引出下一节。
5.2 交还 stdin 的两个陷阱
远程 → 本地这一跳,终端要从 Ink 手里转交给一个会自己接管 TTY 的子进程。这中间有两个坑,都写在 packages/happy-cli/src/utils/terminalStdinCleanup.ts:1-27 的文件头注释里:
- 残留按键。 Ink 拥有 stdin 期间敲进去的字节还留在读缓冲里。Ink 一 unmount,下一个进程通过
inherit继承同一个 fd,就把它们当成你在新提示符下敲的内容吃掉了。 - 内核回显。 Ink 卸载时会
setRawMode(false)回到 cooked 模式。这之后、子进程接管 raw 模式之前,任何按键都会被内核回显在 Ink 最后留下的光标位置上——屏幕上出现乱码和"第二个光标"。
cleanupStdinAfterInk(terminalStdinCleanup.ts:29)的对策是把这两件事一起摁住:
ink.unmount()
│
├─ setRawMode(true) ← 重新按住 raw,堵住内核回显
├─ 挂一个只数不用的 data 监听,resume 150ms ← 静默吞掉残留字节
├─ off + pause
└─ 故意【不】恢复 cooked 模式(leaveRawMode 默认 true)
最后那条最反直觉:清理完之后故意把终端留在 raw 模式。因为紧接着接手的就是 claude 这种自己要用 raw 模式的交互程序,恢复成 cooked 反而会开出一个新的竞态窗口(terminalStdinCleanup.ts:38-50 的参数注释)。调用点在 claudeRemoteLauncher.ts:511-517,drainMs: 150。
5.3 第三个陷阱:O_NONBLOCK
排空还不够。回到本地模式再次 spawn 之前,claudeLocal 还要强行把 stdin 掰回阻塞模式:
// packages/happy-cli/src/claude/claudeLocal.ts:199-207 —— 真实源码节选
const stdinHandle = (process.stdin as any)._handle;
if (stdinHandle && typeof stdinHandle.setBlocking === 'function') {
try { stdinHandle.setBlocking(true); } catch (err) { /* … */ }
}
原因写在上面那段注释里(claudeLocal.ts:191-198):Node 用 libuv 读过 stdin 之后会把 O_NONBLOCK 留在 fd 上——Ink 和刚才那个排空函数都读过。子进程继承这个 fd 后按 阻塞方式读,拿回来的是 EAGAIN 而不是字节,表现就是 macOS/Linux 上远程→本地切换后光标重影、回显错乱(注释点名 slopus/happy#301 一族问题)。必须在 crossSpawn 复制 fd 之前清掉这个标志。
三个坑连起来看,它们是同一件事的三层:
| 层 | 症状 | 对策 | 位置 |
|---|---|---|---|
| 缓冲区 | 多按的空格漏进 claude | 150ms 静默排空 | terminalStdinCleanup.ts:98-101 |
| 终端模式 | 内核在错误位置回显 | 全程保持 raw 模式 | terminalStdinCleanup.ts:61-68 |
| fd 标志 | 子进程读到 EAGAIN、回显重影 | setBlocking(true) | claudeLocal.ts:199-207 |
6. 完整时序:本地 → 远程 → 本地
把前面所有零件按时间轴串一遍。竖线是时间,═► 是控制权转移,─► 是数据流。
① local 稳态 ───────────────────────────────────────────── ───────────
你的键盘 ══► claude 子进程 (stdio 0/1/2 inherit) ← 终端归它
│
├─ fd 3 ─► fetch-start/end ─► thinking ─► keepAlive ─► 手机转圈
├─ 写 <cwd 映射>/<sid>.jsonl ─► sessionScanner ─► 加密上传 ─► 手机看对话
└─ SessionStart 钩子 ─► hookServer ─► session.onSessionFound(sid)
② 夺权 ──────────────────────────────────────────────────────────────
手机发消息 / 点"接管" ─► queue.setOnMessage 或 switch RPC
─► doSwitch(): switchRequested = true
─► abortController.abort() ─SIGTERM─► 子进程退出
③ local 收尾 ────────────────────────────────────────────────────────
consumeOneTimeFlags() ← 摘掉 --resume/--continue,防止下次回退历史
closeClaudeSessionTurn('completed')
解绑 abort/switch/onMessage → scanner.cleanup() → 等消息链排空
return { type: 'switch' } ─► loop: mode = remote,广播 controlledByUser=false
④ remote 稳态 ───────────────────────────────────────────────────────
终端 ══► Ink 只读状态屏 (raw 模式,键盘只剩 空格/Ctrl-T/Ctrl-C)
手机消息 ─► MessageQueue2 ─► claudeRemote({ resume: session.sessionId })
└─► SDK 消息流 ─► 加密上传 ─► 手机
⑤ 交还(终端里连按两下空格,或 Ctrl-T)────────────────────────────────
interpretRemoteModeKeypress → 'switch' → onSwitchToLocal → doSwitch()
ink.unmount()
cleanupStdinAfterInk(): 保持 raw ─► 静默吞 150ms 残留按键 ─► pause(不恢复 cooked)
return 'switch' ─► loop: mode = local,广播 controlledByUser=true
⑥ 回到 local ────────────────────────────────────────────────────────
新建 scanner(sessionId = 当前 id),把文件里已有条目全部预标记为"已处理"
setBlocking(true) ← 清掉 O_NONBLOCK,否则子进程回显错乱
spawn claude --resume <当前 id> --settings <钩子文件> --append-system-prompt …
└─ Claude 铸出【新的】session id,写一个含全部历史的新转录文件
└─ SessionStart 钩子 ─ ► hookServer ─► session.onSessionFound(新 id)
└─ scanner.onNewSession(新 id):按 uuid 去重,历史不重播
你的键盘 ══► 新的 claude 子进程 ← 回到 ①,上下文一条不丢
整条链上唯一的"记忆"是 session.sessionId。 它在 ①/⑥ 由钩子写入,在 ④ 被当作 resume 参数交给 SDK——所有其他东西(子进程、Ink 实例、scanner、AbortController)每次换手都全新创建再销毁。
7. 巧妙之处(可带走的技术)
-
"用户开始打字"就是接管信号。 不需要额外的接管按钮,队列的
onMessage回调直接触发doSwitch(claudeLocalLauncher.ts:86-90)。交互设计问题被一行回调解决了。 -
在被包装程序的进程里打点,从非标准 fd 回传。 既不动被包装程序的源码,也不污染它的 stdout/TUI(
scripts/claude_local_launcher.cjs:7-13+claudeLocal.ts:326-378)。要给任何 Node CLI 加可观测性,这是个可以直接抄的模式。 -
身份问题让被包装方主动上报,而不是自己去猜。 钩子 + 本地 HTTP 回环,天然规避了"多实例同时监视目录"的竞态(
startHookServer.ts:54-57)。 -
一次性标志用完就烧。
consumeOneTimeFlags把 CLI 参数当成可变状态管理(session.ts:158-195)——长命进程里反复 spawn 同一个命令时,这个"参数会过期"的观念很容易被漏掉。 -
给死循环装保险丝。 找不到的文件不是永远重试,而是指数退避 + 超时放弃 + 黑名单三件套(
startFileWatcher.ts:22-37配sessionScanner.ts:134-145)。 -
把 TTY 交接当成一个有三层状态的协议来处理。 缓冲区、终端模式、fd 标志各修各的,而不是笼统地"重置一下终端"(§5.2、§5.3 那张表)。
8. 边界与局限
- 本地模式看不到你打的字,只能看到落盘后的转录文件。 所以手机上的显示天然滞后于终端,滞后量取决于 Claude 何时 flush JSONL 和 scanner 的 3 秒兜底轮询(
sessionScanner.ts:154)。 - 本地模式无法拦截工具授权。 权限弹框由 Claude CLI 自己在终端里处理,手机端插不上手;只有远程模式才有
PermissionHandler(见 第 4 章)。 - thinking 状态是靠 HTTP 请求推断的近似值。 任何走
fetch的请求都会点亮它,且熄灯固定延迟 500ms(claudeLocal.ts:361-368)。 - 沙箱在 Windows 上直接跳过(
claudeLocal.ts:279-281);沙箱模式还会强行加--dangerously-skip-permissions(claudeLocal.ts:285-287),这是个需要留意的安全取舍。 --resume不带 id 在远程模式不被支持——SDK 没有交互式选择器,源码里只打日志然后忽略(claudeRemote.ts:71-77)。- 依赖 Claude CLI 的两个内部约定:转录目录(默认
~/.claude/projects/,可被CLAUDE_CONFIG_DIR改根,path.ts:6)里的 JSONL 布局,以及SessionStart钩子的字段名。上游改任何一个,本地模式的可观测性就会静默失效。
9. 代码地图
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 二态状态机 | packages/happy-cli/src/claude/loop.ts | loop、LoopOptions |
| 换手返回类型 | packages/happy-cli/src/claude/claudeLocalLauncher.ts | LauncherResult |
| 本地模式编排与夺权开关 | packages/happy-cli/src/claude/claudeLocalLauncher.ts | claudeLocalLauncher、doAbort、doSwitch |
| spawn / 标志改写 / fd 3 | packages/happy-cli/src/claude/claudeLocal.ts | claudeLocal、extractFlag、claudeCliPath、ExitCodeError |
| fetch 探针 | packages/happy-cli/scripts/claude_local_launcher.cjs | writeMessage、global.fetch |
| 钩子转发脚本 | packages/happy-cli/scripts/session_hook_forwarder.cjs | (整文件) |
| 钩子 settings 生成 | packages/happy-cli/src/claude/utils/generateHookSettings.ts | generateHookSettingsFile、cleanupHookSettingsFile |
| 钩子接收服务器 | packages/happy-cli/src/claude/utils/startHookServer.ts | startHookServer、SessionHookData |
| 转录文件扫描与去重 | packages/happy-cli/src/claude/utils/sessionScanner.ts | createSessionScanner、messageKey、readSessionEntries、INTERNAL_CLAUDE_EVENT_TYPES |
| 文件监视保险丝 | packages/happy-cli/src/modules/watcher/startFileWatcher.ts | startFileWatcher、FileWatcherOptions |
转录目录映射(含 CLAUDE_CONFIG_DIR) | packages/happy-cli/src/claude/utils/path.ts | getProjectPath |
| 会话有效性校验 | packages/happy-cli/src/claude/utils/claudeCheckSession.ts | claudeCheckSession |
| 跨模式共享状态 | packages/happy-cli/src/claude/session.ts | Session、onSessionFound、consumeOneTimeFlags |
| 远程模式编排 | packages/happy-cli/src/claude/claudeRemoteLauncher.ts | claudeRemoteLauncher |
| 远程键盘语义 | packages/happy-cli/src/ui/ink/RemoteModeDisplay.tsx | interpretRemoteModeKeypress、RemoteModeDisplay |
| stdin 交接 | packages/happy-cli/src/utils/terminalStdinCleanup.ts | cleanupStdinAfterInk |
| 顶层装配(钩子服务器 + loop) | packages/happy-cli/src/claude/runClaude.ts | startHookServer 调用点、remoteScanner |
继续读: 加密同步流与不可读的 RPC 中继(switch/abort 怎么送达)· 远程回合:SDK 驱动、工具授权与消息成型(远程模式里到底发生了什么)· 守护进程与机器(连终端都不用先开)· 返回索引