跳到主要内容

数据截至 (上游 commit b084ab075ba2)

适配 25 家 CLI:RuntimeAgentDef 与双向 MCP

30 秒导读: Open Design 自己不调模型,它调别人的编码 agent CLI——Claude Code、Codex、Gemini CLI、OpenCode……一共 25 家。这 25 家的命令行参数、prompt 投递方式、输出流格式没有任何两家相同。本章讲的就是这一层:一份纯声明式的 RuntimeAgentDef 如何把它们抹平成同一个接口,以及 daemon 如何反过来把自己变成一台 MCP server 接给它们。

本章只讲适配层。一次 run 从按下回车到产物落盘的主线在 01-run-lifecycle;prompt 文本本身怎么拼在 03-prompt-composition;失败分类与重试也在 01-run-lifecycle


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

一句话定义: 一个把 25 个终端 AI 编码工具"翻译"成同一套调用协议的兼容层。

它要解决的问题

假设你要写一个桌面 App,用户在里面点"生成一个落地页",你去调用用户本机已经装好的 AI 编码 CLI 来干活。

麻烦在于——每一家的用法都不一样。同样是"给它一段 prompt、让它流式吐结果",你要写出来的命令是这样的:

CLI实际命令形状prompt 怎么给输出长什么样
Claude Codeclaude -p --input-format stream-json --output-format stream-json --verbosestdin,包成一行 JSONLAnthropic 风格的 content_block_delta JSONL
Codexcodex exec --json --skip-git-repo-check --sandbox workspace-writestdin 裸文本自家的 item.completed / turn.completed JSONL
Gemini CLIgemini --output-format stream-json --yolostdin 裸文本又一种 JSONL 方言
Antigravityagy --log-file /tmp/x -p -stdin 裸文本纯文本,出错时什么都不打印
Grok Buildgrok --prompt-file /tmp/prompt.md临时文件纯文本
DeepSeekdeepseek exec --auto "<整段 prompt>"argv 位置参数纯文本

六家六种写法,还有十九家没列。而且这些差异不是"风格问题"——写错一个 flag,进程会在读到 prompt 之前就 exit 2。

它做了什么

这一层负责四件事:

  1. 声明:每家 CLI 写成一个 RuntimeAgentDef 对象——bin 名、argv 怎么拼、prompt 走哪条路、输出是哪种方言。
  2. 探测:在用户机器上找到那个可执行文件(PATH 不够就翻 Homebrew、nvm、macOS App Bundle),跑 --version 确认能启动,跑 --help 确认它支不支持某个可选 flag。
  3. 翻译:把各家五花八门的输出流解析成同一套 UI 事件(text_delta / tool_use / usage / turn_end)。
  4. 反向供能:把 Open Design 自己的能力(创建产物、列技能、发起 run)作为 MCP 工具接回给这些 CLI。

一句话直觉

把它想成打印机驱动。 应用层只会说"打印这一页",25 个驱动各自知道自家打印机的指令集。RuntimeAgentDef 就是驱动的声明格式,registry.ts 就是驱动列表。


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

一张图:一次 spawn 走过哪几关

从上往下读,每一关都可能因为"这家 CLI 不一样"而分叉:

用户选了 agent = "claude"


┌───────────────────────────┐
│ ① 找定义 │ registry.ts getAgentDef('claude')
│ id → RuntimeAgentDef │
└───────────┬───────────────┘

┌───────────────────────────┐
│ ② 找可执行文件 │ executables.ts + launch.ts
│ PATH / 工具链 / App 包 │ (codex 还要回溯原生二进制)
└───────────┬───────────────┘

┌───────────────────────────┐
│ ③ 拼 argv │ def.buildArgs(...)
│ 读能力位 + 会话 id │ ← capabilities.ts 的探测结果
└───────────┬───────────────┘

┌───────────────────────────┐
│ ④ 投 prompt │ stdin / 临时文件 / argv
│ 先过体积预算 │ prompt-budget.ts
└───────────┬───────────────┘

┌───────────────────────────┐
│ ⑤ 解析输出流 │ 按 def.streamFormat 分派
│ 5 种解析器 │ claude-stream / json-event-stream / …
└───────────┬───────────────┘

统一的 UI 事件流

部件一句话职责

部件干什么文件
RuntimeAgentDef一家 CLI 的完整声明(唯一的"知识载体")apps/daemon/src/runtimes/types.ts
AGENT_DEFS25 份声明的注册表 + id 去重断言apps/daemon/src/runtimes/registry.ts
defs/*.ts每家一个文件,导出一个 defapps/daemon/src/runtimes/defs/
探测找二进制、跑 --version / --help / auth 探针detection.ts executables.ts launch.ts
能力位--help 子串匹配点亮的可选 flag 开关capabilities.ts
prompt 投递stdin / 文件 / argv 三条路 + 体积守卫prompt-file.ts prompt-budget.ts
流解析把各家方言翻成统一事件claude-stream.ts json-event-stream.ts agent-protocol/acp/
双向 MCP外部 MCP 注入各家 CLI;daemon 自身也当 MCP serverruntimes/mcp.ts mcp-config.ts mcp.ts

主线走一遍(高层)

浏览器 POST /api/runsroutes/runs.ts:1206,也是 Web 端唯一的 run 入口,见 01-run-lifecycle §3)→ getAgentDef(agentId) 拿到声明 → resolveAgentLaunch 决定真正要 spawn 哪个文件 → def.buildArgs(...) 拼出 argv → 按 promptViaStdin / promptViaFile / argv 投递 prompt → 子进程 stdout 按 def.streamFormat 接上对应解析器 → 解析器把方言事件转成 SSE 推给前端。


3. 抽象本体:一份声明能说清一家 CLI 的什么

3.1 RuntimeAgentDef:把差异全部数据化

核心设计是:代码里没有 if (agentId === 'claude') 这种分支,差异全部落在数据字段上。

定义在 apps/daemon/src/runtimes/types.ts:126 RuntimeAgentDef。字段按用途分四组:

(一)身份与定位

字段含义
id / name内部 id 与 UI 显示名
bin主二进制名,如 claude / agy / vela
fallbackBins?主名找不到时依次再试的名字
versionArgs版本探针参数,通常 ['--version']

fallbackBins 存在的理由很具体:Claude Code 有 argv 完全兼容的 fork(OpenClaude),只装了 fork 的用户不该被判为"未安装"(defs/claude.ts:24)。

(二)调用形状

字段含义
buildArgs(prompt, imagePaths, extraAllowedDirs, options, runtimeContext)唯一的 argv 生产函数
promptViaStdin? / promptViaFile?prompt 走哪条路(都不设 = 走 argv)
promptInputFormat?stdin 用裸文本还是包成 JSONL('text' | 'stream-json'
maxPromptArgBytes?只有 argv 派才声明的体积上限
env?该 CLI 需要的固定环境变量

(三)能力协商

字段含义
helpArgs? + capabilityFlags?--help,用子串匹配点亮能力位
authProbe?声明一条便宜、无副作用的登录状态命令
listModels? / fetchModels?活取模型列表;失败退回 fallbackModels
supportsCustomModel?UI 允不允许手填模型 id

(四)会话与流

字段含义
streamFormat输出流方言,决定挂哪个解析器
eventParser?json-event-stream 内部再分的子方言
resumesSessionViaCli?这家 CLI 自带多轮记忆,daemon 不必重发整段 transcript
capturesSessionIdFromStream?会话 id 是 CLI 自己生成、要从流里抓的(区别于 daemon 指定)
resumesSessionViaAcpLoad?ACP 走 session/load 恢复
externalMcpInjection?外部 MCP 用哪种方式喂给它

resumesSessionViaClicapturesSessionIdFromStream 的区分是这份声明里最值得抄的一条。类型注释把两种"续会话"讲得很清楚(types.ts:182-192):

  • 指定式(specify-style):daemon 自己造一个 UUID 塞给 CLI(Claude 的 --session-id),存的就是自己造的那个。
  • 捕获式(capture-style):CLI 自己造 id 并在流里报出来(Codex 的 thread.started.thread_id),daemon 必须从解析结果里抓下来再存。

两种模式对应 daemon 侧完全不同的持久化时机,混为一谈就会在第二轮丢会话。

3.2 RuntimeContext:spawn 时才知道的东西

buildArgs 是纯函数,但有些信息只有 spawn 那一刻才有——比如临时文件路径、上一轮的会话 id。这些走第五个参数 RuntimeContexttypes.ts:22):

字段谁在用为什么需要
resumeSessionIdclaude / codex / opencode有值就续会话,daemon 只发最新一条用户消息
newSessionIdclauderesumeSessionId 时用它开新会话(指定式)
promptFilePathgrok-buildpromptViaFile: true 的适配器读这个临时文件
agentLogFilePathantigravityprint 模式对错误静默,只能事后读日志判因
hasPriorAssistantTurn-c 一类续聊 flag 的纯文本适配器判断这是不是第一轮
cwdcodex-C <cwd> 只在 create 轮合法

agentLogFilePath 的注释是这份代码"实证主义"风格的样本(types.ts:36-40):agy 在 print 模式下对缺认证配额耗尽两种失败都在 stdout/stderr 上完全静默——这是用 agy --log-file 抓出来验证的,所以只能事后读日志区分。

3.3 registry.ts:组装 + 一条断言

注册表本身只有 78 行(apps/daemon/src/runtimes/registry.ts),做三件事:

BASE_AGENT_DEFS (25 个内置) registry.ts:29
+
readLocalAgentProfileDefs(...) ← 用户自定义 profile

AGENT_DEFS registry.ts:63

id 唯一性断言(重复即 throw) registry.ts:68-74

getAgentDef(id) registry.ts:76

那条断言值得单独看一眼——它在模块加载期跑,重复 id 直接让 daemon 起不来:

const ids = new Set();
for (const def of AGENT_DEFS) {
if (ids.has(def.id)) {
throw new Error(`Duplicate agent definition id: ${def.id}`);
}
ids.add(def.id);
}

registry.ts:68-74。之所以敢用 fail-fast 而不是"跳过重复项":AGENT_DEFS 里混了用户自定义 profile,一个静默覆盖内置 claude 的用户配置会造成极难排查的错乱——虽然 local-profiles.ts:133 已经在生成侧先拦了一道。

3.4 25 家怎么分布

streamFormat 归类,25 家其实只有 7 种输出流形态:

streamFormat家数都有谁
acp-json-rpc8devin、hermes、kilo、kiro、reasonix、trae-cli、vibe、amr
json-event-stream6codex、cursor-agent、gemini、kimi、opencode、mimo
plain(纯文本)5aider、deepseek、qwen、antigravity、grok-build
claude-stream-json3claude、codebuddy、amp
qoder-stream-json1qoder
copilot-stream-json1copilot
pi-rpc1pi

这是整个设计能成立的关键观察:25 家 CLI 的参数几乎两两不同,但输出流只有 7 种;而其中 json-event-stream 这 6 家又共用同一个状态机、只在事件名映射上分方言。所以解析器只需要写 7 个,其中一个再内分 5 种方言。


4. 四个代表性 defs 的对比

挑四家看,它们各自代表一类适配难度。

4.1 claude.ts —— 能力门 + 指定式会话

defs/claude.ts:16 claudeAgentDef。它是"功能最全、也最需要小心版本差异"的一类。

能力门(capability gate) 是它最有教学价值的一段。--include-partial-messages 只有较新的 Claude Code 才有,老版本收到会 "unknown option" + exit 1,直接把对话打死。所以它先声明要探测什么:

helpArgs: ['-p', '--help'],
capabilityFlags: {
'--include-partial-messages': 'partialMessages',
'--add-dir': 'addDir',
},

defs/claude.ts:30-39。注意 helpArgs['-p', '--help'] 而不是 ['--help']——这两个 flag 挂在 claude -p 子命令下,探全局 help 永远探不到(注释里挂了 issue #430)。

然后 buildArgs 读探测结果决定加不加:

const caps = agentCapabilities.get('claude') || {};
const args = ['-p', '--input-format', 'stream-json',
'--output-format', 'stream-json', '--verbose'];
if (caps.partialMessages) {
args.push('--include-partial-messages');
}

defs/claude.ts:53-65这是"探测—声明—消费"闭环的完整样本capabilityFlags 声明关心什么 → detection.ts 探测并写进全局 Map → buildArgs 读 Map 决定 argv。

--add-dir 的判断写成 caps.addDir !== falsedefs/claude.ts:74),不是 caps.addDir === true——因为这个 flag 年头久、大概率存在,探测失败时应该"默认加上"而不是"默认不加"。同一套机制,两种默认方向,看具体 flag 的历史来定。

会话部分是指定式的典型:

if (typeof runtimeContext.resumeSessionId === 'string' && runtimeContext.resumeSessionId) {
args.push('--resume', runtimeContext.resumeSessionId);
} else if (typeof runtimeContext.newSessionId === 'string' && runtimeContext.newSessionId) {
args.push('--session-id', runtimeContext.newSessionId);
}

defs/claude.ts:82-86。daemon 造 id、告诉 CLI 用这个 id,所以存的就是自己造的。

另外它是唯一一个 promptInputFormat: 'stream-json' 的适配器(defs/claude.ts:91)——prompt 包成一行 JSONL 送进 stdin,stdin 不立刻关,这样 daemon 可以在同一轮里继续往里写用户消息。

4.2 codex.ts —— 同一个 CLI 的两套 argv 语法

defs/codex.ts:194 codexAgentDef。它的难点不是版本差异,而是同一个 CLI 的 create 轮和 resume 轮接受的 flag 不一样

codex exec resume 拒绝三类 flag,buildArgs 得对着分叉:

flagcreate 轮resume 轮怎么绕
--sandbox接受拒绝改用 -c sandbox_mode="…" 配置覆写
-C <cwd>接受拒绝不传;daemon 已经用 cwd: 起的子进程
--add-dir接受拒绝不传;权限随会话继承

defs/codex.ts:275-320。这里有个很细的点:resume 轮明明可以不传 sandbox(会话已建),但代码坚持用 -c 传一份语义完全相同的策略。注释解释了原因(defs/codex.ts:270-274)——Codex 每轮会拼一个 turn_context 块,只有让它逐字节相同,上游的前缀缓存才会命中,而"续会话"图的就是这个缓存。

会话是捕获式capturesSessionIdFromStream: truedefs/codex.ts:349),id 从流里的 thread.started.thread_id 抓,resume 时作为位置参数放在所有 flag 之后(defs/codex.ts:337-339)。

还有一个纯环境判断被提成了具名纯函数:

export function codexNeedsDangerFullAccessSandbox(platform, env): boolean {
if (env.OD_CODEX_SANDBOX?.trim() === 'danger-full-access') return true;
if (platform === 'win32') return true;
return Boolean(env.WSL_DISTRO_NAME?.trim());
}

defs/codex.ts:180-192。Windows 上 Codex 没有 OS 级沙箱、退化成"拒绝一切 shell"的粗策略;WSL 报告自己是 linux 但踩同一条路。提成纯函数是为了能单测——参数化 platform / env 而不读全局。

4.3 vibe.ts —— 最短的一份,也最能说明"少即是对"

最短的一份如今是 defs/vibe.ts:4 vibeAgentDef,全文 21 行,buildArgs 干脆是空的:

buildArgs: () => [],
streamFormat: 'acp-json-rpc',
externalMcpInjection: 'acp-merge',

它什么都省的原因是走 ACP:模型发现交给 detectAcpModelsdefs/shared.ts),参数组装交给协议本身(见 §7.4),def 只剩「二进制叫什么、怎么问版本」这点身份信息。(旧版这里引的 gemini def——38 行、用 GEMINI_CLI_TRUST_WORKSPACE 环境变量代替不稳的 --skip-trust flag——已在 HEAD 移除,Gemini 不再是受支持的 agent 之一。)

4.4 antigravity.ts —— 没有 flag 时怎么办

defs/antigravity.ts:169 antigravityAgentDef。这是最"脏"的一份,254 行里只有约 80 行是 def 本身,其余全是绕行代码。它同时踩了三个坑:

坑一:print 模式对失败完全静默。

agy -p 在缺认证和配额耗尽两种情况下,stdout 和 stderr 都是空的——用户只会看到 "empty response"。绕法是永远带上 --log-file,事后 grep 日志判因:

if (runtimeContext.agentLogFilePath) {
args.push('--log-file', runtimeContext.agentLogFilePath);
}
args.push('-p');
args.push('-');

defs/antigravity.ts:243-247。注释里还钉了一条顺序是承重的的经验:agy -p --log-file /tmp/x - 能跑但日志是空的,必须 agy --log-file /tmp/x -p -defs/antigravity.ts:238-242)。

坑二:CLI 根本没有 --model flag。

agy v1.0.3 没有 --model(上游 issue #35),但它的 TUI 换模型时会写 ~/.gemini/antigravity-cli/settings.json,而每次 agy -p 启动都会重读这个文件。于是 buildArgs 干脆在拼参数时顺手写这个文件:

if (options.model && options.model !== DEFAULT_MODEL_OPTION.id) {
writeAntigravityModelSelection(options.model, runtimeContext.antigravitySettingsPath);
}

defs/antigravity.ts:212-217,写入实现在 defs/antigravity.ts:48 writeAntigravityModelSelection

坑三:那个 settings.json 是进程全局的,会被并发 run 抢。

run A 写模型 A → spawn A → run B 写模型 B → A 的 agy 才去读文件 → A 跑在了模型 B 上。解法是一条 Promise 链做的互斥锁 + 一个"对方真的读到了"的信号探测:

run A ──写 settings.json──► spawn agy A ──┐
│ 轮询 --log-file 直到出现
│ Propagating selected model
│ override to backend: label="A"

run B 等在 acquireAntigravityModelLock() ─┘ ← 看到信号(或 A 退出)才放行

锁在 defs/antigravity.ts:82 acquireAntigravityModelLock,探测在 defs/antigravity.ts:131 waitForAgyToReadModel

后者的文档注释里有一条容易写错的契约(defs/antigravity.ts:126-130):返回 false 不等于"可以放锁了"false 只表示"我不轮询了",可能是冷启动的 agy 还没读到文件。放锁只能在两种情况下发生——(a) 返回 true,(b) 子进程退出。所以生产代码把 abort 信号接到 child.once('exit') 上(server.ts:7286-7292),让退出而不是超时来驱动放锁。

它还刻意不做一件事:agy 有 -c 续聊 flag,但 def 明确不用(defs/antigravity.ts:189-204)。原因是 -c 会激活 agy 内部的 agentic loop(多步重试、工具调用、出错回落到缓存响应),这个 loop 无法被 Open Design 的 system prompt 覆盖掉,实测导致第二轮逐字节重发第一轮的表单。"上游有这个能力但我们不用",也是适配决策的一部分。

4.5 defs/shared.ts —— 共用的小工具

defs/shared.ts 只有 47 行,装两类东西:re-exportdetectAcpModels / execAgentFile / DEFAULT_MODEL_OPTION,让 defs 只 import 一个模块)和两个共享解析函数

  • clampCodexReasoningdefs/shared.ts:9)——把 UI 上的 reasoning 档位按模型系族夹到合法值。比如 gpt-5.1-codex-mini 只认 high / medium,选了别的会被夹住。这类"模型 × 参数"的兼容矩阵放在 def 之外,因为 codex 和 amr 都要用。
  • parseLineSeparatedModelsdefs/shared.ts:33)——把"一行一个 id"的 <cli> models 输出解析成选项数组,顺手去重并前置一个合成的 default 项。opencode 和 cursor-agent 共用。

5. 从"一个名字"到"一个能跑的进程"

声明写好了,问题变成:bin: 'claude' 这个字符串,怎么变成一个真的能 spawn 的绝对路径?

这条链有四步,每步解决一类真实的部署事故。

def.bin = 'claude'

├─ ① 用户显式配置? executables.ts CLAUDE_BIN 等 22 个 *_BIN 变量

├─ ② 打包内置? (只有 amr/vela 有)

├─ ③ PATH + 工具链目录 resolveOnPath:PATH ∪ Homebrew ∪ nvm ∪ ~/.bun/bin …

└─ ④ macOS App Bundle /Applications/Codex.app/Contents/Resources/codex


selectedPath("用户装的那个")

▼ launch.ts resolveAgentLaunch
launchPath("实际 spawn 的那个")

5.1 为什么 PATH 不够

executables.ts:98 resolvePathDirsprocess.env.PATH 和一组"用户级工具链目录"并起来搜。理由写在注释里(executables.ts:101-104):GUI 启动的进程 PATH 是被剥光的。用户从 Dock 点开 Electron App,那个进程的 PATH 跟他 shell 里的 PATH 完全不是一回事,~/.nvm/versions/node/*/bin 一个都不在。

macOS App Bundle 那一层更具体(executables.ts:301 codexAppBundleExecutable):官方 Codex 桌面版把 CLI 藏在 Codex.app/Contents/Resources/codex,除非用户主动点过 "Install command line tool",否则它永远不在 PATH 上。这一层排在 PATH 之后(executables.ts:373),保证 npm i -g / Homebrew 装的那个永远优先。

四个来源的优先级在 executables.ts:339 inspectAgentExecutableResolution 里一行写清:

selectedPath: configuredOverridePath || builtInPath || pathResolvedPath || appBundlePath,

5.2 launch.ts:selectedPath ≠ launchPath

launch.ts:15 resolveAgentLaunch 引入了第二个概念。除 codex 外,两者相同;codex 要多走一步(launch.ts:26-36)。

原因是:npm 装的 codex 常常是个 #!/usr/bin/env node 的包装脚本,而 GUI 启动环境里 node 未必找得到。launch.ts:88 tryResolveCodexNativeBinary 从包装脚本的路径逐级向上node_modules/@openai/codex-<platform>-<arch>/vendor/<target-triple>/codex/codex 这样的原生二进制,找到就换掉。

目标三元组的映射在 launch.ts:156 codexNativeTargetTripledarwin+arm64aarch64-apple-darwin,等等)。

一个小而漂亮的细节在 launch.ts:166 looksLikeCodexNodeWrapper:判断"这是不是包装脚本"只读前 64KB。因为 selectedPath 现在可能是那个约 190MB 的 Codex.app 原生二进制,readFileSync 会把它整个读进字符串——只为了嗅一行 shebang。

5.3 applyAgentLaunchEnv:PATH 要两头补

launch.ts:39 applyAgentLaunchEnv 给子进程的 PATH 做前后夹击:

位置补什么为什么
最前dirname(process.execPath)Windows 的 npm .cmd shim 会调裸 node;GUI 启动时 PATH 里可能没有
次前agent 二进制自己的目录它可能要调同目录的兄弟工具
最后全部用户工具链目录见下

最后那一条解决的问题很具体(launch.ts:54-61):Pi 是个 #!/usr/bin/env bun 脚本,二进制本身能找到(因为解析时搜了 ~/.bun/bin),但 spawn 时子进程 PATH 里没有 ~/.bun/bin,于是 exit 127 "env: bun: No such file or directory",探测误判成"未安装"。解析用的搜索路径和 spawn 用的 PATH 必须对称,这就是修法。

PATH 合并还得处理 Windows 用 Path 不用 PATHlaunch.ts:69)——直接读 env.PATH 在 Windows 上是 undefined,会拼出一个只含新增项、丢光系统路径的 PATH。

5.4 detection.ts:一次探测跑几件事

detection.ts:191 probe 是单个 agent 的探测入口。它做的第一件事就点破了一个常见 bug:

const launch = resolveAgentLaunch(def, configuredEnv);

detection.ts:203探测必须探"将来真的会 spawn 的那个路径",不是 PATH 上看得见的 shim。否则 codex 在 nvm 下会出现"探测探 shim(失败)→ 显示未安装 → 但运行时其实能跑原生二进制"的矛盾(detection.ts:195-202)。

版本探针的错误分类写得很克制(detection.ts:117 probeVersionAtPath),两个分支:

症状判定依据
EACCES 或 exit 126不可调用 · 没执行权限execFile 的 OS 级拒绝给字符串 code
ENOENT / ENOTDIR 或 exit 127不可调用 · 目标缺失同上
其他任何失败(超时、非零退出、stderr 噪音)能调用,只是读不到版本非零退出给数字 code

第三类必须判成"可用、version = null"——有些适配器压根不支持 --version,判成不可用就把好好的 CLI 藏起来了。

版本探针过关后,三个后续探针并发跑(detection.ts:231):

const [caps, modelResult, auth] = await Promise.all([
probeCapabilities(def, launch.launchPath, probeEnv),
fetchModels(def, launch.launchPath, probeEnv),
probeAgentAuthStatus(def, launch.launchPath, probeEnv),
]);

单个 agent 的探测墙钟从 ≈15s 降到 max ≈5s。25 家再整体并发(detection.ts:327),外面还包一层 safeProbedetection.ts:289)——一个适配器的探测炸了不能拖垮整个列表。这条注释挂了 issue #2297:以前裸 Promise.all 一 reject,/api/agents 的 catch 返回 [],UI 静默丢掉全部 CLI 选项、只剩 BYOK。

还有个流式变体 detection.ts:346 detectAgentsStream,按完成顺序而非注册顺序 yield,让 UI 探到一个画一张卡,不用等最慢那家。

5.5 capabilities.ts:三行的全局单例

export const agentCapabilities = new Map<string, RuntimeCapabilityMap>();

capabilities.ts:3,整个文件就这三行(含 import)。写入在 detection.ts:238,读取在各家 buildArgs

探测逻辑本身在 detection.ts:167 probeCapabilities,核心是子串匹配

for (const [flag, key] of Object.entries(def.capabilityFlags)) {
caps[key] = String(stdout).includes(flag);
}

detection.ts:180-182。粗暴但对——help 文本里出现 --include-partial-messages 这个串,基本就说明它认这个 flag。--help 跑失败时返回空对象而不是 null(detection.ts:184-188),让 buildArgs 落回"不加任何可选 flag"的安全基线。

5.6 local-profiles.ts:用户自己加一家

用户可以在 ~/.open-design/agents.local.json 里加自定义 agent,不改代码。

local-profiles.ts:125 createLocalAgentDef 的做法是继承 + 前缀:挑一个 base def(默认 claude),覆盖 bin / name / env / 模型列表,然后把用户给的 args 作为前缀塞进 base 的 argv:

buildArgs: (prompt, imagePaths, extraAllowedDirs, options, runtimeContext) => [
...prefixArgs,
...base.buildArgs(prompt, imagePaths, extraAllowedDirs,
optionsWithDefaultModel(options, defaultModel), runtimeContext),
],

local-profiles.ts:185-194。这样一个"包装 claude 的脚本"或"用 claude argv 的代理网关",写几行 JSON 就能接进来。

安全边界卡得挺紧:

校验位置
id 必须匹配 ^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$local-profiles.ts:132
id 不得与内置 def 重名local-profiles.ts:133
env key 必须是合法 shell 标识符local-profiles.ts:74
字符串不得含 \0local-profiles.ts:66 :154
prefixArgs丢弃 base 的 authProbelocal-profiles.ts:182

最后一条容易漏想:base 的登录探针参数是给原 CLI 设计的,套一层 wrapper 后不一定还成立,所以有前缀就不继承。

沙箱模式下这个文件的路径会被重定向到沙箱内(local-profiles.ts:38-52),防止沙箱化的探测跑去读真实 home。


6. prompt 怎么送进去:三条路和一道体积闸

6.1 为什么不能直接当参数传

最直觉的写法是 claude -p "<整段 prompt>"。这条路在真实世界会炸,而且是三种不同的炸法:

平台限制触发形态
LinuxMAX_ARG_STRLEN 单个 argv 项 ≈128 KBspawn E2BIG
macOSARG_MAX argv+env 合计 ≈256 KBspawn E2BIG
WindowsCreateProcess 整条命令行 32 767 字符spawn ENAMETOOLONG
Windows + .cmd shim上面那条再套 cmd.exe /d /s /c "…",且引号会被翻倍同上,但阈值更低

而 Open Design 的 composed prompt(system prompt + DESIGN.md + 选中的 skills)常规就有 50–70 KBprompt-budget.ts:26-29)。所以 defs/claude.ts:45-51 的注释直接写死了结论:prompt 走 stdin。

6.2 三条路

声明谁在走怎么走
stdinpromptViaStdin: true绝大多数spawn 时 stdin 设 'pipe',写完(通常)关闭
临时文件promptViaFile: truegrok-builddaemon 建临时文件,路径经 RuntimeContext.promptFilePath 传给 buildArgs
argv两者都不设deepseek、aider、kimi作为位置参数,且必须声明 maxPromptArgBytes

stdin 派里 Claude 又特殊一档:promptInputFormat: 'stream-json',prompt 被包成一行 Anthropic 格式的 user 消息 JSONL,stdin 保持打开,直到一个非 tool_usestop_reason 到达才关。

文件派的实现只有 29 行(prompt-file.ts:11 preparePromptFileForAgent):mkdtemp 一个目录,写 prompt.md(mode 0o600),返回路径 + cleanup 闭包。def.promptViaFile 不为真就直接返回 null,所以调用点不用判断。grok-build 的 buildArgs 甚至在缺路径时直接 throw(defs/grok-build.ts:81-83)——这是内部契约违约,不该静默降级。

argv 派为什么还留着?看 deepseek 的注释(defs/deepseek.ts:25-26):它的 exec 模式把 prompt 声明成 clap 的必填位置参数,不接受 - 作 stdin 哨兵。没得选。

6.3 体积闸有三道

prompt-budget.ts 里三个 checker,分别守三种命令行形状:

composed prompt


① checkPromptArgvBudget ← spawn 前,只看 prompt 字节数
│ prompt-budget.ts:41

buildArgs → args[]

├─ resolvedBin 是 .cmd/.bat ──► ② checkWindowsCmdShimCommandLineBudget
│ prompt-budget.ts:150

└─ resolvedBin 是 Windows .exe ─► ③ checkWindowsDirectExeCommandLineBudget
prompt-budget.ts:217

第一道只看字节数,但有个平台分叉很关键(prompt-budget.ts:31-38):

const POSIX_ARGV_PROMPT_BUDGET = 120_000;

function resolveArgvPromptBudget(maxPromptArgBytes, platform) {
if (platform === 'win32') return maxPromptArgBytes;
return Math.max(maxPromptArgBytes, POSIX_ARGV_PROMPT_BUDGET);
}

maxPromptArgBytes 是按 Windows 32 KB 定的(deepseek 声明 30 000)。要是在 macOS/Linux 上也用这个数,正常项目就会被 50–70 KB 的常规 prompt 顶爆,报一个莫名其妙的 "prompt too long"(挂了 issue #4473)。所以 POSIX 上抬到 100 KB——仍在 Linux 128 KB 之下,纯粹为了让失控的 prompt 快速失败并给出可读的错误,而不是一个裸的 spawn E2BIG

后两道buildArgs 之后跑的,因为它们要算的是引号展开后的实际长度。两种展开规则不同,各写一份镜像实现:

  • .cmd / .bat shim → prompt-budget.ts:71 quoteForWindowsCmdShim,每个 """%"^%"
  • 直接 .exeprompt-budget.ts:86 quoteForWindowsDirectExe,镜像 libuv 的 quote_cmd_arg"\",紧邻引号的反斜杠翻倍。

%"^%" 那条替换不只是长度问题(prompt-budget.ts:66-70)——不转义的话,prompt 里任何 %NAME% 都会被 cmd.exe 从 daemon 环境里展开,一个提到 %DEEPSEEK_API_KEY% 的 prompt 会把密钥打出来

三个函数都刻意写成纯函数、显式收 resolvedBinargs,这样 macOS 上的测试可以传一个假的 C:\…\deepseek.cmd 路径跑同一套算术。


7. 流解析:把五种方言收敛到一个出口

7.1 分派

server.ts:7908 起是一条按 def.streamFormat 的分派链,每个分支挂一个解析器,全部通过同一个 sendAgentEvent 出口。UI 那边只认这几种事件:

事件含义
status生命周期(initializing / thinking / streaming),也用来捎带 sessionId
text_delta助手文本片段
thinking_delta扩展思考片段(折叠显示)
tool_use一次工具调用(id / name / input)
tool_result工具返回
usagetoken 与费用
turn_end本轮结束 + stopReason
error / raw错误、以及解析不了的原始行

7.2 claude-stream.ts:一个内容块状态机

claude-stream.ts:48 createClaudeStreamHandler。这是最复杂的一个,因为它要同时吃两种输入形态。

核心难点:开了 --include-partial-messages 时,文本以 stream_event 增量到达;没开时(老版本),文本在最后的 assistant 包裹里出现一次。两种都得支持,还不能重复。

解法是两个 Set 记账(claude-stream.ts:70-71):

stream_event 到达 text_delta ──► textStreamed.add(messageId)

assistant 包裹到达 ──► textStreamed.has(msgId) ? 跳过 : 补发

claude-stream.ts:406-445tool_use 用同样思路,但记的是 streamedToolUseIdsclaude-stream.ts:59)——Claude Code 会在最终包裹里重复已经流式发过的 tool_use,而且 input 常常是空的 {}

tool_use 的 input 累积是这个状态机的另一半。工具参数是分片的 JSON 文本,一片片喂进 content_block_deltainput_json_delta

content_block_start → blocks.set(key, { type:'tool_use', id, name, input:'' })
content_block_delta → state.input += delta.partial_json (同时发 tool_input_delta 给 UI 做实时预览)
content_block_stop → JSON.parse(state.input) → 发一个完整的 tool_use

claude-stream.ts:582 / :557 / :573JSON.parse 失败就什么都不发claude-stream.ts:637-640),让最终 assistant 包裹里那份完整 input 兜底。

turn_end发出时机是一条被注释重点标注的规则(claude-stream.ts:446-449):它必须在遍历完这条 assistant 消息的全部内容块之后才发。因为 daemon 会根据 turn_endstopReason 决定要不要关 stdin,而 stop_reason === 'tool_use' 表示模型停在工具中间——这时候关 stdin 会把后续回复截断。老版本 Claude Code 的 tool_use 从这个包裹里冒出来,先发 turn_end 就会让 daemon 在看到工具之前就下判断。

usage 聚合其实很简单:type: 'result' 那一行直接带了 usage / total_cost_usd / duration_ms,转发即可(claude-stream.ts:510-539)。

还有一层artifact 文本抑制claude-stream.ts:237 stripDuplicateArtifactText)。模型常常先用 Write 工具写文件、然后又把同样的内容用 <artifact> 包一遍吐到聊天里。这个函数把最近几次写文件的内容记下来(recentWriteContentsclaude-stream.ts:182-183),流式扫描时如果一个 <artifact>…</artifact> 块的正文跟其中一份归一化后相等,就整块丢掉。跨 chunk 匹配靠 artifactOpenCandidateLengthclaude-stream.ts:307)——<artifact 这个开标签可能被切在两个 chunk 之间,所以要留住尾部可能构成前缀的那几个字符。

7.3 json-event-stream.ts:一个状态机,五种方言

json-event-stream.ts:918 createJsonEventStreamHandler(kind, onEvent)。公共部分只有三件事:按行切、JSON.parse、按 kind 分派:

if (kind === 'opencode' && handleOpenCodeEvent(obj, onEvent, state)) return;
if (kind === 'gemini' && handleGeminiEvent(obj, onEvent, state)) return;
if (kind === 'kimi' && handleKimiEvent(obj, onEvent)) return;
if (kind === 'cursor-agent' && handleCursorEvent(obj, onEvent, state)) return;
if (kind === 'codex' && handleCodexEvent(obj, onEvent, state)) return;
onEvent({ type: 'raw', line }); // 没人认领 → 原样上抛

json-event-stream.ts:946-953。handler 返回 boolean 表示"我处理了",全部不认就发 raw——永不静默丢弃

共享的 ParserStatejson-event-stream.ts:6)是所有方言的公共记事本:

字段组用途
cursorTextSoFar / cursorTurnStartCursor 的重放对账基线
openCodeToolUses / codexToolUses工具调用去重
codexErrorEmitted错误只报一次
codexPreviousEventWasAgentMessage / codexLastAgentMessageEndedWithNewline段落边界补 \n
suppressNextArtifactText / suppressDuplicateArtifactText / artifactOpenCandidate / pendingArtifactTextartifact 文本抑制(与 claude-stream 同款逻辑的简化版,json-event-stream.ts:522

Codex 方言json-event-stream.ts:745 handleCodexEvent)里两处值得看:

thread.started 被映射成 status 事件、把 thread_id 挂在 sessionId 字段上(json-event-stream.ts:788)——跟 claude-stream 的 sessionId 走同一个通道,daemon 侧的捕获逻辑就只需要一份。注释还提到这个 id 的第二个用途:定位 $CODEX_HOME/sessions/**/rollout-*-<thread_id>.jsonl,那是 codex 唯一记录每次调用用量的地方(流里的 usage 是累计值)。

item.completed 的 agent_message 会做段落边界修补:如果上一个事件也是 agent_message、上一段没以换行结尾、这一段也不以换行开头,就补一个 \njson-event-stream.ts:887-892)。Codex 把消息切成离散 item,不补就会粘成一坨。

Cursor 方言的重放对账(json-event-stream.ts:641 reconcileCursorTurnReplay)是个漂亮的小算法。Cursor 既发实时增量、又在轮末发一份完整重放。直接追加会重复,直接丢弃会漏。做法是:

本轮已发出的文本 = cursorTextSoFar.slice(cursorTurnStart)

if (重放文本.startsWith(已发出)) → 只发差集后缀 ← 补上丢掉的尾巴
else → 什么都不发 ← 中间丢过块,宁可少不可重

注释特别点明基线必须是本轮而非整个跨轮缓冲区(json-event-stream.ts:634-637),否则第二轮会把整份重放再追加一遍,产出 "secondsecond turn" 这种输出。

而实时增量那条路 json-event-stream.ts:611 emitCursorTextDelta 明确不做内容去重"ha" "ha" 拼成 "haha" 是真内容,按内容判重会静默吞字。

7.4 其余几种

解析器位置一句话
createQoderStreamHandlerruntimes/qoder-stream.ts:62Anthropic 风格但更简单:assistant 消息里逐块取 text / thinking
readOpenCodeServiceFailureruntimes/opencode-log.ts:168不解析流——OpenCode 把 provider 错误写在自己的日志里,run 结束后读日志尾巴,先按 HTTP statusCode 分类、再关键词兜底(opencode-log.ts:131
attachAcpSessionagent-protocol/acp/session.ts:127ACP = 一套 JSON-RPC 会话协议:initializesession/new(或 session/load)→ session/prompt → 一串 session/update 通知

ACP 那 9 家(amr/devin/kimi/hermes/kilo/kiro/vibe/trae-cli/reasonix)共享一份实现——旧的单文件 acp.ts(1600+ 行)已拆成 apps/daemon/src/agent-protocol/acp/ 模块组(session.ts/session-params.ts/models.ts/rpc.ts 等,公共面从 agent-protocol/acp/index.ts 导出)。差异只剩两个 def 字段:acpMcpEnvFormat'array' 还是 'map',见 agent-protocol/acp/session-params.ts:45 buildAcpSessionNewParams)和 resumesSessionViaAcpLoadagent-protocol/acp/session.ts:1053 getDurableSessionId 交出可持久化的会话句柄——这是第三种"续会话"模式,跟 CLI flag 和流内捕获都不同。

7.5 role-marker-guard.ts:跨 chunk 的注入防线

role-marker-guard.ts:167 createRoleMarkerGuard。这道守卫解决的是一个真实攻击面:模型如果吐出字面的 ## user / ## assistant,而 daemon 又把渲染后的 transcript 回喂给下一轮,这些伪造的角色标记就会被当成真的轮次边界,让模型自己伪造用户发言

难点是流式## user 可能被切成 "## us" + "er\n" 两个 chunk。守卫的做法不是缓存整条消息(那是 O(n²)),而是只留 64 字符的滚动尾巴(role-marker-guard.ts:135):

已发出文本的尾部 64 字符 + 新 chunk → 跑正则

┌─────────────────┬───────────────┴──────────────┐
▼ ▼ ▼
命中标记 尾部是"完整但未确认"的 干净
截断 + 标 contaminated 标记前缀 → 暂扣,下轮再判 原样发出

正则的每处写法都带理由(role-marker-guard.ts:70-97):内部空白用 [ \t] 而非 \s\s 含换行,会让 ##\nuser 误命中);assistant 排在 assist 之前(让完整拼写吃掉 9 个字符,lookahead 落在正确位置);第二个 chunk 起丢掉 ^ 锚点(否则 "…看一下 ## user 这段…" 逐字符喂进来时,某个切点会让尾窗恰好以空白 + ## user 开头而误报)。

claude-stream.ts:211 emitSafeText 是接入点,并且只守 text_delta、不守 thinking_delta。理由写得很清楚(claude-stream.ts:197-210):thinking 走独立 payload,buildDaemonTranscript 从不把它折进 m.content,所以它根本不构成回注向量;而模型在推理链里讨论对话结构时经常写出 ## user,守它只会平白杀掉合法的 run。


8. 双向 MCP:既当客户端,又当服务端

MCP(Model Context Protocol,模型上下文协议——让 agent 发现并调用外部工具的标准)在这里是双向的。

┌────────────────────────────────────────────────┐
│ 方向 A:外部 MCP server ──► 各家 CLI │
│ 用户在 Settings 配的 GitHub / filesystem /… │
│ daemon 按每家的机制注入 │
└────────────────────────────────────────────────┘

┌────────────────────────────────────────────────┐
│ 方向 B:daemon 自己 ──► 任何 MCP 客户端 │
│ od mcp 把 daemon 变成一台 MCP server │
│ 工具:list_projects / start_run / … │
└────────────────────────────────────────────────┘

8.1 方向 A:四种注入方式

问题是 25 家 CLI 读 MCP 配置的方式各不相同。RuntimeAgentDef.externalMcpInjectiontypes.ts:160)把它枚举成四种策略:

策略机制谁在用构造函数
claude-mcp-json往项目 cwd 写 .mcp.json,Claude Code 启动时自动加载claudemcp-config.ts:312 buildClaudeMcpJson
acp-merge并进 ACP session/newmcpServers 数组hermes / kimi / kilo / kiro / vibe / devinmcp-config.ts:386 buildAcpMcpServers
opencode-env-content序列化成 OpenCode 配置 schema,塞 OPENCODE_CONFIG_CONTENT 环境变量opencodemcp-config.ts:456 buildOpenCodeMcpConfigContent
mimo-env-content同上,改名 MIMOCODE_CONFIG_CONTENTmimo同上
(未设)不转发,UI 显式提示"请在该 CLI 自己的配置里配"codex / gemini / cursor-agent / copilot / qoder / pi

分派在 server.ts:6439 / :6480 / :6495,环境变量在 server.ts:7216 注入。

最后那一行"未设 = 显式提示"是这个字段的一半价值。注释点明了它替换掉的是什么(types.ts:155-159):以前是静默失败——用户配了 MCP server,agent 那边根本没收到,也没人告诉他(issue #2142)。把"不支持"变成一个可查询的字段,UI 就能把沉默变成一句话。

三个 builder 里有两处共同的谨慎:

.mcp.json 之前先确认 cwd 是 daemon 托管的项目目录mcp-config.ts:288 isManagedProjectCwd)。git-linked 项目的 cwd 指向用户自己的仓库,往那儿写会覆盖他手写的 .mcp.json。所以这个判断要求 cwd 严格在 PROJECTS_DIR 之下(等于 PROJECTS_DIR 本身也返回 false)。而且启用的 server 清零时会主动 unlink 掉之前写的文件(server.ts:6467),否则"删掉一个 server"永远不生效。

没有启用项时返回 null 而不是空对象mcp-config.ts:441-450)。因为 OPENCODE_CONFIG_CONTENT 是跟用户全局配置合并的,塞一个 {} 会把用户存好的 mcp 段整个盖掉。null 的语义是"别动这个环境变量"。

远程 server 的 OAuth token 在两个 builder 里都以同样方式合并成 Authorization: Bearer <token> 头,且用户自填的 header 永远赢mcp-config.ts:299-311)。

8.2 方向 B:daemon 自己是一台 MCP server

apps/daemon/src/mcp.ts:1778 runMcpStdio。跑 od mcp 就把 daemon 暴露成一台标准 stdio MCP server,任何 MCP 客户端(包括这 25 家 CLI 里支持 MCP 的)都能调。

工具清单在 mcp.ts:153 TOOL_DEFS,共 18 个,按用途分三组(5 + 7 + 6):

分组工具
项目(5)list_projects get_project create_project delete_project get_active_context
文件与产物(7)get_artifact get_file list_files search_files write_file delete_file create_artifact
发起工作(6)list_skills list_plugins start_run get_run cancel_run list_agents

关键的一个是 start_runmcp.ts:417)——它的描述写得像 API 契约:"Open Design spawns its own agent to do the work and returns a runId immediately. Poll get_run(runId) until status is terminal." 也就是说外部 agent 可以把 Open Design 当成一个"设计承包商"下单,而不是自己去干。

list_skills 的描述里有一句刻意的边界声明(mcp.ts:407):"Discovery only — Open Design runs the skill, not you." 技能是 Open Design 内部的执行单元,不是给外部 agent 抄走的配方。

设计系统和技能文档不做成工具,而是做成 MCP resourceod://design-systems/<id>/DESIGN.mdod://skills/<id>/SKILL.mdmcp.ts:620-640)。区分标准是:要 Open Design 干活的 = tool,只是参考资料的 = resource

还有个小而实在的优化写在注释里(mcp.ts:140-145):工具描述统一写短、"active context 回落"的原理只在 server 的 instructions 块里讲一次,不在每个工具里重复——tools/list 的响应每个会话都要发给模型,省下约 150 token。

8.3 tool-tokens.ts:这次 run 允许回调哪些端点

除了 MCP,还有一条更轻的回调通道:daemon 起一批 /api/tools/* HTTP 端点,把一枚短命令牌放进子进程环境变量 OD_TOOL_TOKENserver.ts:1132-1136)。

端点白名单是硬编码的常量(tool-tokens.ts:22 CHAT_TOOL_ENDPOINTS,10 个):live-artifacts 的增删查改、connectors 的列举与执行、design-systems 读取、media 生成、library 搜索与应用。配套还有一份 CHAT_TOOL_OPERATIONStool-tokens.ts:38)做更细的操作粒度。

令牌的生命周期由 tool-tokens.ts:151 ToolTokenRegistry 管:

环节实现
铸造minttool-tokens.ts:155),随机 32 字节 → odtt_<base64url>,默认 TTL 15 分钟
存储只存 SHA-256 哈希做索引(tool-tokens.ts:109 tokenHash
校验validatetool-tokens.ts:196),依次查存在性 → 过期 → endpoint 白名单 → operation 白名单
撤销到期定时器自动撤(tool-tokens.ts:165);子进程退出时按 run 撤(server.ts:7093 等 7 处 revokeToolToken('child_exit')

ToolTokenRevocationReason 声明了四种理由:'child_exit' | 'sse_end' | 'ttl_expired' | 'manual'tool-tokens.ts:55)。当前代码里实际触发的是 child_exitttl_expiredsse_endmanual 只见于类型声明(本次通读未找到调用点)。

定时器统一 timer.unref?.()tool-tokens.ts:168),一堆待撤销的令牌不会把 Node 进程钉住不退出。

令牌上还能挂插件信任层级。tool-tokens.ts:134 checkConnectorAccess 是一个纯函数门禁:

grant 没带 pluginSnapshotId → 放行(非插件驱动的 run,向后兼容)
trust = trusted / bundled → 放行(隐含 connector:*)
trust = restricted → 必须在 pluginCapabilitiesGranted 里
列出 connector:<id>,否则拒

8.4 run-tool-bundle.ts:这次 run 能带哪些 MCP server

同一个 run 可以带一批run 级的 MCP server(不是用户全局配的那批)。run-tool-bundle.ts 负责校验和合并。

run-tool-bundle.ts:108 validateRunToolBundleForAgent 把注入策略翻译成"能不能接这个 bundle":

agent 的 externalMcpInjection判定
claude-mcp-json只有 deliveryTarget === 'managed-project' 才行(要写 .mcp.json,不能往用户仓库写)
opencode-env-content / mimo-env-content无条件通过(env 注入不落盘)
acp-merge只接 stdio 传输;有 sse/http 的直接报出是第几个不合格
未设拒绝,并在错误消息里带上 agent 名

合并逻辑在 run-tool-bundle.ts:158 resolveExternalMcpServersForRun:run 级覆盖同 id 的持久化配置,且沙箱模式下持久化配置整体不参与run-tool-bundle.ts:168)。返回值里额外带一个 persistedTokenServerIds——标出哪些 server 来自持久化配置且没被 run 级覆盖,它们才需要去查存好的 OAuth token。


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

① 差异全部数据化,代码里没有 agent id 分支。 25 家的区别落成 RuntimeAgentDef 的字段,加一家 = 加一个 defs/*.ts + 注册表加一行。server.ts:6431 的注释专门说明这次重构:从"硬编码 agent id / stream-format 判断"改成"按 def.externalMcpInjection 分派"。

② "不支持"也是一个值,不是沉默。 externalMcpInjection 留空是有语义的——UI 据此显示"外部 MCP 不会转发给 "。把 undefined 当成可查询的状态,而不是"忘了配"。

③ 探测要探将来真的会 spawn 的那个路径。 detection.ts:203resolveAgentLaunch 再探。否则 codex 在 nvm 下永远是"显示未安装但其实能跑"。

④ 能力探测的失败方向要按 flag 的年龄定。 新 flag 探不到就别加(caps.partialMessages),老 flag 探不到还是加(caps.addDir !== false)。同一套机制,两种保守方向。

⑤ 布尔返回值要明确"false 意味着什么"。 waitForAgyToReadModelfalse 是"我不轮询了",不是"对方没读到"。文档注释用全大写 MUST NOT 钉死了调用契约(defs/antigravity.ts:126-130),因为把它当成"可以放锁"会引入一个只在冷启动时出现的模型串号。

⑥ 流式安全检查用固定大小的滚动窗口。 role-marker-guard.ts 只留 64 字符尾巴,per-chunk 是 O(1)。缓存整条消息的写法在"50KB 分 1000 个 chunk"下是 O(n²)。

⑦ 引号展开算术写成纯函数,参数化平台。 quoteForWindowsDirectExe / quoteForWindowsCmdShim 显式收 resolvedBin,macOS 上传个假的 C:\…\foo.exe 就能测同一套逻辑。跨平台代码里"能在别的平台上测"本身就是设计目标。

⑧ 内部契约违约就 throw,别静默降级。 defs/grok-build.ts:81-83 在缺 promptFilePath 时直接抛——这是 daemon 自己没准备好,不是用户输入问题。


10. 边界与局限

能力探测是子串匹配,不是语法解析。 --help 里出现某个串就认为支持。CLI 要是在 help 里提到一个"已废弃"的 flag,会被误判为可用。

六家 CLI 拿不到外部 MCP。 codex、gemini、cursor-agent、copilot、qoder、pi 的 externalMcpInjection 未设,用户必须去它们各自的配置文件里配。这是显式承认的缺口,不是 bug。

antigravity 的模型锁是进程内的。 defs/antigravity.ts:80 的 Promise 链只在单个 daemon 进程内串行化。两个 daemon 实例(比如两套 namespace 并跑)会各自持锁,~/.gemini/antigravity-cli/settings.json 的竞态依旧存在。

hasPriorAssistantTurn 目前没有生产消费者。 类型注释说它是给"有 -c 一类续聊 flag 的纯流适配器"用的,但唯一候选 antigravity 已经明确放弃 -cdefs/antigravity.ts:189-204)。这个字段现在是留白。

capabilities 是全局可变 Map。 agentCapabilitiescapabilities.ts:3)是模块级单例,没有 per-run 快照。探测和 spawn 之间用户重装了 CLI,buildArgs 读到的是旧能力位,直到下次探测。

argv 派的体积闸只有三种形状。 POSIX 上直接 execvp(每个 argv 项独立缓冲区,没有拼接步骤)不做后置检查,只靠第一道字节预算兜底(prompt-budget.ts:228-231)。


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

主题文件路径关键符号
适配器契约apps/daemon/src/runtimes/types.tsRuntimeAgentDef · RuntimeContext · RuntimePromptBudgetError · DetectedAgent
注册表apps/daemon/src/runtimes/registry.tsBASE_AGENT_DEFS · AGENT_DEFS · getAgentDef
Claude 适配apps/daemon/src/runtimes/defs/claude.tsclaudeAgentDef · CLAUDE_FALLBACK_MODELS
Codex 适配apps/daemon/src/runtimes/defs/codex.tscodexAgentDef · parseCodexDebugModels · codexNeedsDangerFullAccessSandbox
Antigravity 适配apps/daemon/src/runtimes/defs/antigravity.tsantigravityAgentDef · writeAntigravityModelSelection · acquireAntigravityModelLock · waitForAgyToReadModel
ACP 适配(8 家)apps/daemon/src/runtimes/defs/amr.tsamrAgentDef
defs 共享工具apps/daemon/src/runtimes/defs/shared.tsclampCodexReasoning · parseLineSeparatedModels
探测编排apps/daemon/src/runtimes/detection.tsdetectAgents · detectAgentsStream · probe · probeVersionAtPath · probeCapabilities · safeProbe · stripFns
二进制解析apps/daemon/src/runtimes/executables.tsinspectAgentExecutableResolution · resolveOnPath · AGENT_BIN_ENV_KEYS · codexAppBundleCandidates · userToolchainBinDirs
启动路径与 PATHapps/daemon/src/runtimes/launch.tsresolveAgentLaunch · applyAgentLaunchEnv · tryResolveCodexNativeBinary · codexNativeTargetTriple · looksLikeCodexNodeWrapper
能力位缓存apps/daemon/src/runtimes/capabilities.tsagentCapabilities
id → 路径便捷入口apps/daemon/src/runtimes/resolution.tsresolveAgentBin
spawn 环境apps/daemon/src/runtimes/env.tsspawnEnvForAgent · openDesignAmrTraceEnv
探针执行apps/daemon/src/runtimes/invocation.tsexecAgentFile
自定义 agent profileapps/daemon/src/runtimes/local-profiles.tsreadLocalAgentProfileDefs · createLocalAgentDef · localAgentProfilesFile
prompt 临时文件apps/daemon/src/runtimes/prompt-file.tspreparePromptFileForAgent
prompt 体积闸apps/daemon/src/runtimes/prompt-budget.tscheckPromptArgvBudget · checkWindowsCmdShimCommandLineBudget · checkWindowsDirectExeCommandLineBudget · quoteForWindowsDirectExe
Claude 流解析apps/daemon/src/runtimes/claude-stream.tscreateClaudeStreamHandler · emitSafeText · stripDuplicateArtifactText
多方言 JSONL 解析apps/daemon/src/runtimes/json-event-stream.tscreateJsonEventStreamHandler · ParserState · handleCodexEvent · reconcileCursorTurnReplay · emitCursorTextDelta
Qoder 流解析apps/daemon/src/runtimes/qoder-stream.tscreateQoderStreamHandler
OpenCode 日志判因apps/daemon/src/runtimes/opencode-log.tsreadOpenCodeServiceFailure · extractOpenCodeServiceFailure
ACP 会话apps/daemon/src/agent-protocol/acpattachAcpSession(session.ts) · buildAcpSessionNewParams(session-params.ts) · detectAcpModels(models.ts)
角色标记守卫apps/daemon/src/role-marker-guard.tscreateRoleMarkerGuard · FABRICATED_ROLE_MARKER_RE
MCP 注入(构造)apps/daemon/src/mcp-config.tsbuildClaudeMcpJson · buildAcpMcpServers · buildOpenCodeMcpConfigContent · isManagedProjectCwd
MCP 注入(ACP live-artifacts)apps/daemon/src/runtimes/mcp.tsbuildLiveArtifactsMcpServersForAgent
daemon 作为 MCP serverapps/daemon/src/mcp.tsrunMcpStdio · TOOL_DEFS
回调令牌apps/daemon/src/tool-tokens.tsToolTokenRegistry · CHAT_TOOL_ENDPOINTS · CHAT_TOOL_OPERATIONS · checkConnectorAccess
run 级 MCP bundleapps/daemon/src/run-tool-bundle.tsvalidateRunToolBundleForAgent · resolveExternalMcpServersForRun · parseRunToolBundleForRequest
spawn 分派(消费方)apps/daemon/src/server.ts流解析分派 :7908:8175 · MCP 注入 :6394:6520 · 令牌铸造 :5589

继续读