跳到主要内容

数据截至 (上游 commit 4ec6bbf5884e)

认得出 agent — 目录、OSC 状态协议、hook 注入与会话考古

30 秒导读: Orca 不实现 agent,它只是在终端里把别人的 agent CLI 启动起来。可它的整个界面(谁在干活、谁卡住等你、这条会话花了多少 token、能不能续上)都依赖一件事——认得出终端里跑的是谁、它现在处于什么状态。本章讲 Orca 怎么在"完全不改造 agent"的前提下拿到这些事实。


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

一句话定义: 本章讲的是 Orca 的 agent 层——一组把"某个终端窗口里正在跑的 CLI 进程"转换成"一行有状态、有身份、可续接、可计量的记录"的机制。

它要解决的问题。 假设你同时开了 8 个 worktree,每个里面跑一个 coding agent。你现在只想知道三件事:

  • 谁还在跑?谁已经跑完了?谁停下来等你点"允许"?
  • 昨天那条被我关掉的会话,还能接着聊吗?
  • 这个月我在 Claude 上烧了多少额度?

难点在于:这 8 个进程是 8 个互不相干的第三方 CLI(Claude Code、Codex、Gemini CLI、Cursor Agent……),它们没有统一的状态 API,甚至连"完成"这个概念的表达方式都不一样。Orca 不能改它们的源码。

它能做什么。

  • 维护一份 agent 目录:35 个已知 CLI 的启动命令、探测命令、进程名、prompt 注入方式。
  • 从进程名/命令行 反推这个终端里跑的是哪个 agent。
  • 用三条独立通道收集运行时状态:working / blocked / waiting / done
  • 把 agent 自己写在磁盘上的会话文件 离线解析出来,做历史归档和续接。
  • 记录每家的账号与额度。

一句话直觉。 把 agent 想成一台没有仪表盘的机器。Orca 做的事是在外面贴三种传感器:听它说话(终端里的转义序列)、让它按铃(hook 回调)、翻它的日记(落盘的会话文件)。三种都不完整,合起来才够用。

本章不讲多 agent 之间怎么互相派活——那是第 6 章。终端和 PTY 本身怎么活着,见第 3 章


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

怎么读这张图: 左列是"谁在说话"(都是 agent 自己产生的信息,Orca 只是接收方),中列是传输通道,右列是接收方。注意 ③ 不汇入状态表——它是一条独立的只读考古路径。

谁在说话 怎么传 谁在听
┌────────────────┐ 转义序列 ┌─────────────┐
│ ① agent 的 │───────────>│ PTY 字节流 │──────────┐
│ 终端输出 │ OSC 9999 └─────────────┘ │
└────────────────┘ v
┌──────────────────┐
┌────────────────┐ HTTP POST ┌─────────────┐ │ AgentHookServer │
│ ② agent 的 │───────────>│ 本机回环 │──>│ 主进程唯一的 │
│ hook / 插件 │ /hook/<源> │ 127.0.0.1 │ │ 实时状态表 │
└────────────────┘ └─────────────┘ └────────┬─────────┘
│ IPC
┌────────────────┐ 读文件 ┌─────────────┐ v
│ ③ agent 落盘的 │───────────>│ AI Vault │ 渲染进程侧边栏 / 手机 App
│ 会话 transcript│ JSONL/DB │ 扫描器 │ + last-status.json 落盘
└────────────────┘ └─────────────┘

部件一句话职责:

部件干什么在哪个文件
agent 目录35 个 CLI 的启动/探测/注入配置src/shared/tui-agent-config.ts
进程识别从进程名或命令行反推 agent idsrc/shared/agent-process-recognition.ts
OSC 9999 解析器从 PTY 字节流里剥出状态 JSONsrc/shared/agent-status-osc.ts
hook HTTP 服务收 agent hook 的 POST,做归属与合并src/main/agent-hooks/server.ts
hook 归一化器把 17 种厂商事件格式压成一个 payloadsrc/shared/agent-hook-listener.ts
hook 安装器往用户家目录写托管脚本 + 改配置src/main/agent-hooks/installer-utils.ts
AI Vault 扫描器解析各家落盘的 transcriptsrc/main/ai-vault/session-scanner-*.ts
续接由 provider session id 拼出 resume 命令src/shared/agent-session-resume.ts

主线走一遍(高层):

  1. 用户在某个 worktree 的 tab 里选 claude,Orca 查目录拿到启动命令。
  2. spawn PTY 时把 身份环境变量 注进去:ORCA_PANE_KEYORCA_TAB_IDORCA_AGENT_HOOK_PORTORCA_AGENT_HOOK_TOKEN……
  3. Claude 跑起来,每次提交 prompt / 调工具 / 结束回合,都会执行 Orca 事先装进 ~/.claude/settings.json 的托管脚本。
  4. 脚本用 curl 把事件 POST 到 127.0.0.1:<随机端口>/hook/claude,带上 pane key。
  5. 主进程归一化、归属到 pane、合并进状态表,再 IPC 推给渲染进程画那一行。
  6. 与此同时 Claude 自己把 transcript 写进 ~/.claude/projects/…,AI Vault 随时可以离线扫回来。

3. 第一层:认得出这是哪个 agent

在谈状态之前先要谈身份。一个 pane 里跑的到底是 claude 还是 bash,决定了后面所有逻辑。

3.1 三张表,三种用途

Orca 里"agent 是谁"其实有三套并存的标识,各有各的理由:

类型成员数干什么用在哪
TuiAgent字符串联合36用户能启动的 agent 全集,用于选择器和默认 agent 设置src/shared/tui-agent.ts:3
TUI_AGENT_CONFIGRecord<TuiAgent, …>36每个 agent 的探测命令、启动命令、期望进程名、prompt 注入方式src/shared/tui-agent-config.ts:53
AgentKind遥测枚举36 + other遥测事件里的封闭枚举,看板按这个分组枚举 src/shared/telemetry-events.ts:66AGENT_KIND_VALUES),映射 src/shared/agent-kind.ts:16

为什么不合成一张。 因为它们的生命周期不同:TuiAgent 是代码里的类型,AgentKind 是数据管道里的契约(改了会打断历史数据),而 TUI_AGENT_CONFIG 会随上游 CLI 改名而变。

两表之间的桥接靠 TUI_AGENT_KIND_BY_AGENT,它用 satisfies Record<TuiAgent, ConcreteAgentKind> 做编译期穷尽检查(src/shared/agent-kind.ts:53)——少写一个成员就编译不过。但运行时仍然兜底:

// src/shared/agent-kind.ts:58 tuiAgentToAgentKind
export function tuiAgentToAgentKind(agent: TuiAgent): AgentKind {
return TUI_AGENT_KIND_BY_AGENT[agent] ?? 'other'
}

这一行的注释点破了理由:过期的持久化设置或不安全的 IPC cast 可能带进一个不在联合里的字符串,宁可退化成 'other' 也不要让遥测事件校验失败被静默丢弃。

3.2 目录条目长什么样

TUI_AGENT_CONFIG 的每条记录都是一堆"踩过的坑"的沉淀。看一个真实条目(逐字,只去掉外层缩进):

// src/shared/tui-agent-config.ts:201 TUI_AGENT_CONFIG.kiro
kiro: {
// Why: the Kiro installer (https://cli.kiro.dev/install) ships `kiro-cli`, not `kiro`; keep id 'kiro' for stored prefs.
detectCmd: 'kiro-cli',
// Why: trust flags like --trust-all-tools attach to Kiro's `chat` subcommand, not top-level kiro-cli.
launchCmd: 'kiro-cli chat --tui',
expectedProcess: 'kiro-cli',
promptInjectionMode: 'stdin-after-start'
},

三个字段各管一件事,很容易混:

字段含义典型不一致的例子
detectCmd在 PATH 上找哪个可执行文件来判断"装了没"continue 的二进制叫 cncontinue 是 shell 内建词)
launchCmd实际起 TUI 的命令行hermes --tui(裸 hermes 只开老 REPL)
expectedProcess起来之后进程名应该长啥样aug 的进程名其实是 auggie

promptInjectionMode 是另一条独立轴:把用户的初始任务文本送进 agent 有 6 种做法(argvflag-promptflag-prompt-interactiveflag-interactivehermes-querystdin-after-start,见 src/shared/tui-agent-config.ts:4)。选错就会出事——注释里写得很直白:copilot--prompt 跑完就退出,会直接杀掉托管会话,所以必须用 -i/--interactive:289)。

还有些字段纯粹是为了对付 TUI 的启动竞态:

  • draftPromptFlag: '--prefill' —— Claude 有原生的"填入但不提交"标志,优先用它,避免"等 composer 就绪再粘贴"的竞态。
  • preflightTrust: 'cursor' —— Cursor 首次启动的"信任此文件夹?"菜单会吞掉 bracketed paste,所以先把信任标记文件写好。
  • argvPromptSeparator: '--' —— Grok/Trae 用 Cobra 解析器,prompt 以 help 开头会被当成子命令。

3.3 反过来:从进程名推 agent

目录解决的是"我要启动谁"。运行中还有反向问题:这个 PTY 的前台进程是不是一个 agent?

agent-process-recognition.ts 在模块加载时把目录压成一张倒排表(外层循环从 :63 起遍历目录):

// src/shared/agent-process-recognition.ts:68 PROCESS_TO_AGENT 构建循环(内层)
for (const candidate of [
config.expectedProcess,
...getTuiAgentDetectCommands(config),
getFirstCommandToken(config.launchCmd)
]) {
const normalized = normalizeProcessName(candidate)
if (normalized) {
// Why: claude-agent-teams is an Orca wrapper whose child process is the
// real `claude` binary. Do not let wrapper configs overwrite canonical
// CLI ownership for the same foreground process name.
if (!PROCESS_TO_AGENT.has(normalized)) {
PROCESS_TO_AGENT.set(normalized, agent)
}
}
}

那条 !has 的理由就写在它上面:claude-agent-teams 是 Orca 自己的包装模式,它的子进程就是真正的 claude,不能让包装配置抢走 claude 这个进程名的归属。

难的是解释器包装。 很多 agent 是 npm 包,node-pty 看到的前台进程就是 noderecognizeAgentProcessFromCommandLine:285)因此要走命令行 token:

命令行 ──> tokenize(处理引号/转义)

├─ tokens[0] 直接命中 PROCESS_TO_AGENT ? ──> 命中即返回

└─ tokens[0] 是解释器(node/python/sh…)?

└─ 找入口脚本 token ──┬─ 路径匹配已知包 ──> 认出
├─ python -m <模块> ──> 认出
└─ 都不匹配 ──> null

这里有一条明确写下来的防御纪律(:154-158):只检查解释器的脚本路径 token,不扫描全部 argv。因为 prompt 文本里完全可能出现 "compare opencode vs orca",把每个 argv token 都当可执行名会重新引入"子串式误判"这一类 bug。

同理,被认作"入口脚本"的 token 必须带路径分隔符或可执行扩展名(tokenLooksExecutable:148)。对 Cursor 和 Pi 这种入口是通用 index.js / cli.js 的,只能靠完整安装路径正则认(CURSOR_AGENT_NODE_ENTRYPOINT_REPI_AGENT_NODE_ENTRYPOINT_RE:55-57)。

3.4 shell 是负信号

反过来还有一个同样重要的判断:isShellProcesssrc/shared/shell-process-detection.ts:9)。它的文件头注释一句话说清了用途——

裸 shell 是"有没有 agent 在跑"的负信号,因为它会把注入的前导文本搞乱。

这个判断被三处共用:运行时的派发守卫、tui-idle 兜底、渲染进程的"等 agent 就绪"。Windows 上 node-pty 会把 Git Bash 报成 bash.exe,所以要同时匹配原名、basename、去扩展名三种形态。


4. 状态来源一:终端流里的转义序列(OSC 9999)

4.1 它要解决的小问题

有些 agent(或者 Orca 自己造的合成帧)没法回调 HTTP,但它总能往自己的 stdout 写字节。既然 Orca 已经在读这条 PTY 流,那就在流里开一条带内旁路

思路: 借用 OSC(Operating System Command)转义序列。终端本来就用 ESC ] <数字> ; <参数> BEL 这种格式传"设置窗口标题"之类的带外指令。Orca 挑了一个没人用的编号 9999,参数位放一段 JSON。

ESC ] 9 9 9 9 ; {"state":"working","prompt":"重构登录"} BEL
└──── 前缀 ────┘ └──────────── 载荷(JSON) ────────────┘ └终止符┘
也可以是 ESC \ (ST)

4.2 难点:流是被切碎的

PTY 数据是任意切分的 chunk。一个 OSC 9999 序列可能被切成三段送来,甚至前缀 \x1b]999 落在 chunk 尾巴上。解析器必须是有状态的。

createAgentStatusOscProcessorsrc/shared/agent-status-osc.ts:35)返回一个闭包,里面只留一个 pending 字符串。每次调用返回两样东西:

export type ProcessedAgentStatusChunk = {
cleanData: string // 剥掉序列后的字节,交给 xterm
payloads: ParsedAgentStatusPayload[] // 解析出来的状态
}

关键在于"部分前缀"的处理。 当 chunk 里找不到完整前缀时,它会从尾巴倒着试探最长的部分前缀:

// src/shared/agent-status-osc.ts:52
for (let k = Math.min(prefixLen - 1, tail.length); k > 0; k--) {
if (tail.endsWith(OSC_AGENT_STATUS_PREFIX.slice(0, k))) {
partialPrefixLen = k
break
}
}

命中就把那几个字节留进 pending,其余交给 xterm。不这么做的后果:要么状态丢失,要么半截转义序列被打印成乱码到用户屏幕上。

另一处防线是 MAX_PENDING = 64 * 1024:如果一个序列开了头却迟迟没有终止符,攒到 64KB 就整个丢弃(:73),避免恶意或坏掉的 agent 把内存吃干。

4.3 载荷归一化:不信任任何字段

解析出来的 JSON 要过 parseAgentStatusPayloadsrc/shared/agent-status-types.ts:446)。这个函数是所有状态来源共用的净化闸门——OSC 走它,hook 也走它。

四道关:

  1. 结构限额assertJsonTextStructureWithinLimits 先扫文本,结构 token 上限 4096、嵌套深度上限 16(AGENT_STATUS_JSON_STRUCTURE_LIMITS:247)。这是在 JSON.parse 之前做的,防解析炸弹。
  2. 状态白名单state 必须是 working | blocked | waiting | done 之一,否则整条返回 null
  3. 逐字段截断toolName 60 字符、toolInput 160、lastAssistantMessage 8000、interactivePrompt 16000。每个上限都带注释解释为什么是这个数——比如 interactivePrompt 装的是完整的 AskUserQuestion JSON,像 toolInput 那样截成预览会破坏 JSON 并丢掉选项:221)。
  4. 语义清洗interrupted 只在 done 状态下有意义,其他状态一律强制 undefined:366),免得旧值跨状态泄漏。

agentType 也被当单行字段归一化,注释说得很具体:不这么做的话 agentType: "claude\nrogue" 这种嵌了换行的值会撑坏单行 UI 和相等性比较(:352)。

4.4 边界:OSC 说不了的事

有一处设计决策值得单独拎出来。ingestTerminalStatussrc/main/agent-hooks/server.ts:2072)在收到 OSC 状态时必须保留之前缓存的 providerSession

// src/main/agent-hooks/server.ts:1750 附近的注释
// Why: the OSC 9999 wire payload has no providerSession field at all, so an OSC
// observation is never evidence that the session ended

翻译过来:OSC 载荷里根本没有会话 id 这个字段,所以"OSC 没说"不等于"会话结束了"。 早期版本直接覆盖行,结果把缓存的会话身份抹掉了——持久化的行重启后丢失,无头模式 orca serve 直接给手机端喂了空白的 Chat UI(issue #10630)。

同一段还处理了另一个坑:OSC ping 如果没有点名 agentType(或者写了字面量 'unknown'),不算身份声明,不能被当成"身份不匹配"而去剥掉会话。

4.5 两个姊妹协议

流里还有另外两类序列被解析,但它们不是状态来源

序列用途实现
OSC 0/1/2窗口标题。用于 tab 标题显示、agent 名字识别src/shared/osc-title-extraction.ts:74 extractLastOscTitle
OSC 133;C / 133;Dshell 集成:命令开始 / 命令结束(带退出码)src/shared/terminal-osc133-command-finished.ts:58 createOsc133CommandFinishedScanner

标题为什么不算状态来源?agent-status-types.ts 开头三行写死了这条纪律:

status comes from hooks (Claude, Codex, etc.) — never inferred from terminal titles

标题只用来做辅助身份判断退出检测(标题从 ✳ Claude 变回 bash,说明 agent 退了)。原因不难理解:标题是自由文本,~/codex/ready 这种路径会误命中 ready,所以 STRONG_IDLE_KEYWORDS_RE 得用后行否定断言排掉路径分隔符和连字符(src/shared/agent-title-core.ts:29)。用它当状态源必然假阳。

OSC 133;D 则给出了一条真正可靠的信号:shell 打出提示符了 = 前台命令确实结束了。前台进程追踪器明确说明:这里读到 shell 不是提示符的证据,只有 133;D 才是(src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts:162)。


5. 状态来源二:hook 回调本机服务(带外通道)

这是三条来源里信息最丰富的一条,也是工程量最大的一条。

5.1 全景

安装期(应用启动时跑一次)
Orca ──写──> ~/.orca/agent-hooks/claude-hook.sh (托管脚本)
──改──> ~/.claude/settings.json 的 hooks 段 (挂上去)

运行期(每个回合触发多次)
Claude 触发 UserPromptSubmit
└─> 执行 claude-hook.sh,把事件 JSON 灌进它的 stdin
└─> 脚本 source endpoint 文件拿端口和 token
└─> curl POST 127.0.0.1:<port>/hook/claude
└─> AgentHookServer 校验 token → 归一化 → 合并

5.2 注入有三种形态

不是所有 agent 都长一样,Orca 对应了三种注入手法:

形态适用做什么代表实现
配置 + 托管脚本Claude 家族、Codex、Gemini、Droid、Grok、Copilot…写一个 .sh/.cmd~/.orca/agent-hooks/,再把它注册进 agent 的 settings.jsonsrc/main/claude/hook-service.ts:204 install
插件文件 + 配置目录 overlayOpenCode、mimo-code生成一份内联 JS 插件源码,镜像用户配置目录后把插件塞进 plugins/src/main/opencode/hook-service.ts:38 getOpenCodePluginSource
无注入Pi、OMPagent 自带扩展,Orca 只提供端点和环境变量路由见 HOOK_SOURCE_BY_PATHNAME

注册表在 src/main/agent-hooks/managed-agent-hook-registry.ts:23,14 个托管安装器一字排开;HTTP 路由认 17 个来源(src/shared/agent-hook-listener.ts:4656)——差额正是插件式和自带扩展的那几家。

5.3 托管脚本长什么样(真源码)

这段是理解整条链路的钥匙。Claude 的 POSIX 脚本由 getManagedScriptsrc/main/claude/hook-service.ts:60)拼出来,核心几行:

#!/bin/sh
payload=$({ command -p cat 2>/dev/null || cat; }) # ① 先吃干净 stdin
if [ -z "$payload" ]; then exit 0; fi
if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then
. "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || : # ② 刷新端口/token
fi
if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_PANE_KEY" ]; then exit 0; fi
printf '%s' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/claude" \
--connect-timeout 0.5 --max-time 1.5 \
-H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \
--data-urlencode "paneKey=${ORCA_PANE_KEY}" \
--data-urlencode "payload@-" >/dev/null 2>&1 || true # ③ 失败也返回 0

三个编号处各对应一条硬教训:

① stdin 必须先被吃掉。 POSIX_HOOK_STDIN_READERsrc/main/agent-hooks/hook-stdin-contract.ts:7)用的是 command -p cat,注释解释了三重理由:PATH 被清空时不至于 exit 127 让 agent 写到一半断管(issue #8110);command -p 用 shell 内建默认 PATH,在没有 /bin/cat 的 NixOS 上也活;而且它忽略 worktree 里的同名 cat——不然一个恶意仓库放个 cat 就能截获 hook 载荷。

同样的偏执贯穿全部包装:Windows 版把 curl.exepowershell.exe 都写成 %SystemRoot%\System32\ 全路径,因为 Windows 会先在工作目录找可执行文件(src/main/agent-hooks/installer-utils.ts:148:166)。

② endpoint 文件让老 PTY 活过 Orca 重启。 端口和 token 是每次 start() 重新生成的(见 5.4)。可 PTY 是守护进程托管的,能跨 Orca 重启存活——它的环境变量还停在旧值上。所以脚本每次执行都去 source 一个文件,拿最新坐标。

③ 永不失败。 || true + >/dev/null。hook 是尽力而为的旁路,绝不能因为 Orca 没开着就让 agent 报错。wrapPosixHookCommand(installer-utils.ts:108 转出自 posix-hook-command.ts:8)连"脚本文件不存在"都兜住了:包一层 if [ -f … ] && [ -r … ] && [ -x … ],否则退化成排空 stdin 的 no-op,而不是每次工具调用都 exit 127。

5.4 服务端:随机端口 + 随机 token + 0600 文件

AgentHookServer.start()src/main/agent-hooks/server.ts:2397)的安全姿态很紧:

措施代码挡什么
listen(0, '127.0.0.1'):2059只绑回环,端口由内核随机分配
token = randomUUID() 每次启动重置:1966本机其他进程猜不到;旧 token 自动失效
非 POST 一律 404,token 不符 403:1974-1985拒绝浏览器 GET 和 CSRF
req.setTimeout(5s):1987slowloris
请求体 1MB 上限agent-hook-listener.ts:81内存
解析失败仍回 204:2030fail open:坏 hook 绝不阻塞 agent

端点文件的写法同样谨慎(writeEndpointFileagent-hook-listener.ts:4703):目录 0700、文件 0600、临时文件 + rename 原子替换,而且每个值都先过 isShellSafeEndpointValue 正则——因为这文件是要被 . "$file" 直接 source 的,一个带引号或分号的值就是注入。校验不过就整个放弃写文件,退回纯环境变量路径。

stop() 里有一条反直觉的注释(:2093):故意不删端点文件。留个陈旧文件符合 fail-open 姿态,也避免和另一个并发 Orca 实例产生 TOCTOU 竞态。

5.5 归属:paneKey 是整个体系的主键

hook 从一个全局配置触发,它怎么知道自己属于哪个终端窗格?答案是 spawn PTY 时注入的环境变量。

// src/renderer/src/components/terminal-pane/pty-connection.ts:3317
const paneIdentityEnv = {
...workspaceEnv,
ORCA_PANE_KEY: cacheKey,
ORCA_TAB_ID: deps.tabId,
ORCA_WORKTREE_ID: deps.worktreeId,
...(launchToken ? { ORCA_AGENT_LAUNCH_TOKEN: launchToken } : {})
}

paneKey 的形状是 ${tabId}:${leafId}leafId 必须是 UUID(makePaneKeysrc/shared/stable-pane-id.ts:22;这个身份本身的建模见 01-domain-model.md §6.3)。对本章而言只需要记住它的一条性质:paneKey 要穿过渲染进程重载、PTY 环境变量、hook IPC 三道边界,所以它必须是持久 id 而不是渲染进程本地的自增数字。

继承问题。 子 agent 进程会继承父终端的 ORCA_PANE_KEY。于是 resolveAgentStatusIdentitysrc/shared/agent-status-identity.ts:44)要做仲裁:

收到 incoming.agentType

├─ 没有 / 是 'unknown' ──> 沿用已有身份,不作任何声明
├─ 和已有身份相同 ──> 直接接受
└─ 和已有身份不同

├─ 已有身份仍活跃(非 done 且 30 分钟内)
│ └──> 保留父身份,标记 inheritedFromActivePane
└─ 已有身份已过期 ──> 接受新身份

配套还有 shouldSuppressInheritedTerminalStatus:19):继承来的 done 一律丢弃——子进程跑完不能证明父回合跑完了。

5.6 归一化:17 种事件格式压成一个 payload

agent-hook-listener.ts 有 4264 行,它就干一件事:把各家五花八门的 hook 事件翻译成同一个 ParsedAgentStatusPayload。每家一个 normalize<Agent>Event 函数(Claude、Codex、Gemini、Antigravity、Amp、OpenCode、Cursor、Copilot、Pi、Droid、Command Code、Grok、Hermes、Devin、Kimi),最后由 normalizeHookPayload:3937)统一出口。

以 Claude 为例(normalizeClaudeEvent:2614),事件名到状态的映射是这样的:

Claude 事件映射到为什么
UserPromptSubmit / PreToolUse / PostToolUse / PostToolUseFailureworking回合进行中
PermissionRequestwaiting卡在人类批准上
PreToolUse 且工具是 AskUserQuestionwaitingClaude 的自动放行问句只发 PreToolUse,不发 PermissionRequest
Stop / StopFailuredone回合边界
SubagentStart / SubagentStop / TeammateIdle走子 agent 花名册不改变 pane 自身状态

注册这些事件的表在 src/main/claude/hook-settings.ts:36 CLAUDE_EVENTS。里面每条都带注释解释存在理由,比如 StopFailure——OpenClaude 在 API/模型报错后会跳过正常的 Stop 改发 StopFailure,不监听它的话 Orca 会让这一行永远转圈。

中断标志只在回合边界可信:2632):只有 Stop/StopFailure 才准声明 is_interrupt,其他任何事件都开启新回合并把它清掉。

5.7 合并:状态表不是简单覆盖

applyNormalizedStatussrc/main/agent-hooks/server.ts:1316)是主进程唯一改状态表的地方,它是一串守卫的叠加:

新事件到达

├─ providerSessionOnly? ──> 只更新会话身份,不动状态,不发遥测
├─ 远端 Codex? ──────────> 先跑 reconcileRemoteCodexState 补状态机
├─ 补回根级字段 ─────────> SSH relay 重启会忘掉 providerSession/model,
│ 子 hook 不能把它们抹成空
├─ 身份仲裁 ─────────────> resolveAgentStatusIdentity(见 5.5)
├─ 继承来的 done? ───────> 丢弃
├─ Claude 权限窗还开着? ─> 保持可见
├─ 刚被中断过(15s 内)? ─> 迟到的 working/done 不许复活这一行
└─ 全过 ─> 写表 → 排队落盘 → 通知订阅者 → IPC 推送

"刚被中断过"那条(INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS = 15_000:152)挡的是一个很具体的现象:某些 TUI 在你按 Ctrl+C 之后还会延迟吐一个 working 事件,不拦住的话侧边栏里那一行会自己活过来。

时间戳也做了区分:updatedAt(每次事件都刷新)和 stateStartedAt(只在状态改变时重置)。attachStatusTiming:963)负责这个——不分开的话,工具调用的高频 ping 会不停重置"已经 working 了多久"这个计时。

5.8 缺 hook 时的兜底推断

有些 agent 被 Ctrl+C 之后根本不发结束 hook。Orca 于是提供一条推断路径 inferInterruptserver.ts:901),但把它包得极紧:

先按 agent 排除:

agent规则理由
DroidCtrl+C 不算中断它的 Ctrl+C 是退出 CLI,由 PTY 生命周期处理
OpenCode / Copilot单次 Esc 不算首个 Esc 是 TUI 取消,回合可能还在跑;要双击
Claude(等在 AskUserQuestion)改走 inferQuestionAnsweredEsc 是关掉问句,不是中断回合

再按状态基线严格比对:753):当前必须是 working,且 agentType、prompt、updatedAtstateStartedAt 全部与请求携带的基线一致。任何一项对不上就放弃——这保证一个延迟触发的定时器不会盖掉更新的真实 hook。

最后两条否决权:

  • 花名册里还有非 idle 的子 agent → 不推断(Ctrl+C 停不掉后台子进程,推断成 done 会误杀活着的子行)。
  • Claude 还有运行中的非 agent 任务或 session cron → 不推断。

推断成功时还要顺手 markClaudeLeadTurnInterrupted:777),否则之后一个子事件会拿旧的 working 重新广播,把已取消的 pane 又救活。

这一整节是本章最值得学的部分:兜底推断很容易写,写得"只在确实安全时才开火"很难。

5.9 跨主机:同一条管道,两种搬运

Orca 支持 SSH 远程和 WSL。远端 agent 也要能回调,但它连不到宿主机的 127.0.0.1

解法是在远端跑一个 relay:relay 自己起一份 同样的 agent-hook-listener 监听(这就是为什么这个模块住在 shared/ 且只用 Node 内建 API,不碰 Electron),解析后包成 JSON-RPC 通知 agent.hook 发回宿主机(AGENT_HOOK_NOTIFICATION_METHODsrc/shared/agent-hook-relay.ts:120)。

信封的设计有两条纪律写在文件头(:11-23):

  • relay 负责归一化,Orca 负责路由。ingestRemoteserver.ts:2167)在 SSH 信任边界上再跑一遍规范化器,relay 版本漂移或远端进程有 bug 都不能污染主进程状态。
  • 线上的 connectionId 永远是 null connectionId 是 Orca 本地对某个 ssh2 连接的句柄,不是线路身份;真值由 ingestRemote 从 mux 身份盖上去。

WSL 侧由 WslHookRelayManagersrc/main/agent-hooks/wsl-hook-relay-manager.ts:50)管:每个发行版一个 relay,从每次 WSL PTY spawn 时确保存在,带失败冷却退避和稳定运行时长判定。

还有一个跨实例问题:多个 Orca(生产版 + dev 版 + 多个 relay 守护进程)可能同时去改同一份 ~/.claude/settings.jsonwithManagedHookInstallLocksrc/main/agent-hooks/managed-hook-install-lock.ts:130)用 ~/.orca/managed-hook-install.lock 序列化,并且验证持有者进程是否还活着(比对 pid + 进程身份),同时拒绝跨主机偷锁——家目录可能被多台 SSH 主机共享,它们的 PID 命名空间毫无关系(:81)。

配置文件本身的写入也是防御式的(writeHooksJsoninstaller-utils.ts:300):内容相同就完全跳过写入,否则临时文件 + 单份滚动备份 .bak + rename。跳过相同内容那一步有明确理由(:325)——不然每次 install() 都会滚动备份一次,反复调用会把最后一份可恢复的副本冲掉。

5.10 落盘:last-status.json

状态表会防抖 250ms 持久化到 <userData>/agent-hooks/last-status.jsonSTATUS_PERSIST_DEBOUNCE_MSserver.ts:200)。start()先 hydrate 再绑监听:1968),保证一个很早到达的 hook POST 是跑在已填充的表上。

hydrateLastStatusFromDisk:2430)的谨慎程度值得一提:版本号不匹配整个忽略、非法 JSON 整个忽略、超过 7 天的条目丢弃(HYDRATE_MAX_AGE_MS)、旧版明文 launchToken 被擦掉换成哈希。stop() 则会先 flushStatusPersistSync()——不这么做的话,退出前 250ms 内的最后一次 hook 就丢了。


6. 状态来源三:翻它的日记(离线会话考古)

前两条来源都要求 Orca 当时开着。第三条不需要。

6.1 各家把会话写在哪

这张表是 AI Vault 的地基(src/main/ai-vault/session-scanner-source-discovery.ts:12):

agent会话目录格式
Claude~/.claude/projects/JSONL
Codex$CODEX_HOME(默认 ~/.codex/sessions/JSONL
Gemini~/.gemini/tmp/JSON
Antigravity~/.gemini/antigravity-cli/brain/JSONL
Copilot$COPILOT_HOME/session-state/JSON
Cursor~/.cursor/projects/JSON
Hermes~/.hermes/sessions/JSON
Rovo~/.rovodev/sessions/JSON
Pi / OMP~/.pi/agent/sessions/~/.omp/agent/sessions/JSON
Droid~/.factory/sessions/~/.factory/projects/JSON
Devin<DEVIN_HOME>/transcripts/ATIF
OpenCode旧版按文件;1.17.x 起 SQLite两套并行

WSL 发行版的家目录也被并进扫描根(claudeProjectsRootDirs:42)。Codex 还要额外扫 Orca 自建的托管 CODEX_HOME:66),因为 Orca 启动的 WSL Codex 会话不写用户默认的 ~/.codex

6.2 解析器长什么样

所有解析器共享一个"逐行喂 + 累加器 + finalize"的形状。Codex 版(parseCodexSessionFilesrc/main/ai-vault/session-scanner-codex-parser.ts:38)走一遍 JSONL,按记录类型分派:

每行 JSON
├─ type=session_meta ──> 抓 sessionId / title / cwd / git 分支
├─ type=turn_context ──> 抓 cwd / model
├─ type=response_item ──> 计消息数、抓首个用户消息当标题
└─ type=event_msg
├─ user_message ──> 记 lastUserPrompt
├─ agent_message ──> 进预览
└─ token_count ──> 累加 token(用 total 的差分,缺则用 last)

两处巧思:

① 排掉 worker 会话。 Codex 把内部子 agent 的 transcript 写进同一棵历史树。isCodexWorkerSession:316)看 thread_source 字段,不是 user 就整个文件放弃并提前停止读取:299),不浪费 IO。

② 标题是懒命名的。 Codex 在 session_index.jsonl 里事后给线程起名。所以 finalizeCodexParseState:230)每次 finalize 都要查一次索引(索引读取本身按签名缓存),因为一个在首次解析之后才出现的标题,仍然应该替换掉那条原始 prompt(:246)。

Antigravity 的解析器(:74)则要从 <USER_REQUEST>…</USER_REQUEST> 标记里抠出真正的用户输入,而且明确放弃了 cwd/model——注释说 transcript 里根本没这些字段,工作区信息只能靠一次保守的历史 join,protobuf/SQLite blob 不稳定不碰(:53)。

6.3 子 agent 转录:按需才读

Claude 的 Task 子 agent 各自写一份 transcript 到 subagents/ 子目录。主扫描为了速度直接剪掉这棵子树,只有用户展开某条会话的详情时才走 listClaudeSubagentSessionssrc/main/ai-vault/session-scanner-claude-subagents.ts:72)。

这里有一处很典型的性能考量(:33-39):判断子 agent 状态要在父 transcript 里找 toolUseResult 记录,但 Read/Bash 的工具输出toolUseResult,而且是文件里最大的那些行。所以加了第二个标记 "agentId" 做门禁,避免每次按需读取都把整个文件 JSON.parse 一遍。

还有个"多久没写字算不跑了"的启发式:SUBAGENT_RUNNING_RECENCY_MS = 5 分钟:28)——超过这个时间没有输出又没有终止通知,状态记为未知而不是保持陈旧的 running。

6.4 两遍读:预览 vs 全文

列表扫描出于内存考虑会截断首个用户 prompt。要拿完整的原文(用户想复制或复用),走一条二次解析路径 readAiVaultFirstUserPromptsrc/main/ai-vault/session-first-user-prompt-read.ts:28),用 withFullFirstUserPromptCapture 在异步上下文里打开"全量捕获"开关重新解析同一个文件。

这条路径还明确划了边界:远端主机的行直接返回 null(transcript 正文在会话主机上),UI 退回预览文本。

6.5 远程主机:同一份 schema,两个方向

远端 runtime 的扫描走 RPC(scanRuntimeAiVaultSessionssrc/main/ai-vault/runtime-session-scanner.ts:47),返回值用 zod schema 严格校验。两个细节:

  • 老版本兼容靠 .default() 而非 .optional()queuedMessageCountsubagentTranscriptCountsubagent 都给了默认值,跑旧构建的远端主机仍然可解析(:83-95)。
  • 不信任对面回报的主机 idretagRuntimeSession:202)强行用本地已知的 executionHostId 重新打标——"配对的 server 只是传输层,哪个具体 runtime 主机被扫描了由父进程说了算"。

7. 续接:把睡着的会话叫醒

有了会话身份,就能续接。这一层由三个部分组成。

7.1 会话身份从哪来

extractAgentProviderSessionsrc/shared/agent-session-resume.ts:182)在 hook 载荷里找会话 id。各家键名完全不一样:

来源载荷里的键归一化成
claude / codex / gemini / droid / kimi / devin / ompsession_id{ key: 'session_id', id }
antigravityconversationId{ key: 'conversation_id', id }
opencode / mimo-codesessionID{ key: 'session_id', id }
groksessionIdsession_id{ key: 'session_id', id }
amp / cursor / command-code / copilot / hermes——null(不支持续接)

Claude 和 Codex 还额外抓 transcript_path。注释说明了原因(:26-32):新版 Claude Code 用一个和 hook session_id 不同的 UUID 给 transcript 文件命名,所以从 id 反推路径已经失效,必须存下 hook 报告的权威路径。Pi 更极端——它的 session_file 就是 resume 定位符本身,没有它这条记录直接作废(:206-212)。

id 本身要过 normalizeSessionId:81):非空、≤512 字符、不能以 - 开头(否则拼进 argv 会被当成标志)、不含控制字符。

7.2 拼出 resume 命令

11 个可续接 agent(RESUMABLE_TUI_AGENTS:5),每家的语法各不相同:

agentresume argv
claudeclaude --resume <id>
codexcodex resume <id>
gemini / droid / grok / devin<cmd> --resume <id>
antigravityagy --conversation <id>
opencode / mimo-code<cmd> --session <id>
pipi --session <transcriptPath>
ompomp --resume <文件路径 或 id>

getAgentResumeArgv:235)生成,且每条都要求 providerSession.key 匹配——一个 conversation_id 不能拿去当 session_id 用。

7.3 反向操作:把 resume 参数摘掉

有个不显然但重要的对偶操作。如果 Orca 无法确认哪个账号拥有这条会话,用当前选中的账号去 resume 是错的。这时它需要把已经拼好的 resume 参数从命令行里摘掉,退回一次干净的启动。

dropAgentResumeArgvFromCommandsrc/shared/agent-resume-argv-drop.ts:44)返回三态:

返回含义调用方该做什么
dropped摘干净了,给出纯启动命令用它启动
absent命令里本来就没有 resume 定位符直接用
unrecognized定位符还在里面,摘不掉拒绝启动

第三种是这个设计的要点:摘不干净就不许跑,而不是"尽力而为然后祈祷"。为了对付不同 shell 的引号风格,它会把 resume 参数按 posix/powershell/cmd 三种方式都引号化一遍去尝试匹配(:18),并要求后缀前面必须有空白边界,免得 codex resume-foo 误匹配 resume:29)。

7.4 会话选项目录

续接之外还有"这条会话用什么模型、什么努力档位"的问题。agent-session-option-catalog*.ts 是一张按 agent → 模型 → 选项的三层目录,每个选项同时描述启动时怎么传会话中途怎么改

// src/shared/agent-session-option-catalog-claude-codex.ts:44 claudeEffort
apply: {
launchArgs: (value) => ['--effort', String(value)],
agentArgsOverride: (tokens) => hasFlag(tokens, ['--effort']),
midSession: { kind: 'command', build: (value) => `/effort ${String(value)}` }
}

agentArgsOverride 那行的注释点破了细节:用户自己写的自由参数排在后面会赢,所以启动记录必须丢掉被覆盖掉的 picker 值,否则 UI 会显示一个并未生效的档位。

中途修改分四种形态(CatalogMidSessionApplyagent-session-option-catalog-types.ts:11):发斜杠命令、发切换命令、打开 agent 自己的选择器、明确标为不支持。


8. 计量:账号与额度

这一层规模不小但结构简单,一句话概括各自的职责:

目录干什么入口
src/main/claude-accounts/多个 Claude 账号的登录、凭据捕获、Keychain 存取、选择service.ts
src/main/codex-accounts/每账号一份托管 CODEX_HOME,含旧版共享认证的迁移runtime-home-service.ts
src/main/rate-limits/各家额度抓取与轮询service.ts
src/main/claude-usage/扫 Claude transcript 算 token 用量,按 worktree 归因scanner.ts

两个细节值得注意:

凭据存在系统 Keychain,不存 Orca 自己的文件。 服务名分两个:Claude Code-credentials(Claude CLI 自己那份,Orca 读写它来切账号)和 Orca Claude Code Managed Credentials(Orca 托管的副本),见 src/main/claude-accounts/keychain.ts:4

额度抓取有 API 和 PTY 两条路。 拿不到 OAuth 时会退化成"起一个隐藏 PTY 跑 claude、发 /usage、解析 TUI 输出"(src/main/rate-limits/claude-pty.ts)。这条路要靠正则去匹配 TUI 面板的措辞,注释里记着上游把 "Current week" 改成 "Weekly limits" 这类变更——很脆,但确实是唯一可行的兜底。用量扫描器本身则和 AI Vault 走同一批 transcript 文件,只是按 usage 字段和 requestId 去重后做日聚合。


9. 为什么三条来源必须同时存在

这是本章的主线。三者不是冗余,是覆盖互补

① OSC 9999② hook 回调③ 会话文件
谁产生agent 往 stdout 写agent 的 hook/插件机制agent 自己落盘
需要装什么什么都不用改用户家目录的配置什么都不用
实时性实时实时滞后
Orca 没开着时丢失丢失完整保留
能带的信息状态 + prompt + 工具(薄)最厚:模型、工具、子 agent、会话 id、中断标志全文历史、token、cwd、分支
主要盲区没有会话 id;要 agent 主动支持需要 agent 有 hook 机制;agent 崩溃就没有终结事件不实时;格式各家不同且随上游变
典型独占场景隐藏/模型托管的终端、无渲染面板侧边栏那一行的全部细节昨天关掉的会话、跨机器归档

举三个只有某一条能覆盖的情形:

  • 隐藏终端里跑 agent。 没有挂载的终端视图,但主进程仍在读字节流——createAgentStatusOscProcessor 的文档注释正是这么写的(agent-status-osc.ts:30-34)。
  • "它到底在跑哪个工具"。 只有 hook 的 PreToolUse 带得出 toolName + toolInput 预览。OSC 载荷太薄,transcript 又是事后的。
  • "上周三那条会话"。 Orca 那天可能根本没开。只有 ③。

还有一条隐含的暗线:三者最终都收敛到同一个净化函数。 OSC 走 parseAgentStatusPayload,hook 走 normalizeAgentStatusPayload,两者共用 normalizeAgentStatusObjectagent-status-types.ts:395),远端 relay 的信封在 ingestRemote 里被强制再跑一遍。三条来源,一个信任边界。


10. 巧妙之处(可借鉴的技术)

① 把"hook 脚本"当成敌对环境来写。 全路径调用 curl.exe/powershell.execommand -p cat 绕过 worktree 里的同名可执行文件、脚本存在性三重检查、|| true 收尾。核心洞察是:这段代码跑在用户的仓库目录下、用户的 PATH 里,任何一步都可能被仓库内容劫持。见 src/main/agent-hooks/installer-utils.ts:108(转出自 posix-hook-command.ts)、hook-stdin-contract.ts:7

② endpoint 文件解耦了"端点坐标"和"进程生命周期"。 PTY 能活过 Orca 重启,环境变量却是启动时快照。加一层每次 hook 执行都 source 的文件,就让长命 PTY 免费获得了新坐标。代价是这个文件必须 shell-safe——所以有 isShellSafeEndpointValueagent-hook-listener.ts:4688)。

③ 兜底推断的"基线快照"模式。 inferInterrupt 要求调用方把观测到的状态快照(agentType、prompt、两个时间戳)一起传回来,服务端逐项比对才开火(server.ts:943)。这让一个延迟的定时器天然无害——世界变了它就自动失效。比"加个锁"或"记个版本号"更轻,也更难写错。

④ 摘不干净就拒绝执行。 dropAgentResumeArgvFromCommandunrecognized 三态(agent-resume-argv-drop.ts:11)。大多数代码在这种位置会选择 best-effort,这里选择了 fail-closed,因为后果是"用错账号 resume 了别人的会话"。

⑤ 用编译期穷尽 + 运行期兜底的组合。 satisfies Record<TuiAgent, …> 保证新增 agent 时漏配会编译失败;?? 'other' 保证运行时脏数据不会让遥测整条丢弃。两者不冲突,各防一类错误(agent-kind.ts:53:59)。

⑥ 写配置前先比内容。 writeHooksJson 在内容相同时完全跳过(installer-utils.ts:322),因为"每次写都滚动备份"会在反复调用时把唯一可恢复的副本冲掉。这是一个只有出过事才会写下来的判断。


11. 边界与局限

诚实清单:

  • hook 要改用户的全局配置。 Orca 会往 ~/.claude/settings.json 之类的文件里注册托管条目。虽然有 .bak 滚动备份和文件锁,这仍然是对用户私有配置的写入。安装/移除刻意不暴露给渲染进程src/main/ipc/agent-hooks.ts:41)——因为应用启动时会自动重装,从 UI 触发移除会在下次启动被静默还原,属于误导。
  • 状态永不从终端标题推断。 这是一条主动放弃的能力(agent-status-types.ts:1-3)。代价是没有 hook 也没有 OSC 支持的 agent,在侧边栏就没有可信状态。
  • transcript 解析随上游漂移。 各家的落盘格式没有任何兼容承诺。代码里已经能看到几处历史断裂:Claude 的 transcript 文件名 UUID 不再等于 session id、OpenCode 1.17.x 从文件迁到 SQLite(两套扫描器并存)、Codex 的标题改成事后写进索引文件。
  • 远端 Windows 不支持。 installRemote 的注释写得很明白(src/main/claude/hook-service.ts:260):本地的 process.platform 判断不了远端 OS,所以 SSH 路径按 POSIX 一条路走。
  • docs/design/ 下的设计文档不在这份克隆里。 代码注释多次引用 docs/design/agent-status-over-ssh.mdterminal-side-effect-authority.md,克隆的 docs/ 目录下没有这些文件——本章对相关行为的描述全部取自代码本身。
  • OSC 9999 的产生方在仓库外。 克隆里只有解析端,没有任何生成这段序列的代码。它是一个给第三方 agent 用的入站协议,谁在用、用得对不对,代码里看不出来。

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

主题文件路径符号名
agent 全集(联合类型,36 个成员)src/shared/tui-agent.ts:3TuiAgent
agent 目录(启动/探测/注入)src/shared/tui-agent-config.ts:53TUI_AGENT_CONFIGgetTuiAgentLaunchCommand
遥测枚举与映射src/shared/telemetry-events.ts:66src/shared/agent-kind.ts:16AGENT_KIND_VALUESTUI_AGENT_KIND_BY_AGENTtuiAgentToAgentKind
进程 → agent 反查src/shared/agent-process-recognition.tsrecognizeAgentProcessFromCommandLinefindInterpreterEntrypointToken
shell 负信号src/shared/shell-process-detection.tsisShellProcess
检测入口兼容桶src/shared/agent-detection.ts(只做 re-export)
OSC 9999 流式解析src/shared/agent-status-osc.tscreateAgentStatusOscProcessor
状态类型与净化闸门src/shared/agent-status-types.tsparseAgentStatusPayloadAGENT_STATUS_STATES
身份仲裁(父子继承)src/shared/agent-status-identity.tsresolveAgentStatusIdentityshouldSuppressInheritedTerminalStatus
OSC 标题提取src/shared/osc-title-extraction.tsextractLastOscTitleextractAllOscTitles
OSC 133 shell 集成src/shared/terminal-osc133-command-finished.tscreateOsc133CommandFinishedScanner
pane 主键src/shared/stable-pane-id.tsmakePaneKeyparsePaneKey
hook HTTP 服务src/main/agent-hooks/server.tsAgentHookServerapplyNormalizedStatusinferInterruptingestTerminalStatusingestRemote
厂商事件归一化src/shared/agent-hook-listener.tsnormalizeHookPayloadnormalizeClaudeEventHOOK_SOURCE_BY_PATHNAMEwriteEndpointFile
hook 契约常量src/shared/agent-hook-types.tsAGENT_HOOK_TARGETSORCA_HOOK_PROTOCOL_VERSION
端点文件解析src/shared/agent-hook-endpoint-file.tsparseAgentHookEndpointFile
relay 线路信封src/shared/agent-hook-relay.tsAgentHookRelayEnvelopeAGENT_HOOK_NOTIFICATION_METHOD
stdin 契约src/main/agent-hooks/hook-stdin-contract.tsPOSIX_HOOK_STDIN_READERbuildPosixHookPayloadCapture
安装器工具src/main/agent-hooks/installer-utils.tswrapPosixHookCommandwriteManagedScriptwriteHooksJson
托管安装注册表src/main/agent-hooks/managed-agent-hook-registry.tsMANAGED_AGENT_HOOK_INSTALLERS
安装互斥锁src/main/agent-hooks/managed-hook-install-lock.tswithManagedHookInstallLock
WSL relay 生命周期src/main/agent-hooks/wsl-hook-relay-manager.tsWslHookRelayManager
Claude hook 脚本生成src/main/claude/hook-service.tsClaudeHookService.installgetManagedScript
Claude 注册的 hook 事件src/main/claude/hook-settings.tsCLAUDE_EVENTS
OpenCode 插件注入src/main/opencode/hook-service.tsgetOpenCodePluginSourceOpenCodeHookService
IPC 边界src/main/ipc/agent-hooks.tsregisterAgentHookHandlers
IPC 富化src/main/ipc/agent-status-ipc-boundary.tsenrichAgentStatusIpcPayload
会话身份与 resumesrc/shared/agent-session-resume.tsextractAgentProviderSessiongetAgentResumeArgvRESUMABLE_TUI_AGENTS
resume 参数摘除src/shared/agent-resume-argv-drop.tsdropAgentResumeArgvFromCommand
会话选项目录src/shared/agent-session-option-catalog.tsgetAgentSessionOptionCatalogmergeCatalogModels
会话源发现src/main/ai-vault/session-scanner-source-discovery.tsdiscoverAiVaultSessionSourcesclaudeProjectsRootDirs
Codex transcript 解析src/main/ai-vault/session-scanner-codex-parser.tsparseCodexSessionFileisCodexWorkerSession
Antigravity 解析src/main/ai-vault/session-scanner-antigravity-parser.tsparseAntigravitySessionFile
Claude 子 agent 转录src/main/ai-vault/session-scanner-claude-subagents.tslistClaudeSubagentSessions
首个 prompt 全文重读src/main/ai-vault/session-first-user-prompt-read.tsreadAiVaultFirstUserPrompt
远端 runtime 扫描src/main/ai-vault/runtime-session-scanner.tsscanRuntimeAiVaultSessions
Claude 账号与 Keychainsrc/main/claude-accounts/keychain.tsservice.ts
额度抓取src/main/rate-limits/service.tsRateLimitService
Claude 用量归因src/main/claude-usage/scanner.tsClaudeUsageAttributedTurn 相关流程

接着读: pane、tab、worktree 这些身份从哪来 → 01-domain-model.md;PTY 怎么活过 Orca 重启(本章 5.3 的 endpoint 文件正是为它服务的)→ 03-terminal-and-pty.md;本章收集的状态被谁消费、agent 之间怎么互相派活 → 06-agent-facing-surfaces.md