跳到主要内容

数据截至 (上游 commit 4ec6bbf5884e)

终端底座 — 常驻守护进程、Provider 抽象与滚屏存活

30 秒导读: Orca 要同时开十几个 coding agent,每个 agent 就是一个跑在终端里的长命进程。如果这些终端住在 Electron 主进程里,app 一崩、一更新、一重启,所有 agent 就全没了。这一章讲 Orca 怎么把 PTY 搬进一个独立的常驻守护进程、怎么在守护进程里用一台无头终端模拟器保存屏幕、以及怎么用一份 provider 契约把「本地 / WSL / SSH / daemon」四种执行地点抹成同一个接口。


1. 这是什么(零基础也能懂)

1.1 一句话定义

终端底座 = Orca 里「让一个 shell 进程活着、把它的字节送到屏幕上、并且保证它别死」的那一整套东西。

1.2 先补三个名词

名词一句话解释
PTY(pseudo-terminal,伪终端)操作系统提供的一对虚拟设备。程序以为自己连着一台真终端,实际上另一头是你的 app。node-pty 就是 Node 里开 PTY 的库
终端模拟器把 PTY 吐出来的字节流(含 \x1b[2J 这类转义序列)解释成「第几行第几列什么字符什么颜色」的东西。Orca 前端用 xterm.js
daemon(守护进程)一个脱离父进程独立活着的后台进程。父进程死了它照活

1.3 要解决的问题(场景化)

假设你在 Orca 里同时跑着 6 个 worktree,每个里面有一个 Claude Code 正在改代码,其中一个已经跑了 40 分钟。这时候:

  • Orca 自己崩了 → 6 个 agent 全被带走,40 分钟白跑。
  • Orca 弹了个自动更新,需要重启 → 同上。
  • 你合上笔记本睡了一觉 → 醒来终端还在不在?滚屏还在不在?
  • 你想在一台远程服务器上开终端 → 上层 UI 想不管本地远程一视同仁。

这四件事就是本章全部内容。核心目标只有一句:agent 的命,不能挂在 Orca 这个 GUI 进程的命上。

1.4 用起来什么样

用户视角其实很朴素——他什么都察觉不到:

1. 开 Orca,在 worktree A 里敲 claude,agent 跑起来
2. Orca 自动更新,窗口消失又出现(主进程整个换了一个)
3. 回到 worktree A 的终端标签
→ 屏幕上还是 agent 刚才输出的那些字,光标位置一样,agent 还在跑
4. 直接接着输入回车 → agent 收到了

第 3 步能成立,是因为在第 2 步里 shell 的父进程压根不是 Orca,而是一个躲在旁边的 daemon。

1.5 一句话直觉

把 daemon 当成 tmux,把 Orca 当成连上 tmux 的终端软件。 你关掉终端软件,tmux 里的会话不受影响;重新 attach 就回到原地。Orca 做的事在结构上就是这个,只是它自己实现了整条链路,而且额外把「屏幕长什么样」也周期性写到磁盘上——所以连 tmux 都做不到的「daemon 自己崩了也能恢复出滚屏」,Orca 能做到。


2. 顶层全景(它大概怎么转)

2.1 四层结构

怎么读这张图:从下往上是「字节产生 → 送达屏幕」的方向,从上往下是「按键 → 送进 shell」的方向。每一层换一个进程。

┌──────────────────────────────────────────────────────────┐
│ ① 渲染进程 (Electron renderer) │
│ xterm.js 真渲染 + 每个 pane 一条 PtyConnection │
│ 只负责「画」和「收键盘」 │
└───────────────┬──────────────────────────▲───────────────┘
│ ipc: pty:write │ ipc: pty:data
┌───────────────▼──────────────────────────┴───────────────┐
│ ② Electron 主进程 │
│ ipc/pty.ts —— 编排层 │
│ · 选 provider(本地 / SSH / daemon) │
│ · 攒批 + 背压 + 隐藏面板丢弃 │
│ · 不持有 PTY 本身 │
└───────────────┬──────────────────────────▲───────────────┘
│ unix socket / named pipe │
│ NDJSON, 双通道 │
┌───────────────▼──────────────────────────┴───────────────┐
│ ③ daemon(独立 Node 进程,detached) │
│ TerminalHost → Session ×N │
│ 每个 Session 挂一台 @xterm/headless 模拟器(权威模型) │
│ 周期性 checkpoint 落盘 │
└───────────────┬──────────────────────────▲───────────────┘
│ node-pty │
┌───────────────▼──────────────────────────┴───────────────┐
│ ④ 真 PTY + shell 进程 + 它的子孙(claude / codex / …) │
└──────────────────────────────────────────────────────────┘

第 ③ 层是这一章的重点:它独立于 ①②,①② 整个换掉它也不动。

2.2 部件一句话职责

部件干什么在哪个文件
IPtyProvider「怎么开一个 PTY、怎么读写它」的唯一契约src/main/providers/pty-provider-contract.ts:123
LocalPtyProvider直接在主进程里 node-pty.spawn,无持久化的降级路线src/main/providers/local-pty-provider.ts:545
SshPtyProvider把同一套调用转发到远端 relay(本章不展开,见第 5 章)src/main/providers/ssh-pty-provider.ts:33
DaemonPtyAdapter主进程侧的 daemon 客户端,实现同一个契约src/main/daemon/daemon-pty-adapter.ts:227
DaemonPtyRouter多代 daemon 并存时,按 sessionId 把调用路由到正确那一代src/main/daemon/daemon-pty-router.ts:16
DegradedDaemonPtyProviderdaemon 能读老会话但开不了新 PTY 时的混合路由src/main/daemon/degraded-daemon-pty-provider.ts:23
DaemonSpawner决定 socket/token/pid 三件套的路径,并 fork daemonsrc/main/daemon/daemon-spawner.ts:49
TerminalHost / Sessiondaemon 内的会话表与单会话状态机src/main/daemon/terminal-host.ts:46session.ts:99
HeadlessEmulatordaemon 内的无头 xterm,屏幕状态的权威副本src/main/daemon/headless-emulator.ts:57
HistoryManager / HistoryReadercheckpoint + 增量日志的写与读(冷恢复)src/main/daemon/history-manager.ts:35history-reader.ts:60
ipc/pty.ts主进程编排:批处理、背压、隐藏闸门、生命周期src/main/ipc/pty.ts(6529 行)
connectPanePty渲染端一条 pane↔PTY 绑定的全部逻辑src/renderer/src/components/terminal-pane/pty-connection.ts:1114

2.3 主线走一遍(高层)

开一个终端:

renderer 挂载 pane
→ ipc: pty:spawn
→ 主进程 getProvider() 挑到 DaemonPtyAdapter
→ adapter 先问磁盘「这个 sessionId 有没有可恢复的历史?」
→ RPC createOrAttach 到 daemon
· 有历史 → 把历史当 seed 灌进新 Session 的模拟器,再开 shell
· 无历史 → 直接开 shell
→ daemon 返回 pid + snapshot
→ 主进程把 snapshot 回给 renderer,renderer 一次性写进 xterm

一个字节的旅程(输出方向):

shell 写 stdout
→ node-pty 回调 → Session.handleSubprocessData
→ 写进 HeadlessEmulator(模型更新)+ 记进 pendingOutput(待落盘)
→ 扇给已 attach 的客户端 → stream socket 攒批
→ 主进程收到 → pendingData 队列攒批 → ipc 送 renderer
→ renderer xterm.write

注意:模型更新在扇出之前。这是整章的地基——只要模型先更新了,后面任何一段丢字节都能靠「重新要一张快照」补回来。


3. 为什么 PTY 不能住在 Electron 主进程里

3.1 问题本身

Electron 主进程会因为三件事整体消失:

事件频率后果(如果 PTY 在主进程)
渲染/主进程崩溃偶发所有 agent 被 SIGHUP,全死
自动更新重启每周若干次同上,且是必然发生
用户主动退出每天同上

对一个「同时跑一队 agent」的 IDE 来说,第二条是致命的:更新本身是好事,但代价是杀掉所有正在工作的 agent。

3.2 解法:一个 detached 的普通 Node 进程

daemon 的入口是一个可以被 fork 的普通脚本 daemon-entry.js,命令行只吃四个参数(socket / token / pid 记录 / launch nonce),见 parseArgssrc/main/daemon/daemon-entry.ts:43)。

fork 它的地方在 createOutOfProcessLaunchersrc/main/daemon/daemon-init.ts:445),其中这几个选项每一个都在解决一个具体的死法(daemon-init.ts:669-705):

选项为什么
detached: true + child.unref()脱离 Electron 的进程组,Electron 退出不带走它
cwd: userDataPathdaemon 会活过它出生时的那个 worktree;worktree 被删了它的 process.cwd() 还得有效
ELECTRON_RUN_AS_NODE: '1'让这个 fork 当纯 Node 跑。Electron 的 GPU/display 初始化会干扰 node-pty 的 posix_spawn
stdio: ['ignore','ignore','pipe','ipc']stdout 忽略(否则阻塞退出),stderr 收启动期崩溃日志,ipc 收 ready 信号
execPath: relocatedHost.execPath(win32 打包态)把 daemon 的可执行镜像挪到 userData,躲开 NSIS 更新器的「杀进程 + 删目录」范围

daemon 自己也做了对称的防护。最重要的一条是吞掉 node-pty 的原生异常daemon-entry.ts:150-168):

// daemon-entry.ts:118
process.on('uncaughtException', (err) => {
const msg = err?.message ?? ''
const isNativeError = err?.name === 'Error' && (msg.includes('pty') || msg.includes('EIO') || ...)
if (isNativeError) { /* 记日志后继续 */ return }
throw err // 逻辑 bug 仍然让 daemon 崩,不掩盖真问题
})

它在干嘛:node-pty 会从 C++ 层抛出 JS try/catch 抓不到的 Napi::Error(典型场景是往一个刚被关掉 fd 的 PTY 写字节)。Node 默认行为是打印堆栈然后退出——一个 PTY 的意外,会带走 daemon 里所有会话。这里只放行「像 PTY 错误」的那一类,其余照旧崩掉。

3.3 版本号写进 socket 文件名

daemon 活得比 app 长,所以「新 app 遇到老 daemon」是常态而不是异常。Orca 的做法是把协议版本直接编进端点名(daemon-spawner.ts:98-118):

POSIX : <runtimeDir>/daemon-v30.sock / daemon-v30.token / daemon-v30.pid
Win32 : \\?\pipe\orca-terminal-host-v30-<sha256(runtimeDir)前12位>

于是破坏性协议变更天然不会撞车:v36 的 app 找不到 v36 的 socket,就自己 fork 一个;v35 的老 daemon 还在自己的 daemon-v35.sock 上继续伺候它那些老会话。当前版本与全部历史版本列在 daemon-protocol-version.ts:3-33(PROTOCOL_VERSION = 36PREVIOUS_DAEMON_PROTOCOL_VERSIONS 是 1..35)。

启动时 createLegacyDaemonAdaptersdaemon-init.ts:1387)会挨个探测这 35 个老 socket,凡是还能连上的,就为它建一个只读不 respawn 的 adapter——老 PTY 继续路由回它原来的 daemon。

3.4 复用还是替换:一张决策表

每次启动,launcher 都要对「已经在那儿的那个 daemon」做判决。判决输入是 checkDaemonHealthdaemon-health.ts:22,四态):它先发 hello 握手,握手通过后再发一条 ptySpawnHealthdaemon-health.ts:102-110)。为什么要第二问:一个协议上活着但 cwd 已失效、或 node-pty spawn-helper 丢了的 daemon,能回 ping 却开不出终端。

健康态还有活会话?决策依据
healthy + 解析器正常 + 同一份 app bundle直接复用(adopt)daemon-init.ts:550-575
healthy 但来自别的 app 路径 / 更旧的 bundle保留不动(宁可代码旧,也不杀活会话)daemon-init.ts:533-537
healthy 但来自别的 app 路径 / 更旧的 bundle替换daemon-init.ts:538-549
pty-spawn-unhealthy降级保留:老会话继续用它,新终端走本地 providerdaemon-init.ts:592-597
unreachable / rejected保留daemon-init.ts:598-602
unreachable / rejected杀掉重开daemon-init.ts:622-624

有一个细节值得单拎出来:unreachable 不等于死。一台负载爆表的机器会让健康检查超时,而这台 daemon 手里可能攥着十几个 agent。所以代码里有一段宽限重试daemon-init.ts:579-590):只要 socket 还能建连,就反复问 listSessions,最多 WEDGED_DAEMON_GRACE_RETRIES = 11 次(daemon-init.ts:73),约 60 秒。只有 11 次都问不出会话数,才认定它是真卡死。

3.5 三种 provider 拓扑

判决结果落成三种不同的 provider 装配(daemon-init.ts:996-1010):

只有当前代 daemon 当前代 + 老代 当前代开不了新 PTY
┌──────────────────────┐ ┌───────────────────────┐ ┌──────────────────────────┐
│ DaemonPtyAdapter │ │ DaemonPtyRouter │ │ DegradedDaemonPtyProvider│
│ (直接用) │ │ ├─ current adapter │ │ ├─ current adapter (旧会话)│
└──────────────────────┘ │ └─ legacy adapters×N │ │ ├─ legacy adapters │
└───────────────────────┘ │ └─ fallback = 本地 provider│
按 sessionId 查表路由 └──────────────────────────┘
新 PTY → 本地,无持久化

Router 的路由表 sessionAdaptersdiscoverLegacySessions() 里建立(daemon-pty-router.ts:36),查不到就落到 current(adapterFordaemon-pty-router.ts:316)。Router 里有个容易忽略的正确性判断:

// daemon-pty-router.ts:59
supportsAgentSessionClaims(): boolean {
// 一个老 daemon 可能还攥着可恢复的 PTY,所以「支持」必须每一路都支持
return this.allAdapters().every((adapter) => adapter.supportsAgentSessionClaims())
}

能力探测取交集,不取当前代。 因为路由是按 sessionId 走的,上层没法保证下一次调用落在新 daemon 上。

降级模式的用户可见行为写在那条 warn 里(daemon-init.ts:593-595):已有会话照常工作,新终端跑在本地 provider 上、没有 daemon 持久化,直到用户手动 Restart。

3.6 手动重启 daemon 的七步

restartDaemondaemon-init.ts:1138)是唯一会主动杀掉当前代 daemon 的入口,用一个模块级 promise 做并发合流(daemon-init.ts:101)。七步的顺序本身就是设计(daemon-init.ts:1159-1262):

做什么为什么必须在这个位置
1给每个活会话合成 pty:exitdaemon 的 shutdown 路径不会向客户端扇 onExit,不补的话渲染端永远等不到退出
2解绑渲染端监听必须在步 1 之后(合成的退出要送到),在步 6 之前(不留悬空绑定)
3杀当前代 daemon,legacy 不动
4复用同一个 spawner 重新 ensureRunning长命 adapter 里烤进了 respawn 闭包,换 spawner 会让闭包失效
5建新 adapter + 拿生命周期租约临时租约与永久租约重叠,避免新 daemon 在收养空档里自我退休
6原子换掉模块级 adapter 与 localProvider对渲染端是一瞬间的事
7重新绑定渲染端监听

对比一下正常退出:disconnectDaemon()daemon-init.ts:1269只断连不杀,并且刻意把会话的历史记录留在「未干净结束」状态——这样万一 daemon 在 Orca 关着的时候崩了,下次开机还能冷恢复。


4. 一条 PTY 的字节旅程与三道背压

4.1 为什么背压是个真问题

一个 cat 大文件、或者一个疯狂重绘的 TUI,一秒能吐几十 MB。这些字节要穿过三段队列才能到屏幕上,任何一段无界增长都是内存炸弹;而任何一段简单粗暴地阻塞,都会让别的面板的按键回显卡住。

Orca 在四个位置分别做了不同性质的处理。下图的圈号与下表一一对应,标在这道手段实际生效的那一段上:

shell ──→ daemon Session ──→ stream socket ──→ 主进程 pendingData ──→ renderer
│ │ │ │
│ ① node-pty │ ② 攒批 + │ 攒批 + 水位 │ ④ 隐藏投递闸门
│ pause() │ 浅 socket 门 │ (越线时回头 │ 直接不发给
│ 真背压 │ ③ keep-tail 丢弃 │ 触发 ①) │ renderer
位置手段丢字节吗谁触发
① node-pty pause()停止读 master fd → 内核缓冲满 → 子进程 write() 阻塞否,真背压主进程水位
② stream socket 浅门队列深了就 HOLD 大户,小会话放行否,只是延后socket writableLength
③ keep-tail 丢弃后台会话超额时丢最老的字节,插一个 dataGap会话被标记 background
④ 隐藏投递闸门模型已消费后,直接不发给 renderer(对 renderer)renderer 报「没有可见视图」

③④ 敢丢的唯一理由:daemon 的模型是权威的,重新可见时靠一张快照恢复。

4.2 daemon 内:模型先行

Session.handleSubprocessDatasrc/main/daemon/session.ts:335)只做一件事——把字节交给 shell-ready 屏障(session-shell-ready-barrier.ts:108ingestSubprocessData);就绪后经 startup ingress(PtyStartupIngress.onEmission)汇入输出平面,真正的出口是 SessionOutputPlane.emitsession-output-plane.ts:160):

// session-output-plane.ts:160-179(节选)
this._outputSequence += rawLength // 绝对序号,跨丢弃仍单调
if (data.length > 0) {
this.emulator.write(data) // ① 先更新权威模型
this.record({ kind: 'output', data }) // ② 再记待落盘
}
for (const client of this.attachedClients) { client.onData(...) } // ③ 最后才扇出

outputSequence 是这条流水线的对账凭据:它数的是源头字节数,而不是「送到了多少」。所以下游任何一处丢弃都不会打乱它,快照带着它一起返回(session-output-plane.ts:123getSnapshot),渲染端就能算出「这张快照覆盖到哪,之后来的哪些是重复的」。

4.3 双 socket:控制面与数据面分开

daemon 的 hello 握手带一个 roledaemon-hello-protocol.ts:1-7),同一个 clientId 要建两条连接(daemon-server.ts:738:487):

通道跑什么特性
controlRPC 请求/响应(createOrAttach、getSnapshot、kill…)+ notify_ 前缀的即发即忘(write/resize/pausePty)请求-响应,有超时
stream单向的 PTY 输出事件、dataGap、transientFact单向 FIFO,可以被攒批和丢弃

分开的价值:一条 8MB 的输出洪水堵在 stream 上时,control 上的 getSnapshotkill 照常秒回。孤儿 stream socket(没有对应 control)直接丢弃。

两条通道上跑的都是 NDJSON(一行一个 JSON),上限 16MB/行(ndjson.ts:1)。

一个可以跳过的历史痕迹: src/main/daemon/binary-frame.ts 定义了一套 [type:1][len:4BE] 的二进制帧协议(types.ts:389-399FrameType),从设计上看是给「daemon ↔ 独立 PTY 子进程」准备的。但在本 commit 里,全仓只有它自己的测试文件引用它——实际的 createPtySubprocesspty-subprocess.ts:654)是在 daemon 进程内直接 pty.spawn,没有再多一层进程。所以这套帧协议目前是未接线的。

4.4 stream 攒批器:浅 socket + 小会话旁路

DaemonStreamDataBatcherdaemon-stream-data-batcher.ts:45)是这条链上设计密度最高的一段。它的目标是一句话:别让 A 面板的几 MB 输出,把 B 面板的一次按键回显埋在后面。

它的手段是「刻意保持 socket 浅」(daemon-stream-data-batcher.ts:24-33):

常量作用
STREAM_DATA_BATCH_INTERVAL_MS2ms攒批窗口;与主进程的批窗口对齐,两处各只吃半个窗口的延迟
SHALLOW_SOCKET_WRITE_GATE_BYTES128KBsocket 已缓冲超过这个值就不再灌大户;128KB 高于 socket 的 ~16KB 高水位,保证一定会有 drain 事件把它唤醒
BULK_WRITE_SLICE_CHARS64KB一次最多写这么多,免得一次调用又把 socket 灌深
SMALL_SESSION_HOLD_BYPASS_CHARS4KB小会话旁路:排队量不到 4KB 的会话永远放行——几 KB 的回显/查询回复不可能是洪水
HELD_WRITE_THROUGH_TOTAL_CHARS32MB安全阀:憋太多就不管回显延迟了,直接写穿,保内存

flush 的核心循环(daemon-stream-data-batcher.ts:159-228):socket 深了就把「大户」的条目 HOLD 住(同一会话后续条目必须一起 HOLD,否则会话内字节序会乱),小户继续流。

还有一处很有意思的工程 trick(daemon-stream-data-batcher.ts:240-250):

// 不能用空写,空写的回调会立刻触发;必须是一条真的协议 no-op 行
socket.write(encodeStreamDataEvent(sessionId, ''), () => {
this.refillArmedClients.delete(clientId)
this.flush(clientId)
})

为什么需要它:Node 的 'drain' 只在缓冲完全清空时才触发,几 MB 的积压意味着好几秒才响一次。这里塞一条零长度的 data 事件,用它的写完成回调,在字节还在飞的时候就把 HOLD 队列续上。

4.5 keep-tail 丢弃:后台面板只保尾巴

当主进程告诉 daemon「这个会话没有任何可见视图」(setPtyBackgrounded),它的 stream 副本就降格成监控流——只用来算 agent 状态和尾部预览,真正要看时再拉快照。于是它变成可丢的(daemon-stream-keep-tail-drop.ts 头部注释 1-9)。

策略是丢最老的,保尾巴:

常量理由
BACKGROUND_SESSION_KEEP_TAIL_CHARS512KB要足够覆盖一次完整 TUI 重绘(cols×rows×SGR ≈ 100KB),保证留下的尾巴能自洽地渲染出一屏
BACKGROUND_SESSION_MIN_KEEP_TAIL_CHARS64KB每个面板的地板
BACKGROUND_GLOBAL_KEEP_BUDGET_CHARS2MB全局预算:N 个后台会话各留 512KB 就是 N×512KB,切回来时要排队排到天亮
drop capkeep 的 2 倍滞后区间,避免抖动

全局预算怎么分:backgroundSessionKeepTailChars(n) = clamp(2MB / n, 64KB, 512KB)daemon-stream-keep-tail-drop.ts:60)。注释里给了实测依据:9MB 积压 → 2.5s 恢复,超过 1.5s 的预算;压到 ~2MB 后任何一次切换都在 ~250ms 内排空。

dropOldestQueuedForSessiondaemon-stream-keep-tail-drop.ts:85)里两个细节:

  • 丢掉的字节留下一个 dataGap 控制条目占位(droppedChars + sequenceChars),接收方据此知道「这里有洞,别拿它当连续流解析」。
  • 查询字节要抢救:DSR / DA / DECRQM 这类会让对面程序阻塞等回复的序列,会被 salvageDroppedData 从被丢的数据里挑出来,以一条 sequenceChars: 0 的小条目重新插回 gap 位置(daemon-stream-keep-tail-drop.ts:174-186)。丢掉普通输出只是少看几行;丢掉一个 DSR 会把对面 TUI 永久卡死。

4.6 主进程:水位驱动的真背压

主进程的队列是 pendingDataipc/pty.ts:2597),它的容量上限跟着用户设的滚屏行数走(ipc/pty.ts:2666-2668)。真正把压力传回子进程的是 PtyProducerFlowControllerpty-producer-flow-control.ts:21):

常量说明
PRODUCER_FLOW_HIGH_WATERMARK_CHARS256KB超过就 pauseProducer
PRODUCER_FLOW_LOW_WATERMARK_CHARS32KB掉到这以下才 resumeProducer
PRODUCER_PAUSE_REASSERT_INTERVAL_MS5s重申间隔

高低水位差得这么大是刻意的滞后pty-producer-flow-control.ts:4-6):不然队列每排空一片就 pause/resume 抖一次。

链条的另一端是 LocalPtyProvider.pauseProducerlocal-pty-provider.ts:1207):

// node-pty pause() 停止读 master fd → 内核缓冲填满 → 洪水子进程阻塞在 write(),真背压
pauseProducer(id: string): void { try { ptyProcesses.get(id)?.pause() } catch { } }

而 daemon 侧的 pausePty 是 notify(即发即忘),可能丢。所以 daemon 给自己加了个 5 秒失效保护 PRODUCER_PAUSE_FAILSAFE_MSsession.ts:42):超时自动 resume,绝不让一个丢失的 resume 把 shell 永久卡死。而那个 5 秒的重申间隔(上表)正好是主进程对这个失效保护的应答——洪水还在,就再 pause 一次。

4.7 隐藏投递闸门:模型消费之后再丢

最后一道在 pty-hidden-delivery-gate.ts。逻辑很短但语义要点很多:

  • 丢弃发生在模型摄入之后pty-hidden-delivery-gate.ts:5-8)——runtime 已经解析过这一块了,丢的只是「送给 renderer 画」这一份。
  • 有「投递兴趣」注册的 PTY 豁免(setRendererPtyDeliveryInterest:73):粘贴节流、后台 agent 启动、自动化观察者这些旁路消费者仍要原始字节。
  • droppedSinceHiddenPtys 是个一次性闩锁:26-30):第一次丢就发一个恢复标记,只有 unmarkHiddenRendererPty 会消费它。重新标记 hidden 不清这个闩——否则隐藏态下的重挂载会让「丢过字节」这件事被遗忘,再显示时就不去拉快照了。

渲染端与之对应的是一整套 seq 对账(pty-connection.ts:6002 setRestoredSnapshotBaseline)。它维护四个游标:快照 seq、期望的下一块起始 seq、主进程未投递积压的起点、以及所属 ptyId。有一个边界判断特别能说明这类代码的难度(pty-connection.ts:6014-6023):如果主进程报告积压为空,就不能建立基线——因为投递是一次且有序的,此时不会再有 ≤ 快照 seq 的块到来;硬建基线反而会把来自另一个 seq 域(会话重生、计数器重启)的新字节误判成重复而丢掉。


5. 「重开就在」:headless 模型与三层持久化

5.1 三层,各管一种死法

存在哪覆盖哪种死法关键文件
① daemon 内存模型HeadlessEmulator 的 xterm bufferOrca 崩溃 / 更新重启 / 关窗headless-emulator.ts
② 磁盘 checkpoint + 增量日志userData/terminal-history/<session>/daemon 自己崩溃、机器重启history-manager.ts / terminal-history-log.ts
③ 渲染端滚屏快照文件userData/terminal-scrollback/v1-<hash>.bin会话已结束、但用户想看到上次那屏字terminal-scrollback-snapshots.ts

层与层是降级关系:①在就用①(最新);①没了用②(最多落后几秒);②也没有就靠③(只是文本,不能继续交互)。

5.2 第①层:daemon 里那台 xterm

HeadlessEmulatorheadless-emulator.ts:57)就是 @xterm/headlessTerminal 加两个插件(:89-94):

  • SerializeAddon —— 把当前 buffer 反向序列化回一串 ANSI,写进另一台 xterm 就能得到同样的屏幕。这是「重开就在」的技术核心。
  • Unicode11Addon + Orca 自己的宽度 provider —— 必须和渲染端用同一套字符宽度表,否则 emoji 行会算错列宽,模型和视图逐渐错位撕裂。

有一条纪律必须专门说:daemon 的模拟器绝不能回复查询序列。 构造 Session 时刻意不传 onQueryReplysession.ts:139-147):

this.emulator = new HeadlessEmulator({ cols, rows, scrollback, wslDistro })
// 不传 onData:daemon 模拟器绝不能回复查询序列 —— 渲染端的 xterm 才是权威应答者,
// daemon 抢答会跑到它前面把它的回复覆盖掉

为什么重要:像 OSC 11(问背景色)、DA1(问设备属性)这类序列,程序问完会读一条回复。两台模拟器同时答,shell 就收到两条,第二条变成乱码打进命令行。HeadlessEmulator 因此把回复能力做成了「按写入块授权」的窗口(queryReplyForwardingDepth:65-67 / :154-162):只有显式带 forwardQueryReplies 的那次写入,其解析期产生的回复才被放行;种子回灌、快照重放这些写入一律不放行。

getSnapshotheadless-emulator.ts:226)产出的结构(terminal-snapshot.ts:4)值得逐字段看,因为每个字段都对应一个「恢复后不对劲」的 bug:

字段是什么不要它会怎样
snapshotAnsi当前活动 buffer 的序列化
scrollbackAnsialt-screen 时另外抓的普通 bufferTUI 退出后滚屏是空的
rehydrateSequences模式重放(bracketed paste / 鼠标 / 光标键 / kitty 键盘)恢复后鼠标点不动、粘贴不带包围
oscLinksOSC 8 超链接的行列范围链接失去可点性(SerializeAddon 不round-trip 它)
pendingEscapeTailAnsi卡在解析器里的半截转义序列下一块真数据接不上这半截,屏幕上直接渲染出乱码字面量
outputSequence绝对源字节序号渲染端无法对账去重
lastTitle最近一次 OSC 0/1/2 标题SerializeAddon 不 round-trip 标题

pendingEscapeTailAnsi 这一条尤其能说明「用 serialize 做快照」的隐藏坑:xterm 的解析器状态不在 buffer 里serialize() 看不见它。所以模拟器自己额外维护了这条尾巴(advancePartialEscapeTailheadless-emulator.ts:190/:214),并在快照里单列一个字段,明确要求恢复方最后写它——因为消费者往往还要在正文之后追加自己的重置序列,任何 ESC 都会把这半截序列打断。

另外 writeSyncheadless-emulator.ts:192)也不是性能优化:冷恢复重放必须同步完成,异步写会让紧随其后的 getSnapshot 抓到一个只应用了一半的流。

5.3 第②层:checkpoint + 增量日志

只靠内存模型,daemon 自己崩了就全没了。所以 DaemonPtyAdapter 在主进程侧开了个 5 秒节拍(CHECKPOINT_INTERVAL_MS = 5_000daemon-pty-adapter.ts:324)把模型抄到磁盘。

设计要点是「脏位驱动 + 增量优先」:

markSessionDirty(写入/resize/收到 data) → scheduleCheckpointTimer
│ 5s

checkpointDirtySessions

┌─────────────────────────┴──────────────────────┐
│ │
需要全量快照?(首次/冷恢复后/溢出) 否 → takePendingOutput
│ │
▼ ▼
takePendingOutput(includeSnapshot) appendIncrements(seq, records)
→ checkpoint.json(整屏) → output.log(追加帧)

四个关键决策:

(a) 为什么增量优先。 日志携带的是崩溃前 ~5 秒的逐字节输出,而 checkpoint 可能已经落后一整个日志上限(5MB 输出)(history-reader.ts:121-125)。

(b) takePendingOutput(includeSnapshot: true) 必须原子。 SessionOutputPlane.takePendingOutputsrc/main/daemon/session-output-plane.ts:206Session 的包装在 session.ts:229)在同一个 tick 内清空 pending 记录并序列化快照。如果分两次调用,中间到达的字节会既进快照又留在 pending,冷恢复时被重放两遍(daemon-pty-adapter.ts:2436-2437)。

(c) 全量快照有 45 秒冷却。 FULL_CHECKPOINT_COOLDOWN_MSdaemon-pty-adapter.ts:326):一个持续刷屏的会话每 5 秒都会触发一次「需要全量」,多 MB 的序列化每 5 秒写一次盘会打死磁盘。冷却期内返回 'deferred',会话保持脏、等下一轮(checkpointSessiondaemon-pty-adapter.ts:2372)。

(d) 宁可陈旧,不可有洞。 这条规则在代码里出现了三次。最清楚的一处在 takeSnapshotAndCheckpointdaemon-pty-adapter.ts:2538-2541):快照落盘失败时,那批已经被 take 走的 records 是直接丢弃而不是追加的——因为这些字节本来就在那张失败的快照里,把它们按下一个连续 seq 追上去,等于把窟窿糊平,让日志的 seq-gap 检测失效。

日志的磁盘格式自带撕裂检测(terminal-history-log.ts:1-17):

header = 'OCKL' | u8 formatVersion | u32le generation (9 字节)
frame = u8 kind | u32le payloadLength | payload
0x01 batch(u32le seq) · 0x02 output(utf8) · 0x03 resize(u16×2) · 0x04 clear

长度前缀让撕裂的尾帧可被识别decodeTerminalHistoryLogterminal-history-log.ts:83):崩溃可能把最后一次追加写坏,解码时发现 payload 越界就置 truncatedTail,只重放完整前缀。而 batch 帧里的 seq 一旦不连续,整个日志直接判废返回 null——不连续意味着有一批被整个丢了(比如主进程在 take 和 append 之间挂了),字节流有洞,重放它只会得到一个损坏的终端。

5.4 冷恢复的完整判定链

HistoryReader.detectColdRestoreStatehistory-reader.ts:97):

有 recovery quarantine 标记? ── 是 ──> unreadable(不碰,避免反复读坏文件)
│否
meta.json 存在? ── 否 ──> none
│是
meta.endedAt !== null? ── 是 ──> none ← 「干净结束过」= 不该恢复
│否(unclean = 崩溃或被 suspend)

读 checkpoint.json(可能读失败)

restoreFromIncrementalLog(日志重放,最新) ──有──> restored
│无
checkpoint 有? ──有──> restored(稍旧)
│无
legacy scrollback.bin ──有──> restored(最旧)
│无
none / unreadable

meta.endedAt 是这套机制的开关:正常关闭会写它,崩溃不会。所以 disconnectDaemon() 刻意不写(§3.6),而用户主动睡眠一个会话时,走的是 suspendSessiondaemon-pty-adapter.ts:1277)——物理退出不算干净结束,最后那次 checkpoint 才是唤醒时的恢复权威。

spawn 路径上还有一次省钱优化(daemon-pty-adapter.ts:595-612):先用 probeRestorableHistory(只读 meta.json,history-reader.ts:71)判断「有没有可能恢复」,再用 getAppliedSize 探活;会话还活着的话就跳过重放,因为一张活模型的快照必然优于磁盘。冷恢复重放最多 ~5MB,跑在主进程上,白跑一次是实打实的卡顿。这条重放还过一个并发度为 1 的信号量(coldRestoreReplaySemaphorehistory-reader.ts:58),免得多个 pane 同时挂载时把主进程连打几个来回。

5.5 恢复出来的历史怎么灌回新 shell

恢复出来的是一堆 ANSI,要先喂进新 Session 的模拟器、再开 shell,这样用户看到的是「老内容 + 新提示符」。

段落怎么切(getRecoveredHistorySeedSegmentsterminal-history-seed-segments.ts:15):alt-screen 的会话只灌普通 buffer(TUI 状态不该复活);普通会话灌 rehydrate + snapshot + 尾巴,顺序固定。

传输分两档(daemon-pty-adapter.ts:682-727):

体量路径
≤ 1M code units(TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS直接内联在 createOrAttach
更大startHistorySeedTransfer → N × appendHistorySeedTransfer(512KB/块)→ finishHistorySeedTransfer

分块器 iterateTerminalHistorySeedChunksterminal-history-seed-chunks.ts:20)专门处理 UTF-16 代理对:切点落在高低代理之间会产生两个非法半字符,所以它会把落单的高代理攒到下一块。传输前还先算一遍 sha256(measureTerminalHistorySeed:60)。

daemon 收到后同步灌入(session.ts:150-153):

this._historySeeded = opts.historySeedChunks === undefined
? undefined
: opts.historySeedChunks.every((chunk) => this.emulator.writeSync(chunk))

every 的短路在这里是安全的(注释解释了):writeSync 只会整体失败(模拟器已销毁 / 无同步写 API),既然后面的块也写不进去,那就必须停——跳过一块继续写会种下一个撕裂的流。而且这一步必须在注册 onData 监听之前完成,因为 shell 可能在订阅的同一 tick 就吐出提示符。

5.6 第③层:渲染端滚屏快照文件

这一层管的是「会话已经没了,但用户切回这个 tab 还想看到上次那屏字」。存储很简单(terminal-scrollback-snapshots.ts):

  • ref = v1- + sha256(tabId + '\0' + leafId) 前 32 hex(:41),路径正则严格校验后才拼(:47),避免路径穿越。
  • 写:temp 文件 + rename 原子替换,权限 0600,目录 0700(:99)。
  • 只留尾部 5MB(TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT),读回只取尾部 512KB(TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT)。
  • 截断时会往前跳过 UTF-8 续字节(trailingUtf8Bytes / readTrailingUtf8:67/:79),保证不从一个多字节字符中间切开。

renderer 那边通过一个全局注册表把「序列化这个 pane」的能力暴露给主进程(registerPtySerializerpty-buffer-serializer.ts:49)。这里有两个 React 特有的坑:

  • 所有权 token:每次注册铸一个 Symbol,注销闭包只在 owner 匹配时才删表项(:53-67)。否则 StrictMode 的首挂载在被丢弃时会把重挂载后的真实注册顺手删掉。
  • 标题要单独回传SerializeAddon 不 round-trip OSC 0/1/2,所以标题得靠 registerPtyTitleSourceonTitleChange 另存一份(:76)。

另外与滚屏无关但同属「跨重启存活」的还有一条:shell 命令历史按 worktree 隔离injectHistoryEnvterminal-history.ts:99)在 spawn 环境里注入 HISTFILE=<historyRoot>/<hash(worktreeId)>/zsh_history,用的是业界通行的 check-before-set(调用方已给 HISTFILE 就不动)。于是在 worktree A 里按上箭头,只会翻出 A 的命令。孤儿目录由 runHistoryGcterminal-history-gc.ts:116)在启动后 10 秒清理,并且有 5 分钟的最小年龄保护(GC_MIN_AGE_MS:18)来防 TOCTOU:GC 快照 live worktree 列表之后新建的目录,不能被当成孤儿删掉。


6. Provider 契约:本地 / WSL / SSH / daemon 抹成一个接口

6.1 契约长什么样

IPtyProviderpty-provider-contract.ts:123)分两半,这个划分本身就是设计:

必需方法(每个 provider 都得有):spawn attach write resize shutdown sendSignal getCwd getInitialCwd clearBuffer hasChildProcesses getForegroundProcess serialize revive listProcesses getDefaultShell getProfiles onData onReplay onExit

可选方法 = 能力声明?:),调用方必须在没有它时依然正确工作:

可选方法谁实现没有它时的退路
pauseProducer / resumeProducerLocal、Daemon(v19+)只靠主进程 pending 上限兜内存(pty-provider-contract.ts:142-151
setPtyBackgroundedDaemon(v20+ 且 v29+)不做 keep-tail 瘦身
getBufferSnapshotDaemon(v20+)恢复只能靠重放,没有权威模型
canProvideAuthoritativeBufferSnapshotDaemon: 看版本;SSH: 恒 falsessh-pty-provider.ts:73
getAppliedSizeLocal(读 proc.cols/rows)、Daemon(RPC getSize)、SSH视为「无法确认」,重发一次 resize
onBackgroundStreamEvent只有会瘦身的传输没有 gap / transientFact
onWriteUnavailable只有 daemon adapter单个 pane 各自发现各自的死连接
probePtyLivenessDaemon、Router返回 null = 无法判定

getAppliedSize 的注释(pty-provider-contract.ts:181-191)把这类可选能力的价值讲得很透:远程 provider 的 resize() 是 fire-and-forget 的 notify,可能被静默丢弃(会话还没起来、句柄已死、冷恢复时被快照列数强制覆盖),而调用方以为它生效了。有个权威回读,渲染端在恢复时就能做漂移检测并重新断言;返回 null 的语义明确是「无法确认」而不是「没变」。

6.2 四种实现对比

LocalPtyProviderDaemonPtyAdapterDaemonPtyRouterSshPtyProvider
PTY 住在哪Electron 主进程daemon 进程转发给某个 adapter远端主机
活过 app 重启是(远端自己活着)
权威屏幕模型无(renderer 的 xterm 就是唯一副本)有(headless)委托无(false
真背压node-pty.pause()notify + 5s 失效保护委托传输层
落盘持久化checkpoint + 日志委托远端
主要用途降级 / headless 场景主路径升级期多代共存远程执行

对上层(ipc/pty.ts)来说,这四个是同一个类型;切换靠一次赋值 setLocalPtyProvider(routedAdapter)daemon-init.ts:1032,函数在 ipc/pty.ts:2083)。

6.3 能力靠版本号门控,不靠 try/catch

DaemonPtyAdapter 的构造函数里一次性算好所有能力位(daemon-pty-adapter.ts:371-376):

能力门槛为什么这么设计
checkpoint 持久化v4+老 daemon 会拒绝 getSnapshot,无门控就是每 5 秒刷一次错误日志
增量 checkpointv13+老的退化为全量快照
生产者流控v19+老的静默 no-op
权威 buffer 快照v20+v19 能瘦身但拿不到绝对 seq,快照没法对账
startup ingressv25+
agent session claimv26+
2031 退订 factv29+见下

最后一条是个绝好的「向后兼容陷阱」样本(daemon-protocol-version.ts:20-30):pre-v29 的 daemon 会发 2031-subscribe没有对应的退订 fact。于是一个 TUI 在隐藏状态下退出,这个订阅就永远挂着,下次主题切换会把 CSI 997 注进接替它的那个 shell。修法不是过滤那条 subscribe(没用,可见期的订阅是主进程注册的),而是根本不让 pre-v29 的 daemon 进入后台态canDelegateBackgroundToDaemondaemon-pty-adapter.ts:340)——扫描权威留在主进程,而主进程两个 fact 都会发。再加一道保险:即使真收到了,pre-v29 的 subscribe 也在事件路由里被丢弃,而 unsubscribe 永远放行(daemon-pty-adapter.ts:2929-2941)。方向是不对称的:多退一次无害,少退一次有害。

6.4 平台差异都藏在 provider 里

Windows:PowerShell 回退链。 把裸 pwsh.exe 交给 ConPTY,Windows 可能解析到 Microsoft Store 的 App Execution Alias 存根,CreateProcessW 直接 ERROR_ACCESS_DENIEDbuildWindowsPowerShellSpawnAttemptswindows-shell-fallback-chain.ts:55)预先构造一条每一环都是真实绝对路径的链:请求的 PowerShell → 系统内置 Windows PowerShell → cmd.exe,而且每一环重新计算启动参数toAttempt:19),这样回退到 cmd.exe 时那句 chcp 65001 还在。走链的是 spawnDaemonPtyWithWindowsFallbackpty-subprocess.ts:572),它也顺手把 useConptyDll: true 打开(:518)——系统自带的旧 ConPTY 会把全角 TUI 行在滚屏里弄花。

macOS:TCC 归因。 macOS 的隐私权限(TCC)是按进程归因的,daemon 是个脱离登录会话的 detached Node 进程,直接 spawn 出来的 shell 拿不到正确的归因。解法是把 shell 包一层 login(1)wrapShellSpawnForMacosTccAttributionmacos-tcc-login-shell.ts:324):

return { file: '/usr/bin/login', args: ['-flpq', username, '/usr/bin/env', `SHELL=${shellEnvValue}`, file, ...args] }

但 PAM 栈可能拒绝、可能挂起,所以有一次预检runLoginPreflightmacos-tcc-login-shell.ts:64):跑 login -flpq <user> /usr/bin/printf ORCA_LOGIN_PREFLIGHT_OK,只有干净退出看到那个标记串才算 PAM 接受了(login(1) 在 EOF 驱动的失败提示后也可能返回 0)。预检 500ms 超时,且严格区分「确定性判决」(可缓存)和「不确定」(不能粘住)。整条路径 fail-open:预检没过就退回直接 spawn。daemon 启动时会预热这个探针(daemon-entry.ts:132-140),免得第一次开终端时才付这笔钱。

WSL:resolveWslSessionContextpty-subprocess.ts:586)从 cwd / sessionId / shellOverride 解析出 distro,命中就把 shell 强制成 wsl.exe——并且明确忽略老持久化 tab 上残留的 PowerShell override,否则 WSL 重连会掉回 Windows 侧。


7. 收尾:把一棵进程树杀干净

关掉一个跑着 agent 的终端,难点不是杀 shell,是杀 shell 已经 detach 出去的子孙。给 PTY 发 SIGHUP 只覆盖前台进程组。

killWithDescendantSweeppty-descendant-termination.ts:255)分两条平台路径:

POSIX:
① 先快照进程树(ps -axo pid,ppid,pgid,lstart,LANG=C)
↑ 必须在 signal 之前!root 一死,子孙 reparent 到 pid 1,ppid 走法就找不到它们了
② 对快照里每个后代发 SIGTERM(够到 PTY 的 SIGHUP 够不到的 detached pgid)
③ 等 2s 宽限
④ 重新读进程表,只对「身份可确认」的幸存者补 SIGKILL
⑤ killRoot()(无论如何都跑)

Windows:
① 身份探测 verifyWindowsTreeKillTarget(rootPid)
↑ node-pty 的 ConPTY 退出观察器会在 JS 回调之前关掉最后一个句柄,
此刻 PID 可能已被系统回收,JS 侧的 map 却还显示它活着
② 只有返回 'own' 且 ownsRoot() 仍成立 → taskkill /pid <n> /T /F(5s 超时)
③ killRoot()

第 ④ 步的「身份可确认」是这段代码里最精细的判断(hasUnambiguousStartIdentitypty-descendant-termination.ts:316)。pslstart 只有秒级精度,一个在采样那一秒内出生的进程,可能已经被换成了另一个同 PID 同显示时间戳的进程。所以延迟 SIGKILL 要求三条同时成立:

  1. 启动时间早于采样秒的整秒边界(排除同秒 PID 复用);
  2. 重读的 startedAt 与快照逐字相同;
  3. pgid 也相同。

外加一条:进程表里出现重复 PID 行时,身份视为不明,绝不升级(:341-346)。

还有一处沉默的正确性判断(collectDescendantRows:170-174):如果快照里根本找不到 root 那一行,直接返回空后代集。因为 root 已经退出了,真正的子孙已经 reparent 到 pid 1、按 ppid 找不到;此时还指着那个空出来的 PID 的行,全是 PID 复用的巧合——扫它们等于对无关进程开枪。

平台后代发现方式升级为强杀的门槛失败时降级为
POSIXps 全表 + ppid BFS三重身份校验只杀 root
Windows交给 taskkill /TOS 身份探测返回 own只杀 root(detached 子进程可能幸存,代码里诚实标注了)

8. 巧妙之处(可以带走的)

  1. 模型/视图分离,让「丢字节」变成合法策略。 因为 daemon 里有一份权威屏幕模型,下游的 keep-tail 丢弃(daemon-stream-keep-tail-drop.ts:85)和隐藏投递闸门(pty-hidden-delivery-gate.ts:80)才敢真的丢。没有这份模型,任何一次丢弃都是永久的数据损失。

  2. 绝对序号是所有对账的地基。 outputSequence 数的是源头字节(session-output-plane.ts:164,注释明说 "absolute raw count (daemon stream thinning can drop bytes)"),不是投递字节,所以中间任何一段丢弃都不打断它的单调性。渲染端因此能用一张带 seq 的快照,把「快照之后」和「快照之前的积压」精确切开(pty-connection.ts:6002)。

  3. 「宁可陈旧,不可有洞」写进了三处磁盘逻辑。 日志 seq 不连续 → 整个日志判废(terminal-history-log.ts:83);快照落盘失败 → 丢掉已 take 的 records 而不是糊上去(daemon-pty-adapter.ts:2538);全量冷却期内 → 跳过增量追加而不是留个洞(daemon-pty-adapter.ts:2418-2419)。

  4. 小会话旁路,用一个 4KB 的阈值买到交互延迟。 几 KB 的回显、查询回复不可能是洪水,所以让它们无条件绕过 HOLD(daemon-stream-data-batcher.ts:33:176)。比任何优先队列都简单,效果一样。

  5. 抢救被丢弃的查询字节。 丢掉普通输出只是少看几行,丢掉一个 DSR 会把对面 TUI 永久卡住(它在 read() 上阻塞)。所以丢弃路径专门把这类序列挑出来重新插回(daemon-stream-keep-tail-drop.ts:174-186)。丢弃策略必须区分「装饰性字节」和「协议性字节」。

  6. 协议版本号编进 socket 文件名。 一行代码(daemon-spawner.ts:107-109)就把「破坏性协议变更」从一类线上事故变成了一个自然隔离的命名空间。

  7. 文件所有权用 rename 抢占,不用 read-then-unlink claimAndUnlinkOwnedFiledaemon-spawner.ts:239)先原子 rename 抢下那一个目录项再检查内容;不属于自己就用 COPYFILE_EXCL 独占地放回去,EEXIST 恰好证明有人已经装上了替代品。这规避了「读完发现是自己的,删的时候已经是别人的了」。

  8. 能力探测取交集。 Router 的 supportsAgentSessionClaims 要求所有代 daemon 都支持(daemon-pty-router.ts:59),因为路由按 sessionId 走,上层没法保证落在哪一代上。

  9. 不对称的兼容处理。 2031 订阅那一段(§6.3):多退订一次无害,少退订一次有害——所以丢 subscribe、永远放行 unsubscribe。做兼容降级时先问「这个错的两个方向代价一样吗」。

  10. 快照要连解析器状态一起存。 pendingEscapeTailAnsiheadless-emulator.ts:69-70):xterm 的半截转义序列在解析器里而不在 buffer 里,serialize() 看不见它。任何基于「序列化 buffer」的快照方案都会踩这个坑。


9. 边界与局限

  • binary-frame.ts 是未接线的。 那套 5 字节头的帧协议在本 commit 里只有测试引用它;实际 PTY 是在 daemon 进程内 pty.spawn 的(pty-subprocess.ts:654),daemon↔主进程用的是 NDJSON。

  • daemon 不隔离单个 PTY 的原生崩溃面。 所有 PTY 共享一个 daemon 进程。node-pty 的原生异常靠 uncaughtException 里的字符串匹配识别(daemon-entry.ts:152-159),匹配漏了就会带走整个 daemon。代价由第②层的磁盘 checkpoint 兜底。

  • Windows 上抓不到 detached 子进程。 captureDescendantSnapshot 在 win32 上直接返回 nullpty-descendant-termination.ts:271),只能指望 taskkill /T 走 ConPTY 的进程树;身份探测失败时代码明确说明「detached children may survive」(:242)。

  • SSH provider 没有权威 buffer 快照。 canProvideAuthoritativeBufferSnapshotfalsessh-pty-provider.ts:73),所以本章讲的模型驱动恢复对远程终端不适用——远程的等价机制在第 5 章。

  • 降级模式下新终端没有持久化。 pty-spawn-unhealthy 且有活会话时(daemon-init.ts:592-597),新开的终端跑在本地 provider 上,app 一重启就没了,直到用户手动 Restart daemon。这是一个显式的、有 warn 的取舍。

  • keep-tail 丢弃会切在转义序列中间。 注释里明说这是故意的daemon-stream-keep-tail-drop.ts:80-84):接收方把 gap 当作尾部预览的重置点,而后台会话的瞬时事实扫描是 daemon 权威的,所以下游没人跨这个切口做解析。

  • macOS 的 login(1) 预检有保真度上限。 预检跑在管道上,生产 shell 跑在真 PTY 上,一个对 tty 敏感的 PAM 栈可能表现不一致;代码明确标注这条 fail-safe(macos-tcc-login-shell.ts:61-64)。

  • 本章不覆盖的: SSH 传输协议本身(见 05-runtime-rpc-and-remote);从终端字节流里认出 agent 状态、OSC 状态协议与 hook 注入(见 04-agent-layer);sessionId / paneKey / worktreeId 这些标识本身的建模(见 01-domain-model);worktree 清理时怎么安全地收掉它名下的终端(见 02-worktree-lifecycle)。


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

主题文件路径关键符号
daemon 进程入口 / 原生异常抑制src/main/daemon/daemon-entry.tsparseArgsmainuncaughtException handler
daemon 服务器装配src/main/daemon/daemon-main.tsstartDaemonDaemonStartOptions
socket/token/pid 路径与所有权src/main/daemon/daemon-spawner.tsDaemonSpawnergetDaemonSocketPathclaimAndUnlinkOwnedFilerestoreClaimedDaemonArtifact
fork / 复用判决 / 重启七步src/main/daemon/daemon-init.tscreateOutOfProcessLauncherinitDaemonPtyProviderrestartDaemoncreateLegacyDaemonAdaptersWEDGED_DAEMON_GRACE_RETRIES
健康检查四态src/main/daemon/daemon-health.tsDaemonHealthcheckDaemonHealthkillStaleDaemonisDaemonStaleForCurrentBundle
协议版本与能力门槛src/main/daemon/daemon-protocol-version.tsPROTOCOL_VERSIONPREVIOUS_DAEMON_PROTOCOL_VERSIONSsupportsMode2031UnsubscribeFact
主进程 daemon 客户端src/main/daemon/daemon-pty-adapter.tsDaemonPtyAdapterdoSpawnsetPtyBackgroundedcheckpointSessiontakeSnapshotAndCheckpointsetupEventRouting
多代路由src/main/daemon/daemon-pty-router.tsDaemonPtyRouterdiscoverLegacySessionsadapterFor
降级路由src/main/daemon/degraded-daemon-pty-provider.tsDegradedDaemonPtyProviderdiscoverDaemonSessions
双 socket 服务端 / 空闲自退src/main/daemon/daemon-server.tsDaemonServerbeginIdleShutdown
握手报文src/main/daemon/daemon-hello-protocol.tsHelloMessageHelloResponseDaemonEndpointIdentity
NDJSON 编解码src/main/daemon/ndjson.tsencodeNdjsoncreateNdjsonParserNdjsonLineTooLongError
(未接线的)二进制帧src/main/daemon/binary-frame.tsencodeFramecreateFrameParserFrameType
stream 攒批 / 浅 socket 门src/main/daemon/daemon-stream-data-batcher.tsDaemonStreamDataBatcherflusharmHeldQueueRefillSHALLOW_SOCKET_WRITE_GATE_BYTES
后台 keep-tail 丢弃src/main/daemon/daemon-stream-keep-tail-drop.tsdropOldestQueuedForSessionbackgroundSessionKeepTailChars
daemon 会话状态机src/main/daemon/session.tsSessionhandleSubprocessDataemitSubprocessOutputtakePendingOutputPRODUCER_PAUSE_FAILSAFE_MS
会话表src/main/daemon/terminal-host.tsTerminalHostcreateOrAttach
node-pty 真 spawnsrc/main/daemon/pty-subprocess.tscreatePtySubprocessspawnDaemonPtyWithWindowsFallbackrunSinglePtySpawnHealthProbe
无头模拟器 / 快照src/main/daemon/headless-emulator.tsHeadlessEmulatorgetSnapshotwriteSyncpartialEscapeTailAnsi
快照结构src/main/daemon/terminal-snapshot.tsTerminalSnapshot
checkpoint 写src/main/daemon/history-manager.tsHistoryManagerappendIncrementscheckpointsuspendSession
冷恢复读src/main/daemon/history-reader.tsHistoryReaderprobeRestorableHistorydetectColdRestoreState
日志磁盘帧格式src/main/daemon/terminal-history-log.tsencodeLogBatchdecodeTerminalHistoryLogdecodeLogHeader
恢复种子切段/分块src/main/daemon/terminal-history-seed-segments.tsterminal-history-seed-chunks.tsgetRecoveredHistorySeedSegmentsiterateTerminalHistorySeedChunksmeasureTerminalHistorySeed
provider 契约src/main/providers/pty-provider-contract.tsIPtyProviderPtySpawnOptionsPtyProviderBufferSnapshot
provider 事件src/main/providers/pty-provider-events.tsPtyDataEventPtyBackgroundStreamEventPtyTransientFact
本地 providersrc/main/providers/local-pty-provider.tsLocalPtyProviderpauseProducergetAppliedSizekillOrphanedPtys
SSH providersrc/main/providers/ssh-pty-provider.tsSshPtyProvidercanProvideAuthoritativeBufferSnapshot
Windows shell 回退链src/main/providers/windows-shell-fallback-chain.tsbuildWindowsPowerShellSpawnAttemptsWindowsShellSpawnAttempt
macOS TCC 归因src/main/providers/macos-tcc-login-shell.tswrapShellSpawnForMacosTccAttributionprepareMacosTccLoginShellrunLoginPreflight
主进程编排src/main/ipc/pty.tsregisterPtyHandlerssetLocalPtyProviderrebindLocalProviderListenerssyncPtyBackgroundedDelivery
生产者流控src/main/ipc/pty-producer-flow-control.tsPtyProducerFlowControllerPRODUCER_FLOW_HIGH_WATERMARK_CHARS
隐藏投递闸门src/main/ipc/pty-hidden-delivery-gate.tsshouldDropHiddenRendererPtyDatarecordHiddenRendererPtyDataDropunmarkHiddenRendererPty
进程树收尾src/main/pty-descendant-termination.tskillWithDescendantSweepcaptureDescendantSnapshotterminateDescendantSnapshothasUnambiguousStartIdentity
Windows 树杀src/main/windows-process-tree-kill.tsterminateWindowsProcessTree
渲染端滚屏快照文件src/main/terminal-scrollback-snapshots.tsmakeTerminalScrollbackSnapshotRefwriteTerminalScrollbackSnapshotSynctrailingUtf8Bytes
滚屏快照异步迁移src/main/terminal-scrollback-snapshot-async-migration.tsmigrateWorkspaceSessionTerminalScrollbackSnapshotsAsync
shell 历史按 worktree 隔离src/main/terminal-history.tsinjectHistoryEnvresolveShellKindupdateHistFileForFallback
历史目录 GCsrc/main/terminal-history-gc.tsrunHistoryGcscheduleHistoryGcGC_MIN_AGE_MS
渲染端 pane↔PTY 绑定src/renderer/src/components/terminal-pane/pty-connection.tsconnectPanePtysetRestoredSnapshotBaselinegetChunkDataAfterSnapshotrequestHiddenOutputRestoreIfNeeded
渲染端序列化注册表src/renderer/src/components/terminal-pane/pty-buffer-serializer.tsregisterPtySerializerregisterPtyTitleSource
OSC 7 cwd 解析src/renderer/src/components/terminal-pane/parse-osc7.tsparseOsc7
终端面板组件src/renderer/src/components/terminal-pane/TerminalPane.tsxpersistLayoutSnapshothandleRestartCodexPane