它是什么
先搞清楚一件事:这不是第十个编程 Agent,这是那些 Agent 的宿主。
一个不写 Agent 循环的 Agent 产品
2026 年 4 月,Anthropic 发布 Claude Design——第一次让大模型不再吐散文,而是直接交付设计成品。它火了,但它闭源、付费、纯云、锁死在 Anthropic 的模型和技能上:不能自托管,不能换 Agent,不能 Vercel 部署。
Open Design(下称 OD)是它的开源替身。README 的定位句拆成三条可验证的工程主张:
| 主张 | 在代码里长什么样 |
|---|---|
| Agent 无关 | apps/daemon/src/runtimes/defs/ 下 26 个对象字面量,每个描述一条 CLI 怎么调;引擎里零个 per-agent 分支 |
| 品牌可契约化 | design-systems/ 下 151 个包,每包 manifest.json + DESIGN.md + tokens.css,被整段塞进系统提示词 |
| 交付真实文件 | Agent 在项目 cwd 里写文件,沙箱 iframe 渲染,导出 HTML / PDF / PPTX / ZIP / MP4 |
与本系列前九个项目的根本差别
前面九份分析(opencode / hermes-agent / Raven / CodeWhale / goose / nanobot / grok-build / OpenManus / Suna / openworker)里,每一个都在实现自己的主循环:调模型、解析工具调用、执行、拼上下文、再调模型。它们争的是「谁的循环更好」。
OD 一行主循环都没有。docs/agent-adapters.md:5 说得非常直白:
We delegate the entire agent loop — model calls, tool use, context management, permission handling, resume, cancel — to the user's existing code agent CLI. OD's job is to detect it, feed it a skill + prompt + working directory, and stream its output back to the web UI.
以及那句立论 docs/agent-adapters.md:9:
Thesis: The code agent space has already converged on strong implementations… Reimplementing another one is worse than talking to all of them.
数字化的项目形状
/api/*、SQLite、spawn、提示词组装。32 个路由模块。FileViewer.tsx 单文件 660 KB。apps/daemon/src/prompts/system.ts 2 075 行 / 125 KB,而 runtimes/registry.ts 只有 81 行。② 26 条定义对应 25 个二进制:
byok-opencode 复用 OpenCode 的可执行文件。③ 最小的适配器定义只有 629 字节(
defs/kilo.ts)。
全景架构:四进程 · 四类内容 · 一条 SSE
flowchart TB
subgraph P1["① 渲染进程 · 浏览器 / Electron renderer"]
UI["Next.js 16 + React 18
聊天 · 文件工作区 · 沙箱 iframe 预览 · 设置"]
end
subgraph P2["② Web 侧车"]
WEB["静态 UI + 预览状态
/api/* · /artifacts/* · /frames/* 重写"]
end
subgraph P3["③ Daemon · Node 24 + Express + better-sqlite3"]
API["/api/* 32 个路由模块"]
DB[("SQLite: projects · conversations
messages · runs · memory")]
COMP["提示词组装器 2075 行"]
REG["运行时注册表 26 条"]
LINT["lint-artifact"]
JURY["Critique Theater"]
end
subgraph P4["④ 被 spawn 的 CLI(不是 OD 的代码)"]
CLI["claude / codex / cursor-agent / copilot /
opencode / devin / hermes / kimi / pi / …"]
end
subgraph P5["⑤ Electron main(可选)"]
SHELL["原生窗口 · 自动更新 · 文件夹选择器
sidecar IPC"]
end
UI -->|同源 HTTP + SSE| WEB --> API
SHELL --> WEB
SHELL -.->|HMAC 单次令牌| API
API --> DB
API --> COMP --> REG
REG -->|spawn, cwd = 项目工作区| CLI
CLI -->|原生工具| FILES["项目文件(真实磁盘)"]
CLI -->|stdout| API
API -->|SSE 规范化事件| UI
API --> LINT
API --> JURY
FILES -->|文件事件| UI
四条必须记牢的边界
- Web UI 和
odCLI 调同一套 daemon HTTP API。docs/architecture.md:75「The CLI is not a second business-logic implementation; it is the machine-readable surface for the same capabilities.」这就是仓库反复强调的 dual-track rule:加一个用户能感知的能力,必须同一次改动里补齐 contract 类型 + daemon 路由 + web 界面 +od子命令。 - 协议是 HTTP + SSE,不是 WebSocket。架构文档开头有一段罕见的「历史否定说明」docs/architecture.md:11-16:最早的草案画过 Vercel 隧道模式、浏览器直连模式、WebSocket
session.generate、内存 session bus、history.jsonl、三根监听式技能注册表——全部被实现推翻。这段「否定式文档」是判断一份 spec 是否可信的好信号。 - Daemon 是唯一特权进程。它绑 loopback,拥有 SQLite、凭证、项目文件、spawn 权。web 只是壳。
- 子进程不是 OD 的代码。最容易搞混的一点,值得说第二遍。
内容四平面
OD 把「让模型做出好设计」拆成四条互相正交的轴。craft/README.md:9-16 的表格是最清楚的官方表述:
| 轴 | 目录 | API | 管什么 | 例子 |
|---|---|---|---|---|
| 功能技能 | skills/(164) | GET /api/skills | Agent 干活时调用的能力 | brand-extract · web-clone |
| 渲染模板 | design-templates/(115) | GET /api/design-templates | 可渲染的成品形状 | saas-landing · html-ppt-* |
| 设计系统 | design-systems/(153) | GET /api/design-systems | 品牌契约 | apple · stripe · linear-app |
| 工艺规则 | craft/(11 个 .md) | 由 daemon 按需组装 | 与品牌无关的普适规则 | ALL CAPS 必须 ≥0.06em 字距 |
第四轴是 OD 相对 Claude Design 最聪明的一处拆分。craft/README.md:18-21:
DESIGN.mdtells the agent which colors and fonts a brand uses.craft/tells the agent the universal rules a competent designer applies on top — e.g. ALL CAPS always needs ≥0.06em tracking, regardless of the brand.
技能通过 frontmatter 按需订阅工艺规则,只有被列出的段落会进提示词——一个只排版的技能不用为色彩、动效内容付 token 成本:
od:
craft:
requires: [typography, color, anti-ai-slop]
注册表的「用户根优先」扫描
四类内容都遵循同一条发现规则:每次请求都重扫(没有 watcher、没有 SIGHUP、不用重启),同名用户条目遮蔽内置条目(不删除,删掉用户版内置版自动回来)。聊天期解析跨两个注册表——因为持久化的项目里 skillId 既可能是功能技能也可能是设计模板。这是历史包袱换来的兼容性。
三种运行形态与「数据根」这条唯一真理
| 形态 | 入口 | 谁起 daemon | 谁起 web | 桌面壳 |
|---|---|---|---|---|
| 源码开发 | pnpm tools-dev run web | tools-dev 侧车 | tools-dev 侧车 | 不启 |
| 打包桌面 / 无头 | apps/packaged | packaged 启动器 | packaged 启动器 | Electron(无头版跳过) |
| 容器 / daemon 直服 | docker compose up -d | 单进程:同一个 daemon 直接服 apps/web/out 静态导出 + /api/* | 无 | |
When ports are not supplied,
tools-devchooses available daemon and web ports… Ports are transport details; they do not define process identity, namespaces, or daemon data roots. docs/architecture.md:22-31
端口是临时的,身份不是端口决定的。打包桌面模式下 Electron 甚至不假设端口——它通过 sidecar IPC 去问 web 的真实 URL。这跟 openworker 用固定 8765 端口 + token 文件的做法形成对照:OD 把端口彻底降级成传输细节。
「数据根契约」:一处刻意的文档纪律
这是全仓库最有意思的一条元规则。docs/architecture.md:160-162:
This document intentionally gives no concrete daemon data path. The root
AGENTS.mdsection Daemon data directory contract is the only path authority.
README 里也重复了同一句禁令(「This README MUST NOT restate it」)。apps/daemon/AGENTS.md:97 再补一刀:「Route all daemon-owned data through RUNTIME_DATA_DIR or constants derived from it.」
为什么值得单独拎出来讲?因为多处文档各自写死一个路径是所有本地优先应用的经典腐烂源:改了实现,八个 md 里有六个还写着旧路径,用户按文档找不到数据。OD 的做法是把路径降级成单点权威,其他所有文档只允许引用不允许复述。
flowchart TB
ENV["OD_DATA_DIR 环境变量"] -->|启动时解析一次| ROOT["RUNTIME_DATA_DIR"]
ROOT --> D1["SQLite 库"]
ROOT --> D2["托管项目工作区"]
ROOT --> D3["artifacts"]
ROOT --> D4["用户 skills / templates / design-systems"]
ROOT --> D5["凭证 · 自动化状态 · 插件状态"]
IMP["POST /api/import/folder"] -->|唯一例外| EXT["用户选中的外部 baseDir
(校验 + 边界限制,不拷贝)"]
metadata.baseDir,daemon 对那个工作区做校验和边界限制,而不是把它拷进托管根。适配器层
整个项目最值得学的一层。它把「支持 N 个 CLI」的成本从 O(N) 压到了 O(1)。
适配器契约:RuntimeAgentDef 是数据规格,不是类
面对「要支持 25 个 CLI」这个需求,99% 的团队会写一个抽象基类。OD 没有。docs/agent-adapters.md:17:
An adapter is not a class that implements the agent loop. It is a plain data object — one
RuntimeAgentDefobject literal per CLI — that declares how to talk to that CLI… There is no per-agent subclass and norun()/cancel()method to implement.
契约本体在 apps/daemon/src/runtimes/types.ts:101-253,共 ~45 个字段,其中只有 6 个必填:
export type RuntimeAgentDef = {
id: string; // 唯一键,注册表用它去重
name: string; // 显示名
bin: string; // 要在 PATH 上探测的可执行文件
versionArgs: string[]; // 版本探测参数
fallbackModels: RuntimeModelOption[];// 探测不到模型时的静态兜底
buildArgs: (prompt, imagePaths, extraAllowedDirs?, options?, runtimeContext?) => string[];
streamFormat: string; // 引擎据此分派到解析器
// …剩下 ~38 个可选字段,全部是数据或纯 arg-builder
};
关键性质:buildArgs 是唯一的函数字段,而且它是纯的——输入提示词和选项,输出 argv 数组。它不 spawn、不读文件、不管生命周期。所以探测、启动、调用、取消、解析全部由共享引擎做。
Adding a CLI is a one-file change. Drop a new
runtimes/defs/<cli>.ts… no engine edits, no new class, no method overrides. docs/agent-adapters.md:27
运行时上下文开关
这条定义声明了什么
引擎最终 spawn 的 argv
注册表:81 行 + 一条启动期不变式
// registry.ts:30-57 —— 26 条基础定义
const BASE_AGENT_DEFS: RuntimeAgentDef[] = [
amrAgentDef, claudeAgentDef, codexAgentDef, devinAgentDef,
opencodeAgentDef, byokOpenCodeAgentDef, hermesAgentDef, traeCliAgentDef,
grokBuildAgentDef, kimiAgentDef, cursorAgentDef, qwenAgentDef,
qoderAgentDef, copilotAgentDef, ampAgentDef, piAgentDef,
kiroAgentDef, kiloAgentDef, vibeAgentDef, deepseekAgentDef,
aiderAgentDef, antigravityAgentDef, reasonixAgentDef, codebuddyAgentDef,
mimoAgentDef, atomcodeAgentDef,
];
// registry.ts:65-68 —— 用户本地 profile 合并进来
export const AGENT_DEFS = [...BASE_AGENT_DEFS, ...readLocalAgentProfileDefs(BASE_AGENT_DEFS)];
// registry.ts:70-76 —— 启动期不变式:id 不许重复,重复直接 throw
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);
}
三种「会话续跑」风格
types.ts:189-209 用三个布尔字段区分了三种截然不同的续跑机制,这是我在其他项目里没见过的精细分类:
| 字段 | 风格 | 谁生成 session id | 代表 |
|---|---|---|---|
resumesSessionViaCli | 指定式 | daemon 生成 newSessionId,告诉 CLI 用它 | claude --session-id <uuid> |
capturesSessionIdFromStream | 捕获式 | CLI 自己生成,从流里报出来 | codex 的 thread.started.thread_id |
resumesSessionViaAcpLoad | ACP 式 | 从 ACP 会话拿 getDurableSessionId() | AMR / Vela,用 session/load |
混淆前两者会导致「以为在续跑,实际每次都新开」——捕获式的 newSessionId 根本不传给 CLI。
agy -c 的事故:「Without this opt-out, agy with
-c receives the same prior turn twice — once from its own conversation memory, once embedded in the composed user request — and the embedded copy includes the literal <question-form> markup it emitted on turn 1. The model then pattern-matches that and re-emits the form on turn 2, looking like the discovery loop never breaks.」解法不是改提示词,是加一个 opt-out 标记,让 daemon 在这类适配器上跳过渲染的 web transcript,只发最新一条用户消息。
契约的边界:它刻意不包含什么
docs/agent-adapters.md:176-181 有一段「反向说明」,价值不亚于正向说明:
nativeSkillLoading.cursorrules。agents.capabilities()capabilities.ts 全文只有 131 字节——它只是一个「从 --help 输出里发现的 flag」的内存 map。surgicalEdit / streaming / resume 能力矩阵——那会变成一张永远对不齐的表。这条设计的可验证后果:接第 26 个 CLI 要改几处?
「适配器是数据不是类」这句话本身是个主张。它的后果才是证据——下面这张图来自 架构图库,把「加一个新引擎」这件事的改动半径摊开:
flowchart LR
NEED["需求:接入第 26 个 CLI 运行引擎"] --> ASK{"要改什么?"}
ASK -->|"❶ 数据扩展 (数据即适配器)"| D1["runtimes/registry.ts
往数组加一条 RuntimeAgentDef 字面量
改动半径 = 1 个文件 · 0 行新逻辑代码"]
ASK -.->|"❷ 接口扩展 (本项目不需要)"| D2["编写 JavaScript 类 / 实现 Adapter 接口"]
ASK -.->|"❸ 核心改造 (本项目不需要)"| D3["修改 Daemon 主循环调度器"]
D1 --> OK["✅ 接入完成"]
classDef good fill:#e8f5e9,stroke:#2e7d32,stroke-width:2.5px
classDef na fill:#fafafa,stroke:#bdbdbd,stroke-dasharray:4 3,color:#9e9e9e
class D1,OK good
class D2,D3 na
反向对照:如果适配器是「类」,接第 26 个 CLI 需要新建继承
Adapter 的类文件 + 在工厂注册 + 补单测 + 处理生命周期钩子 ≈ 4 处改动;
现在是 1 处,且是纯数据。这个差值就是本章那条设计的全部收益。
更多同类图见 七大 Agent 架构图库。
探测与能力协商:为什么必须探测「将来真正会被 spawn 的那个路径」
flowchart TB
START["probe(def, configuredEnv)"] --> RESOLVE["① resolveAgentLaunch(def, env)
解析出「将来真正会 spawn 的路径」"]
RESOLVE -->|无路径| UNAVAIL1["不可用 + ExecutableDiagnostic"]
RESOLVE --> ENV["② 组装 spawn 环境"]
ENV --> VER["③ 版本探测"]
VER -->|OS 级缺失/不可执行| UNAVAIL2["不可用 + NotInvocableDiagnostic"]
VER -->|能启动但拒绝 --version| OKNOVER["✅ 可用,version = null"]
VER -->|成功| PAR
OKNOVER --> PAR
PAR["④ 三个后置探测并发 Promise.all"] --> C1["--help 能力表"]
PAR --> C2["模型发现"]
PAR --> C3["鉴权探针(仅当声明了)"]
C1 --> CACHE["agentCapabilities.set(def.id, caps)"]
C1 & C2 & C3 --> OUT["DetectedAgent"]
「Detection must probe the exact path the runtime will spawn, not just the PATH-visible shim. This is load-bearing for Codex under nvm/fnm/mise: the discovered
codex entry is often a #!/usr/bin/env node wrapper that is not invocable from a GUI-launched app's stripped PATH, while the launch resolver can still upgrade it to the packaged native Codex binary. If detection probes the shim but chat/run spawns the native binary, the UI incorrectly reports "not installed" until the user pins CODEX_BIN by hand even though the real launch path is healthy.」
翻译成人话:GUI 启动的 App 拿到的 PATH 是被系统精简过的(macOS 的 launchd 不读你的 .zshrc),nvm 装的 shim 在里面跑不起来;但启动解析器有能力升级到打包的原生二进制。如果探测用 A 路径、真跑用 B 路径,用户就会看到「没装」但其实能跑。
三个次要但会救命的细节
| 版本探测结果 | 判定 | 为什么 |
|---|---|---|
| OS 级缺失 / 不可执行 | 不可用 | 真的没有 |
能启动,但拒绝 --version | 可用,version 留空 | 有些 CLI 改过版本 flag 名,不该判死 |
| 成功 | 可用 + version | — |
并发的动机被写进注释detection.ts:273-277:「run them concurrently so a single agent's detection wall is max(help, models, auth) ≈ 5s rather than the sum ≈ 15s.」
每条适配器故障隔离:裸 Promise.all 会因一条拒绝而整体拒绝——一个坏掉的可执行文件不能清空整个选择器。
鉴权:只对声明了探针的适配器断言
这是一处刻意的减法。老做法是看 ~/.foo/ 目录在不在来猜有没有登录——猜错的代价是把能用的 agent 标成红叉。
Definitions without
authProbeare not assigned a synthetic auth failure from a config-directory guess. docs/agent-adapters.md:126-128
没声明探针就是 unknown,真正的鉴权失败只从真实运行失败的错误文本里推断(classifyAgentServiceFailure)。Claude 的探针就是 ['auth','status'],5 秒超时。
探测的 UX
GET /api/agents?stream=1 每探完一条就发一个 agent SSE 事件,最后 done。设置面板因此不用等最慢的那条 CLI 就能开始画卡片。
没有 24 小时探测缓存——每次调用都重新并发探测。这是一个「宁可多花几秒也不要给用户看陈旧状态」的取舍(用户刚 npm i -g 装完,刷新就该看见)。
四种流格式:从 claude-stream-json 到 ACP 再到裸文本
streamFormat | 运行时 id | 解析器 |
|---|---|---|
claude-stream-json | claude · amp · codebuddy | runtimes/claude-stream.ts(25.9 KB) |
json-event-stream | codex · cursor-agent · opencode · mimo · byok-opencode | runtimes/json-event-stream.ts(30.2 KB,按 eventParser 再分派) |
copilot-stream-json | copilot | src/copilot-stream.ts(唯一平铺在 src/ 的解析器) |
qoder-stream-json | qoder | runtimes/qoder-stream.ts |
acp-json-rpc | 9 条:amr · devin · hermes · kimi · kiro · kilo · reasonix · trae-cli · vibe | agent-protocol/acp/(session.ts 40.7 KB) |
pi-rpc | pi | agent-protocol/pi-rpc/ |
plain | aider · antigravity · atomcode · deepseek · grok-build · qwen | runtimes/plain-stream.ts |
九条 ACP 适配器共用同一个传输层——这是「新增一条 ACP agent 只需一个 def 文件」的直接原因。defs/kiro.ts 637 字节、defs/vibe.ts 635 字节、defs/kilo.ts 629 字节。
所有解析器输出同一套事件:thinking / tool-call / tool-result / text-delta / file-write / error / done。这套事件是由解析器定义的,不是由 def 定义的docs/agent-adapters.md:107。
plain 流的 <artifact> 提取:最弱的适配器要写最多的代码
裸文本流的 CLI 没有结构化的文件写入工具调用。约定是 Anthropic 风格的源码块,run 结束时扫 stdout 提取。听起来五分钟能写完,实际 plain-stream.ts 有 473 行,因为要绕开四个坑:
<artifact> 放进代码围栏。解法:先算跳过区间——围栏逐行判断,行内反引号要匹配相同数量的连续反引号。plain-stream.ts:252-306<artifacts> 不是 <artifact>。判定:开标签后面必须是空白字符。plain-stream.ts:230-233>title="A > B" 会被 indexOf('>') 截断。要用带引号状态机找开标签结尾。plain-stream.ts:235-250apps/web/src/artifacts/markdown-context.ts——headless 落盘和浏览器解析必须看到同一套围栏边界,否则无头运行和有浏览器时的结果会不一样。
再加两个防炸弹:MAX_ARTIFACTS_PER_RUN = 50,文件名冲突加 -2/-3(上限 10 000)。
落盘时同时写「产物清单」
// plain-stream.ts:421-472 —— 按扩展名生成 artifactManifest
// .html → { kind:'html', renderer:'html', exports:['html','pdf','zip'], primary:true }
// .css → { kind:'code-snippet', renderer:'code', exports:['txt','zip'] }
// .svg → { kind:'svg', renderer:'svg', exports:['svg','zip'] }
// .md → { kind:'markdown-document', renderer:'markdown', exports:['md','html','pdf','zip'] }
这份清单就是 CONTEXT.md:23-25 定义的 Artifact Manifest——「把一个项目文件标记成正式产物,并记录它的种类、渲染器、导出方式、入口文件」的旁挂元数据。没有它,文件工作区只能靠扩展名猜。
嵌套 agent:一个诚实的开放问题
Claude Task frames have a non-null
parent_tool_use_id; the parser surfaces their content but prevents the child'sturn_endfrom completing the parent run. The UI still does not expose an independent nested-run tree. docs/agent-adapters.md:518-522
内容能显示,但 UI 里没有嵌套树。文档把它明确列在「Open questions」里,而不是假装已解决。
两档执行画像:filesystem vs text_artifact
这是 OD 处理「适配器能力参差」的核心抽象,只有一个 249 字节的文件:
// packages/contracts/src/execution-profile.ts
export type ExecutionProfile = 'filesystem' | 'text_artifact';
export function executionProfileFromStreamFormat(streamFormat) { … }
flowchart TB
RUN["一次生成"] --> PROF{"executionProfileFromStreamFormat
(def.streamFormat)"}
PROF -->|filesystem| F1["CLI 用自己的 Write/Edit 原生工具
直接在项目 cwd 写文件"]
F1 --> F2["文件事件推给文件工作区"]
F2 --> F3["可预览文件 = 交付物"]
F3 --> F4["助手以普通摘要收尾
❌ 禁止再输出 artifact 源码块"]
PROF -->|text_artifact| T1["模型循环里没有文件系统工具"]
T1 --> T2["唯一交付形态:一个完整的
artifact 源码块"]
T2 --> T3["run 结束后 daemon 扫 stdout
persistPlainStreamArtifacts()"]
T3 --> T4["落成项目文件 + Artifact Manifest"]
T4 --> F3
<artifact> 源码块,text_artifact 档则明确说 artifact 块是唯一交付形态。「Do not output generated source code in a
<artifact> block.」「Do not duplicate file contents in assistant text after writing them to disk.」「A filesystem run that emits a source-code <artifact> is treated as an unexpected fallback by the host.」「No tools are wired through to you.
TodoWrite, Read, Write, Edit, Bash, WebFetch are unavailable.」「The override does NOT block <artifact> blocks — those are how the web UI receives finished HTML in API mode.」<todo-list>、[读取 X])instead of producing real progress events.」模型不会说「我没有这个工具」,它会假装调用。UI 什么也渲染不出来,用户看到一堆乱码。
修法的难点在于:发现层自己开头就写着「以下规则覆盖后文一切」。要压过它,唯一干净的办法是把 API 模式覆盖钉在绝对顶部。contracts/prompts/system.ts:498-512 的 JSDoc 把这个理由完整记录了下来——这是我见过对「提示词优先级」最认真的一处工程注释。
上下文工程
真正的产品护城河。既然你控制不了推理,唯一的杠杆就是这一层。
输入分流:六个创建面 × 七种 skill mode
They are intentionally not one-to-one. UI tabs describe the workflow a user starts; skill modes describe how the daemon indexes and routes an instruction bundle. docs/modes.md:5-11
| 创建标签 | 项目元数据 | 技能路由 | 主要区别 |
|---|---|---|---|
| Prototype | kind: prototype | 默认 prototype 技能,被选中的设计模板替换 | 响应式 web / 移动 / 平板 / 桌面界面 |
| Live Artifact | kind: prototype, intent: live-artifact | 具备 live-artifact 能力的 prototype 技能 | 高保真、带数据 / 连接器 |
| Deck | kind: deck | 默认 deck 技能,被设计模板替换 | 幻灯导航 + 演示导出 |
| Template | kind: template | 用户通过 Share 保存的项目模板 | 从用户自己存的模板起步,不回落到内置目录 |
| Media | kind: image / video / audio | 匹配 mode 的媒体技能 | provider / 模型 / 画幅 / 时长 / 音色 |
| Other | kind: other | 不要求技能 | 自由形态 |
Daemon 侧七种 od.mode:prototype · deck · template · design-system · image · video · audio。
design-system 是合法的 skill mode,但已不是创建标签——设计系统被提升成独立的产品页面了。② Live Artifact 只是 prototype + intent:「Code that branches on project kind alone must therefore also check the intent.」这是一条会被踩的隐坑。
「模板替换默认技能」而不是「模板叠加默认技能」
A selected Start from template replaces the tab's default skill as the project's primary
skillId… it does not automatically compose the defaultprototypeskill with the template. docs/modes.md:36-41
选了 saas-landing 模板,就没有默认 prototype 技能了。列表分离(两个 API)不等于自动组合——两份都注入会产生互相打架的工作流指令。
HyperFrames:一个不新增后端 kind 的新入口
CONTEXT.md:39-41 记录了一个很聪明的产品/工程折中:HyperFrames 在 Home 有独立入口(用户感知上是一等公民),提交时却是 kind: "video" + videoModel: "hyperframes-html"——不动 ProjectKind 联合类型、不动 SQLite schema、不动已有项目。
_Avoid_ 的领域词典
CONTEXT.md 里每个术语都带一行 _Avoid_——列出不许用的同义词。例如 Normal Artifact 后面写着「_Avoid_: live artifact, generic file upload」。它甚至附了一段示例对话和一条已解决的歧义记录:「"artifact creation" was used to mean both Normal Artifact creation and Live Artifact creation; resolved: this capability creates Normal Artifacts only.」
心脏地带:composeSystemPrompt 的二十层与缓存分区
如果说别的 Agent 项目的心脏是主循环,OD 的心脏就是这个函数:daemon 版 apps/daemon/src/prompts/system.ts:791-1370(约 580 行函数体,文件 2 075 行),BYOK 镜像版 packages/contracts/src/prompts/system.ts:256-496。ComposeInput 有 30+ 个字段。
装配顺序(classic 变体)
注意 ⑬ 和 ㉑ 是同一件事说两遍:设计系统的内容在第 13 层注入,第 21 层再补一个「别再问用户要视觉方向了」的覆盖。为什么要重复?因为第 5 层的发现层里有一条硬规则是「turn 1 必须发问卷」,而问卷里通常有品牌/方向问题。
// daemon/prompts/system.ts:603-616 —— ACTIVE_DESIGN_SYSTEM_VISUAL_DIRECTION_OVERRIDE
- Do not ask the user to pick a separate theme color, visual direction, palette,
typography mood, or direction card.
- Do not emit a direction question-form, a `direction-cards` picker, or any
visual-direction card while an active design system is present.
- If an earlier discovery answer asks to "Pick a direction for me", treat that as
already satisfied by the active design system and continue with the plan.
slim vs classic:一次带 A/B 的提示词重构
Slim core collapses the discovery layer + designer charter + their tail overrides into one charter document; the classic stack keeps the legacy layered composition until the A/B comparison signs off.
提示词重构的风险极高(改一个词可能让通过率掉 20%),所以不一次性替换,两套并存 + A/B。slim 还带两个 classic 没有的优化:
- 方向库改成索引 + 按需拉取system.ts:996-1005:「Slim carries only the id+label index and the agent pulls the chosen direction's full spec via
od tools directions --id <id>— but ONLY on filesystem runs.text_artifactruns have no tools to dereference the index… anything less tells them to bind palettes they cannot fetch.」 - 平台契约块按信号稳定性分区——见下一节。
缓存友好的分区排序:全仓库最精妙的一处工程
Head ordering differs by variant, following prompt-caching prefix rules (stable content first)… the STATIC charter opens the document… so every conversation shares the same cacheable prefix; conversation-stable overrides follow, project context after that, turn-variable blocks last. daemon/prompts/system.ts:851-867
LLM 的前缀缓存是前缀匹配的:只要前 N 个 token 一样就能命中。把最稳定的放最前,一个回合中途翻转的信号(用户说「改成 iOS 版」)只会作废缓存后缀,而不是整份提示词。
Trigger stability decides position. Metadata is fixed at project creation → the block can sit in the project-stable zone. The conversation-text signal is turn-variable (a mid-session "make it an iOS app" flips it on), so signal-only triggers defer the block to the turn-variable suffix — an early insert would break the cached prefix for every section after this line.system.ts:1023-1037
缓存命中归因
// runtimes/chat-prompt-inputs.ts:394-442
if (!isResuming) → missReason: 'new-session'
if (storedStablePromptHash === currentHash) → hit: true
if (storedStablePromptHash === null) → missReason: 'missing-stored-hash'
else → missReason: 'stable-prompt-changed'
+ changedSections: 逐段 diff
changedSections 只在真正漂移时计算。注释解释:missing-stored-hash 是没有基线的老行,在那里报告「所有段都变了」会淹没真正关心的信号。做遥测时要小心这类「技术上正确但信息量为零」的输出。
Ask 模式:轻,但不失忆
every artifact-oriented block (the ~3k-token discovery layer, direction library, device frames, the full designer charter, deck framework, media contracts, codex imagegen override, critique panel, DS visual-direction override) is gated off so the turn stays cheap. Memory, custom instructions, the active design system, attached skills, plugins, MCP tools, and the clarifying-questions surface are still composed in — Ask mode is light, not amnesiac. system.ts:939-947
省掉的是工作流,保留的是上下文。用户问「这个配色为什么这么选」时,不需要设计师宪章,但需要知道当前设计系统是什么。
三条硬规则:question-form → 品牌分支 → TodoWrite
DISCOVERY_AND_PHILOSOPHY(packages/contracts/src/prompts/discovery.ts:25-354,330 行纯提示词)是整个产品的行为脚本。它蒸馏自两个上游项目:alchaincyf/huashu-design(初级设计师模式、变体而非答案、反 AI 味、化身专家)和 op7418/guizang-ppt-skill(预飞资源读取、P0 自检、主题节奏)。
sequenceDiagram
participant U as 用户
participant A as Agent
participant H as OD 宿主
Note over A: RULE 1 · 第 1 回合
U->>A: "帮我做一个 SaaS 落地页"
A->>H: 一句短散文 + question-form + 停
Note right of A: ❌ 不读文件 ❌ 不 Bash
❌ 不 TodoWrite ❌ 不扩展思考
H->>U: 渲染成问卷卡(每题预填)
Note over A: RULE 2 · 第 2 回合
U->>A: "[form answers — discovery] brand: brand_spec …"
alt 分支 A:给了品牌/参考源
A->>A: 品牌提取五步(Bash / Read / WebFetch)
A->>H: 写 brand-spec.md
A->>U: 一句话复述系统(便于廉价纠偏)
else 分支 B:没有品牌源
A->>A: 用活跃设计系统 / 自己从方向库挑
Note right of A: ❌ 绝不再弹第二个方向问卷
end
Note over A: RULE 3 · 第 3 回合起
A->>H: TodoWrite 九步计划
loop 每完成一步
A->>H: 立刻标 completed
end
A->>A: 第7步 checklist.md(P0 必须全过)
A->>A: 第8步 五维自评,<3/5 就返工
A->>H: 第9步 交付
RULE 1 的偏执程度
your very first output is one short prose line + a
<question-form>block. Nothing else. No file reads. No Bash. No TodoWrite. No native tool calls. No extended thinking. The form is your time-to-first-byte. discovery.ts:40-44
而且它明确堵死了模型最爱找的借口:
The form applies even when the user's brief looks complete… Do not justify skipping it ("the brief is rich enough"); ask anyway. The user is fast at picking radios; they are slow at re-doing a wrong direction. discovery.ts:154
只有三种情况允许跳过:在已有设计里做微调、用户明说「skip questions / just build」、消息以 [form answers — …] 开头。
表单编写规则里的六条工程细节
default 必须写在 options 前面default that trails a long options array reaches the user late.」——纯工程细节泄漏进提示词,但完全合理。id/type/value 和 pick_direction 等分支值必须保持英文,因为 RULE 2 要按 value 匹配。platform/surface/platformTargets/target 都答「目标平台」。range、品牌色用 color、截止日期用 date、要上传就用 file——在同一张表里,不要表单发完再用散文要文件。RULE 2 的四步优先级 + 品牌提取五步
// discovery.ts:167-172 —— 分支解析顺序
1. 当前消息/附件/先前简报/URL 里已有真实品牌源 → 分支 A
2. 否则看提交的 brand 值(有 [value: ...] 用稳定值,不用可见标签)
3. brand 值是 "brand_spec" 或 "reference_match" → 分支 A
4. 否则 → 分支 B
- 定位源:附件就列出来;URL 就 WebFetch
<brand>.com/brand、/press、/about - 下载样式产物:CSS、品牌指南 PDF、截图
- 提取真值:
grep -E '#[0-9a-fA-F]{3,8}'抓 CSS 里的 hex;截图靠肉眼看排版。「Never guess colors from memory.」 - 编码成契约:写
brand-spec.md——六个 OKLch 色 token + display/body/mono 字体栈 + 3–5 条观察到的版式姿态 - 口头复述:一句话说清将用的系统,让用户能廉价纠偏
brand_spec / reference_match 但还没给源 → 要源并停下。「Do not guess a brand domain or invent tokens.」分支 B 则明确禁止二次问方向:「Do not emit any second direction-picking form… pick the best-matching direction yourself and bind it without asking.」——早期版本大概会弹「五选一方向卡」,后来发现多一次点击就多一次流失。
RULE 3 的九步计划与五维自评
1. 读活跃 DESIGN.md + 技能资源(template.html, layouts.md, checklist.md)
2. 绑定 token 到 :root(分支A 用 brand-spec.md;有设计系统用它;否则自选方向)
3. 规划章节/幻灯/屏幕清单,含平台变体与节奏(写之前先口头说一遍)
4. 把种子模板拷到项目根
5. 粘贴并填充规划好的版式
6. 用简报里的真实、具体文案替换 [REPLACE] 占位
7. 自检:跑 references/checklist.md(P0 必须全过)
8. 评审:五维雷达,任一 <3/5 就修
9. 交付
| 维度 | 拷问 |
|---|---|
| Philosophy 哲学 | 视觉姿态和要求的匹配吗(editorial vs minimal vs brutalist)?还是漂回了你最爱的默认? |
| Hierarchy 层次 | 每屏眼睛有一个明显落点吗?还是所有元素在互相竞争? |
| Execution 执行 | 排版、间距、对齐、对比——是对的,还是只是「差不多」? |
| Specificity 具体性 | 每个词、数字、图片都是这个简报专属的吗?还是混进了填充和通用数据味? |
| Restraint 克制 | 一个 accent 最多用两次、一个决定性亮点——还是三个亮点在打架? |
Deck 的「框架优先」铁律
Decks especially — framework first, content second.… copy the deck framework HTML verbatim before authoring any slide content. Do NOT write your own scale-to-fit logic, keyboard handler, slide visibility toggle, counter, or print stylesheet — every freeform attempt at this re-introduces the same iframe positioning / scaling bugs we have already fixed in the framework. discovery.ts:223
// contracts/prompts/system.ts:466-485 —— 三个分支,每个都是一次事故的化石
const isDeckProject = skillMode === 'deck' || metadata?.kind === 'deck';
const isFreeformProject = !skillMode && (!metadata || metadata.kind === 'other');
const hasSkillSeed = !!skillBody && /assets\/template\.html/.test(skillBody);
if (isDeckProject && !hasSkillSeed) 注入通用骨架;
else if (isFreeformProject && !hasSkillSeed) 注入带条件前缀的骨架;
// 有技能种子时不注入——种子自己有更有主张的框架(simple-deck 的 scroll-snap、
// guizang-ppt 的杂志版式),重复会冲突
第二个分支的条件前缀值得抄:「If — and only if — the brief reads as slides, keynote, presentation, deck, PPT, or 讲解, follow the framework below. Otherwise ignore everything in this section and continue with the freeform output you would have written anyway.」——给一段「可能不适用」的指令加上显式的适用条件,模型就不会硬套。
记忆与双环:task-brief / verify-scorecard / rule-proposal
记忆不是硬规则
Treat them as preferences and context, NOT hard rules: when they collide with the active design system tokens, the brand wins; when they collide with the active skill's workflow, the skill wins. They are still authoritative for tone, voice, terminology… never re-ask the user about something already captured here. contracts/prompts/system.ts:378
注释说明了这段措辞的用意:「what stops the model from treating remembered preferences as harder than the active design system」——防止「用户上次说喜欢深色」压过「本次品牌是浅色」。
Expanding intent this way changes only WHAT you know going in; it never shortcuts the standard build flow — you still plan with TodoWrite and still run the anti-slop / brand self-check on every artifact-producing turn.
flowchart TB
IN["用户短请求"] --> MEM{"记忆够不够
扩写成简报?"}
MEM -->|够 & rewrite=ON| CARD1["① od-card type=task-brief
替代 turn-1 问卷"]
MEM -->|不够| FORM["走 RULE 1 问卷"]
CARD1 --> BUILD["TodoWrite + 构建
【不可跳过】"]
FORM --> BUILD
BUILD --> SLOP["反 AI 味 / 品牌自检
【不可跳过】"]
SLOP -->|verify=ON & 有已验证规则| CARD2["② od-card type=verify-scorecard
daemon 程序化检查其存在"]
SLOP --> HANDOFF["交付收尾"]
CARD2 --> HANDOFF
HANDOFF --> FB{"用户纠正里
隐含可复用规则?"}
FB -->|是| CARD3["③ od-card type=rule-proposal
Keep / Edit / Discard"]
CARD3 -->|用户点 Keep| STORE[("已验证规则库")]
STORE -.->|下次注入| MEM
task-brief最关键的一句:它替代 turn-1 发现问卷,但不替代其余构建流程——「Skipping the discovery form when intent is already understood is correct; skipping TodoWrite or the anti-slop gate is not.」
verify-scorecard收尾顺序被规定死:(1) 完成反 AI 味/品牌自检并就地修复 →(2) 发记分卡 →(3) 正常交付。「Prefer fixing silently over asking.」
rule-proposal| 项目 | 记忆形态 | 用户可见性 |
|---|---|---|
| openworker | 显式 SQLite 事实 | 设置面板里能看能改 |
| Raven | EverOS 双轨(工作记忆 + 长期) | 部分可见 |
| nanobot | Dream 夜间反思 | 结果可见,过程不可见 |
| Open Design | 三张卡 + 已验证规则库 | 每一步都是可见、可改、可拒绝的界面元素 |
内容平面与质量闸门
这一部分是 OD 独一份的东西:把审美质量变成可执行的检查。
内容四平面:skills / design-templates / design-systems / craft
技能格式:原样吃下 Claude Code 的 SKILL.md
基础格式完全不改 Claude Code 的约定,OD 只在 frontmatter 里加一个可选的 od: 命名空间。目标写得很明确:「The goal: zero-config compatibility for existing Claude Code skills.」docs/skills-protocol.md:121
| 字段 | 什么都不写时的缺省推断 |
|---|---|
mode | 从 description 和正文推断,兜底 prototype |
surface | image/video/audio 条目取对应媒体,否则 web |
preview.type | html |
design_system.requires | true(要显式写 false 才不给品牌上下文) |
example_prompt | description 的第一句,截断到选择器长度 |
scenario | 从 description/正文推断,兜底 general |
.od-skills 暂存:从符号链接到真实拷贝的一次安全修复
「An earlier draft of this fix (PR #435 round 1) created a directory link pointing at the repository's live
skills/ tree. Reviewers flagged that as a write-amplification vulnerability: agents have write access to their cwd, and a Write/Edit/Bash call against .od-skills/<id>/SKILL.md resolves through the symlink and mutates the shipped resource itself.」一次
Edit 就能改坏所有项目共用的技能源文件。
flowchart LR
SRC1["内置 skills/saas-landing/"] -->|fs.cp dereference:true| STAGE
SRC2["用户根 skills/saas-landing/"] -->|同名遮蔽| SRC1
STAGE[".od-skills/saas-landing-3f2a91b0c4/
(项目私有真实拷贝)"]
STAGE --> AGENT["Agent 读 SKILL.md / assets/ / references/"]
AGENT -.->|即使 Edit 写坏了| STAGE
STAGE -.->|❌ 不会影响| SRC1
FAIL["暂存失败"] -.->|提示词里的绝对路径兜底| SRC1
SKILLS_DIR。单个技能 1–3 MB;APFS / btrfs / ReFS 上 fs.cp 走 CoW,稳态成本只是几个 syscall。dereference: truestat() 而不是 lstat() 看源根,这样把 skills/ 本身放在软链后面的环境(内容寻址挂载)也能跟过去。copy_file_range(2) 跨文件系统被拒(常见 EXDEV;容器镜像层拷到 ZFS/overlay bind mount 上是 EPERM)。Node 不自动降级,所以 OD 自己实现了递归流式拷贝。设计系统包:三文件最小契约 + 八层注入顺序
design-systems/<slug>/
├── manifest.json ← 发现元数据、来源出处、声明的包内路径
├── DESIGN.md ← 给 agent 看的规范散文(canonical)
└── tokens.css ← 编译好的语义 token 样式表(canonical)
约束:文件夹 slug 必须等于 manifest.id 且用规范化 ASCII;files.design 固定 DESIGN.md、files.tokens 固定 tokens.css;每条声明的路径必须安全、相对、存在。
富文件是缓存不是真理源:components.manifest.json 由 components.html+tokens.css 派生;design-tokens.json 由 token 契约报告派生且必须与 tokens.css 一致;tailwind-v4.css 由 tokens.css 派生。派生文件一致性由 guard 校验——防的是「有人改了 tokens.css 但忘了重生成,模型读到两套冲突的值」。
flowchart TB
L1["① 包专属 USAGE.md(或默认使用契约)"] --> L2["② 完整的 DESIGN.md 正文"]
L2 --> L3["③ import-mode 指引(声明了才有)"]
L3 --> L4["④ tokens.css"]
L4 --> L5["⑤ 紧凑组件清单
(无清单时用 components.html)"]
L5 --> L6["⑥ 富文件按需拉取索引"]
L6 --> L7["⑦ craft 工艺规则"]
L7 --> L8["⑧ 活跃技能/模板正文"]
默认使用契约值得原样引用daemon/prompts/system.ts:617:
Read DESIGN.md for visual principles, paste tokens.css verbatim into the first
<style>when it is provided, and match component shapes from the reference component manifest or fixture when available. Treat any pull-layer index as optional context for deeper inspection; do not assume those files have already been loaded.
最后半句在防一个具体的幻觉:模型看到索引就以为文件已经读过了,然后引用一个它没读过的组件。
od.design_system.sections——裁剪会产生「模型只看到色板没看到用色规则」这类断章取义。{{ design_system }} 变量替换awesome-design-md 用九段固定模板。OD 演进成:「requires at least seven substantive H2 headings for migrated packages, without prescribing their names, order, or numbering. Use headings that fit the actual system and keep their decisions synchronized with tokens.css.」
craft 的两级执法与两级严格度
| 层级 | 含义 | 谁执行 |
|---|---|---|
| Auto-checked | 接进了 linter 的规则 | lint-artifact.ts(下一章) |
| Guidance | 其余部分:agent 读、评审者用、linter 不查 | 人 |
而且 craft 文件里逐条标注了哪些是「(guidance, not auto-checked)」。为什么这很重要:如果你写一份「规则手册」但只有 30% 真的被检查,而文档假装 100% 都被检查,那么半年后没人相信这份手册。标注清楚反而让被检查的那 30% 更有权威。
| 场景 | 行为 |
|---|---|
| 运行时遇到不存在的 craft slug | 跳过,不报错——「A missing optional paragraph must not make an otherwise usable runtime bundle fail.」 |
| 仓库里 checked-in 内容引用不存在的 slug | pnpm lint:craft 和 pnpm guard 失败 |
| 故意的前向引用 | 必须登记在 craft/FUTURE_SECTIONS.md 里才算合法 |
FUTURE_SECTIONS.md 很妙:它让「计划中但还没写的段落」变成可见的、有登记的,而不是靠一个 typo 悄悄漏掉一整段提示词。
质量闸门一:lint-artifact 程序化反 AI 味
apps/daemon/src/lint-artifact.ts,1 000 行 / 46 KB。这是本仓库最有创造性的一个模块:把「一眼假」这种主观判断,落成可执行的正则 + CSS 求值。
lint-artifact.ts 十六条规则的可运行复刻(含 CSS 变量递归求值、字号折算、注释剥离)。选一个样本或直接改 HTML,实时看发现。
产物 HTML
回传给 agent 的系统提醒
<section class="slide dark"> 示例、CSS 注释里的坏规则、经三层 var() 解析出 .08em 的 .eyebrow——全部放过;而 @media 里那条 font-size:48px + letter-spacing:1px(折算只有 0.021em)被准确抓住——这正是把正则从 [^}]* 改成 [^{}]*(只匹配最内层规则)换来的。② 在「合规产物」里把
--caps-tracking:.09em 改成 .02em,all-caps-no-tracking 会立刻亮起——因为求值是穿透 token 的。③ 在「幻灯节奏」里把第 4 张的
light 改成 dark,slide-rhythm(连续 3 张同主题)会亮起。
十六条规则
| 级别 | id | 检查什么 | 行号 |
|---|---|---|---|
| P0 | purple-gradient | 渐变里出现 20 个 Tailwind violet/indigo hex 之一,或字面量 purple/violet | 131-160 |
| P0 | trust-gradient | 蓝→青「信任渐变」:蓝色系 13 个 hex × 青色系 8 个 hex 配对 | 177, 559-635 |
| P0 | ai-default-indigo | 7 个「默认 LLM accent」hex 的纯色使用(不只渐变) | 205 |
| P0 | emoji-icon | 17 个 slop emoji 出现在 <h*>/<button>/<li>/class*="icon" 里 | 228 |
| P0 | left-accent-card | 圆角卡片 + 左侧彩色边框(经典「AI 仪表盘瓦片」) | 245 |
| P0 | sans-display | h1/h2/h3 的 font-family 落在 Inter/Roboto/Arial/system-ui/SF Pro | 259 |
| P0 | invented-metric | 「10× faster」「99.9% uptime」等 5 个句式 | 272 |
| P0 | filler-copy | lorem ipsum / feature one|two|three / placeholder text | 287 |
| P0 | scroll-into-view | 用了 Element.scrollIntoView()(跨 iframe 边界会拽走宿主页面) | 296-304 |
| P0 | slide-theme-missing | deck 里 .slide 缺 light/dark/hero 主题类 | 465-479 |
| P1 | all-caps-no-tracking | text-transform: uppercase 但字距 <0.06em(含内联 style) | 306-387 |
| P1 | external-image | unsplash / placehold.co / picsum 等外链占位图 | 389-403 |
| P1 | raw-hex | :root{} 之外裸 hex >12 个 | 405-429 |
| P1 | accent-overuse | var(--accent) 在 body 里 >6 次 | 431-446 |
| P1 | slide-rhythm | 连续 3 张同主题幻灯(视觉疲劳) | 480-506 |
| P2 | missing-section-anchor | <section> 缺 data-od-id / data-screen-label | 448-463 |
真正有工程含量的那一条:ALL CAPS 字距
天真实现是 /letter-spacing:\s*([\d.]+)em/ 抓个数字。OD 做的事复杂得多:
flowchart TB
S1["① extractCssTokens(html)
收集每个作用域的 --name: value"] --> S2
S2["② buildResolvedThemes(scopes)
把全局主题作用域(:root、[data-theme=…])
组合成多套主题"] --> S3
S3["③ resolveCssVars(body, tokens)
递归解析 var(--x),最大深度 4"] --> S4
S4["④ resolveFontSizePx(decls)
同规则里的 font-size 折算成 px(root=16)"] --> J
J["判定:letter-spacing 在每套主题下、
按该字号是否 ≥0.06em 等效"]
letter-spacing: 1px 在 12px 字上够(0.083em),在 48px 字上远远不够(0.021em)。为什么要多套主题:亮色下 token 是 0.08em,暗色下可能被覆盖成 0.02em。「The body alternation is
[^{}]* (not [^}]*) so the regex matches only innermost selector { body } rules. With [^}]*, an outer @media (...) { .display { font-size: 48px; text-transform: uppercase; … } } matches as a single rule whose selector is the @media wrapper… the same-rule font-size is lost, and the check falls back to the lenient inherited-size path that accepts 1px tracking on a 48px heading.」
三层假阳性防护 + 有理由的阈值
<section class="slide"> here」),不剥会误报。/* .eyebrow { text-transform: uppercase; } */ 浏览器不渲染,但规则形状的正则会匹配。declarationLaundersIndigo / isTokenShapedDeclaration:把 indigo 定义成 token 是合法的,直接当 accent 用不是。Allow up to ~12 raw hex values outside
:root. Device chrome (mobile-app frame: bezel gradient, side rails, status icons) has legitimate hardware-specific values in the 8–10 range; raise the threshold so seed templates pass without ceremony. lint-artifact.ts:415-419
12 这个数不是拍脑袋的,是「手机边框种子模板合法用到 8–10 个」倒推的。每个阈值都应该能说出它是从哪个真实场景倒推的,否则半年后没人敢动它。
反馈回路:给 agent 的报错必须自带修复动作
renderFindingsForAgent()lint-artifact.ts:519-537 有三个要点:按严重度排序(P0 在前)、明确要求重发修正版而不是写解释(「the user has the previous version already」)、每条都带 fix——只给 message 模型会去猜,猜错就多一轮。
P0 命中不阻止落盘。这是对的——硬阻断会让「模型死循环修不好」变成「用户什么都拿不到」。闸门的作用是推动改进,不是阻止交付。
质量闸门二:Design Jury 五陪审员评审剧场
产品名 Design Jury,内部代号 Critique Theater。代码路径 apps/daemon/src/critique/、web 侧 components/Theater/、SSE 频道 critique.*、环境变量 OD_CRITIQUE_*——用户可见名来自单个 i18n key critiqueTheater.userFacingName,所以改产品名不用动代码。
| 角色 | 评什么 | v1 权重 |
|---|---|---|
| Designer | 版式、构图、层次——「这东西好看且平衡吗」 | 0.0 |
| Critic | 是否真的满足简报;对比度、字重、可读性 | 0.4 |
| Brand | token 合规、语气、品牌色使用 | 0.2 |
| Accessibility | WCAG、焦点环、语义结构、alt 文本 | 0.2 |
| Copy | 语气、简洁度、错误文案质量 | 0.2 |
Designer is weighted at zero in v1 because their dimensions are aesthetic preferences rather than ship gates. The slot exists so the Designer's qualitative notes still travel into the transcript, and a future config release can bump the weight without changing the schema. docs/critique-theater.md:65-68
保留席位、权重归零——让定性意见进入记录,但不让主观审美卡住发布。这是一个很成熟的产品决策。
// packages/contracts/src/critique.ts:58-73 —— 默认配置
weights: { designer: 0, critic: 0.4, brand: 0.2, a11y: 0.2, copy: 0.2 },
maxRounds: 3, scoreScale: 10, scoreThreshold: 8.0,
perRoundTimeoutMs: 90_000, totalTimeoutMs: 240_000,
parserMaxBlockBytes: 262_144, fallbackPolicy: 'ship_best',
maxConcurrentRuns: 4, enabled: false,
// critique.ts:48-54 —— 浮点比较的教科书处理
.refine((cfg) => cfg.scoreThreshold <= cfg.scoreScale + 1e-9, …)
最关键的实现决定:一个会话,不是五个进程
The orchestrator does not spawn extra processes per panelist. Each panelist is a turn in the same agent session, separated by
<PANELIST role="...">tags… This keeps the operational contract identical to a normal generation: same auth, same env, same logs. docs/critique-theater.md:76-82
All five panelists are turns in the same conversation, which keeps the model context coherent and prevents the "panelist disagrees with itself across processes" failure mode.
flowchart TB
ART["Agent 发出 artifact"] --> R1["Round 1:五位陪审员各自打分
(同一会话的五个回合)"]
R1 --> COMP["composite = designer×0 + critic×0.4
+ brand×0.2 + a11y×0.2 + copy×0.2"]
COMP --> GATE{"composite ≥ 8.0 ?"}
GATE -->|是| SHIP["Shipped at round N, composite X.X"]
GATE -->|否| CNT{"round < maxRounds(3) ?"}
CNT -->|是| REV["发出轮次摘要 → Agent 修改 → 下一轮"]
REV --> R1
CNT -->|否| FB["fallbackPolicy"]
FB --> SB["ship_best(默认):取 composite 最高的那轮"]
FB --> SL["ship_last"]
FB --> FL["fail"]
Shipped / Below threshold / Timed out / Interrupted / Degraded。Interrupted 的文案是单独写的——「so the user is not told the run shipped when it did not」。四级开关解析器
| 优先级 | 层 | 说明 |
|---|---|---|
| 1(最高) | 技能级 od.critique.policy | required 强制开、opt-out 强制关、opt-in 只在 M2+ 开 |
| 2 | 项目级覆盖 | localStorage(会话内 UI)+ 对项目做读-合并-写 |
| 3 | OD_CRITIQUE_ENABLED | 高级用户 / CI fixture |
| 4(最低) | 灰度阶段默认 | M0/M1 = false,M2 = opt-in 技能为 true,M3 = 全开 |
技能作者的经验法则:产出确定性产物的技能(如 od-export-pdf)通常 opt-out;生成全新设计输出的(magazine-poster、saas-landing)设 required。
跨 25 条第三方 CLI 的一致性纪律
| 降级原因 | 起因 | 处置 |
|---|---|---|
malformed_block | 适配器发的 <CRITIQUE> 块解析器不认 | 跑一致性测试 tests/critique-conformance.test.ts |
oversize_block | 超过 parserMaxBlockBytes(256 KB) | 通常是模型跑飞,重试或加预算 |
adapter_unsupported | 该适配器被标 critique:degraded,24h TTL | 等 TTL 过期,或调 clearDegraded(adapterId) |
protocol_version_mismatch | 适配器协议版本旧 | 升级适配器或钉住协议协商 |
missing_artifact | run 结束但没有 artifact 主体 | 「Almost always a prompt bug; check the skill template.」 |
The conformance harness runs every adapter prerelease against 10 brief templates. If an adapter drops under the 90% shipped or 95% clean-parse thresholds for two consecutive cycles, it gets marked
critique:degradedfor 24h. The mark auto-clears on the next clean cycle.…globally during M3 after ≥ 90% of production adapters maintain conformance for 14 consecutive days.
可回放:每次 run 写一份结构化 .ndjson(可选 gz)。Replay 支持 Instant / Live / {intervalMs: N} / Paused(暂停后从光标继续,不重新冲刷已发事件),J/K 逐轮跳,Esc 退出。
插件与原子:atoms + pipeline + 封闭的 until 词汇表
my-plugin/
├── open-design.json ← 必需:市场元数据 + inputs + pipeline + capabilities
├── SKILL.md ← agent-skill / scenario 类型必需,其他类型可省
├── README.md ← 可选
├── preview/ ← 可选:index.html / poster.png(视觉类强烈建议)
└── examples/ ← 可选
核心字段:specVersion(当前 1.0.0)· name(稳定 ID)· version(semver)· od.kind(skill/scenario/atom/bundle)· od.taskKind(new-generation/figma-migration/code-migration/tune-collab)· od.mode · od.capabilities[] · od.inputs[]。
一条重要的默认:od.capabilities[] 要声明最小集——受限安装默认只给 prompt:inject。
| 官方插件层级 | 数量 | 内容 |
|---|---|---|
scenarios/ | 13 | od-default · od-design-refine · od-figma-migration · od-code-migration · od-react-export · od-nextjs-export · od-vue-export · od-media-generation · od-new-generation · od-tune-collab · od-plugin-authoring · od-share-to-community · od-web-effect-extractor |
image-templates/ | 45 | 一次性图像提示 |
video-templates/ | 64 | HyperFrames / Seedance / Veo 动效模板 |
design-systems/ | 143 | 品牌 DESIGN.md 包成插件 |
atoms/ | 13 | 可复用原子能力体 |
examples/ | 183 | 可重混的参考产出 |
原子:daemon 暴露给插件的能力单元
A plugin assembles atoms into ordered stages… The daemon is responsible for resolving each atom into a system-prompt fragment, tool gating, and (when applicable) GenUI surface declarations. Plugins never own the atom implementations; they only reference them by id. docs/atoms.md:1-8
| id | 干什么 | 适用 task kind |
|---|---|---|
discovery-question-form | 第 1 回合问卷 | new-generation, tune-collab |
direction-picker | 定稿前 3–5 个方向选择 | new-generation, tune-collab |
todo-write | TodoWrite 驱动的计划 | 全部 |
file-read / file-write / file-edit | 项目 cwd 的文件操作 | 全部 |
research-search | Tavily 支撑的浅层调研 | new-generation |
media-image / media-video / media-audio | 通过配置的 provider 生成媒体 | new-generation, tune-collab |
live-artifact | 创建/刷新 live artifact | new-generation, tune-collab |
connector | Composio 连接器工具调用 | new-generation, tune-collab |
critique-theater | 五维面板评审,发出驱动收敛的 critique.score 信号 | 全部 |
code-import | 克隆/读取已有仓库 | code-migration |
design-extract | 从源码 / Figma / 截图提取设计 token | code-migration, figma-migration |
figma-extract | 提取 Figma 节点树 + token + 资源 | figma-migration |
token-map | 把提取的 token 映射到活跃设计系统 | code-migration, figma-migration |
rewrite-plan / patch-edit / diff-review | 多文件重写 / 小步补丁 / 可评审 diff | code-migration, tune-collab |
build-test | 跑 build/typecheck/tests,产出收敛信号 | code-migration |
handoff | 把产物推给下游(cli / cloud / desktop) | tune-collab |
flowchart TB
M["插件 manifest
od.pipeline.stages[*].atoms[]"] -->|①解析| PS["PipelineStage[]
plugins/pipeline.ts"]
PS -->|②run 开始前| BODIES["解析内置原子指令体
plugins/atom-bodies.ts"]
BODIES --> BLOCK["渲染成 ## Active stage 提示词块"]
BLOCK --> RUN["③ pipeline-runner.ts 逐阶段走"]
RUN --> E1["发 pipeline_stage_started SSE"]
E1 --> W["④ 向 atoms/registry.ts 的 worker
要 daemon 可观测的信号"]
W --> AUDIT["⑤ 往 run_devloop_iterations 写一行审计"]
AUDIT --> E2["发 pipeline_stage_completed + 信号"]
E2 --> RUN
file-write)只能用宽松兼容信号,因为「the daemon has no independent observation for that tool action」——不假装能观测一切。封闭的 until 词汇表
| 信号 | 由谁发出 |
|---|---|
critique.score | critique-theater 原子 |
iterations | 内建的每阶段计数器 |
user.confirmed | confirmation GenUI 面解析时 |
preview.ok | live-artifact 预览流水线 |
build.passing / tests.passing | build-test 流程 |
od plugin doctor reports them.」docs/atoms.md:86-87如果插件能写任意 JS 作为收敛条件,你就得沙箱它、审计它、担心无限循环、担心它读环境变量。封闭词汇表把「插件能表达什么」限制在 daemon 能保证的语义内——代价是表达力受限,换来的是插件市场可以开放安装。
原子的晋升路径
不要一上来就把新能力做成内置原子。docs/atoms.md:89-100 定义了一条路:① 先作为树外插件实现 → ② 形状稳定后加内置原子 + 往 FIRST_PARTY_ATOMS 追加一行 + 有真实可观测信号时才注册 worker → ③ 同一个 PR 更新文档和 spec 表格 → ④ 通过 pipeline 引用 / GET /api/atoms / od atoms list / od plugin doctor 触达。
边界与总结
你放弃了权限闸门,就必须补齐外围。以及:三个最独特的设计,三处必须知道的取舍。
安全边界:loopback / SSRF / 桌面 HMAC / .od-skills 拷贝
| CLI | headless 权限姿态 |
|---|---|
| Claude | --permission-mode bypassPermissions |
| Cursor | --force + 能力门控的 --trust |
| Devin | --permission-mode dangerous --respect-workspace-trust false |
| Qoder / Trae | --yolo |
| Copilot | --allow-all-tools |
| DeepSeek | --auto |
| Amp | --dangerously-allow-all |
| OpenCode | --dangerously-skip-permissions,且仅当 help 探测确认该 flag 存在时 |
| Codex | macOS/Linux 默认 workspace-write + 网络;Windows/WSL 或显式 OD_CODEX_SANDBOX=danger-full-access 用 danger-full-access |
The effective project cwd is an execution root, not a uniform Open Design sandbox, and external-directory flags can widen a CLI's reach… users must treat these runs as trusted agent execution with the authority shown by the selected definition. docs/agent-adapters.md:442-465
为什么必须这样?「the daemon runs agent CLIs without a TTY, so it must not rely on an interactive tool-approval prompt to make progress.」没有 TTY 就没法交互批准,交互批准会直接把 run 挂死。
放弃了执行层的闸门,安全预算就全花在边界上:
边界一:默认 loopback
daemon 默认绑 127.0.0.1;LAN 暴露需要同时设 OD_BIND_HOST 和 OD_ALLOWED_ORIGINS;连接器凭证和 live-artifact 预览路由无论如何都保持 loopback-only——即使公开部署也不放开。
边界二:SSRF——默认封内网,opt-out 极其严格
默认封锁解析到私有/内部地址的 provider base URL——RFC1918、link-local、CGNAT、云元数据 IP(169.254.169.254 那类,能偷 IAM 凭证)。但真实用户确实有内网网关(VPN 里的 LiteLLM、Ollama),所以有 OD_ALLOWED_INTERNAL_HOSTS:
| 性质 | 规则 |
|---|---|
| 严格 opt-in | 默认空 |
| 精确主机匹配 | 不做子域名/子串匹配 |
| 格式宽容 | 接受 host:port 或完整 URL,归约到 hostname;IPv6 必须 [fd00::1] |
| 范围受限 | 只作用于你自己配置的 provider 端点(连接测试、模型发现、BYOK 聊天) |
| 不放宽下游 | 刻意不放宽上游响应里返回的下载 URL——那些仍然被封 |
| 错误项丢弃 | 畸形条目、CIDR 记法(不支持) 被丢弃并告警,而不是静默信任 |
边界三:桌面文件夹导入的短寿命单次 HMAC 令牌
sequenceDiagram
participant U as 用户
participant M as Electron main(可信)
participant R as Renderer(沙箱)
participant D as Daemon
U->>M: 点「导入文件夹」
M->>U: 原生文件夹选择器
U->>M: 选中 /Users/me/work/site
M->>M: 铸造短寿命、单次 HMAC 令牌
M->>R: 令牌 + 路径
R->>D: POST /api/import/folder(带令牌)
D->>D: 校验 HMAC + 规范化路径
拒绝落在自己托管存储内的导入
D->>D: 打上服务端控制的「可信选择器」标记
D-->>R: 项目创建成功
Note over D: 之后每次文件访问都对该外部根做安全路径解析
desktop-auth.ts 和 import-export-routes.ts。边界四:沙箱 iframe 按需开启最小特性
沙箱 iframe,无宿主同源访问;每个面只 opt-in 它需要的 sandbox 特性(下载、弹窗)。切换 URL/srcDoc 渲染模式时两个 frame 都保持挂载避免重载闪烁;消息处理器校验发送方 iframe;需要来自活跃 frame 的信号会再次核对活跃窗口——最后两条防的是页面里有多个 iframe 时,一个后台 frame 冒充活跃 frame 发消息。渲染模式决策单独放在 apps/web/src/components/file-viewer-render-mode.ts。
边界五:Windows 命令行长度的三重守卫
| 守卫 | 时机 | 检查什么 |
|---|---|---|
checkPromptArgvBudget | bin 解析前(快速) | 原始组装提示词的字节数 vs maxPromptArgBytes(DeepSeek 声明 30 000) |
checkWindowsCmdShimCommandLineBudget | buildArgs 之后 | 解析出 .cmd/.bat shim 时,用平台层相同的逐参数引号翻倍规则重算 cmd.exe /d /s /c "<inner>" |
checkWindowsDirectExeCommandLineBudget | buildArgs 之后 | 解析出非 shim 的 .exe 时,用 libuv quote_cmd_arg 规则(每个 " 变 \",紧邻引号的反斜杠翻倍)重算 |
两个 Windows 守卫在给定解析上互斥。三者一起抓的是:原始字节数没超,但引号密集的提示词(代码块、JSON 形状的技能种子)展开后超过 CreateProcess 的 32 767 字符 lpCommandLine 上限。三者发同一个可行动的 AGENT_PROMPT_TOO_LARGE 错误,而且三者都有单测(超长 + 短提示词分支、两条 Windows 路径的引号密集回归、互斥性检查),「so the guards can't silently regress」。
.od-skills 拷贝),而不是执行上。对个人本地使用,这是合理的;对团队共享部署,这需要非常认真的评估。
功能特性拾遗
od CLI 与 MCP 服务端:把 OD 变成别人的工具
OD 出货三种形态:skills、CLI、MCP server。od mcp install <agent> 一行装进 16+ 个 CLI 的配置,然后:
od project list --json
od files list <project-id> --json
od files read <project-id> <relative-path>
od plugin list --json
od skills list --json
Why MCP? Exporting and re-attaching a zip every iteration breaks flow. MCP exposes the design source directly — the agent always sees the live file, not a stale export.
/usr/bin/od 是系统的八进制转储工具,会在 PATH 上盖过 Open Design 的 od。三种应对:桌面 App 的设置 → MCP server 给一段用绝对路径的片段;install.sh 存在的理由之一就是「fails fast if your shell resolves a non-Open-Design od binary」;文档里三处提醒。教训:选命令名前先
which <name> 一遍常见系统。
一次值得尊敬的架构回归
The former direct-Anthropic fallback was replaced by the
byok-opencodeprofile… There is no daemon-owned fallback loop and no daemon implementation ofRead/Write/Edittools, and this profile is not an automatic recovery target for failed local agents. docs/agent-adapters.md:206-218
早期版本大概真的在 daemon 里实现过一个「直连 Anthropic + 自己实现 Read/Write/Edit」的兜底循环。现在被彻底删掉了——因为那违背了「我们不实现 Agent 循环」这条根本主张。一旦你开了这个口子,它会慢慢长成第二个(更差的)Agent。
没有跨 agent 兜底链
A chat request explicitly names its agent, and a crash, auth failure, timeout, or invalid invocation remains a failure for that run… the daemon does not silently — or through a dedicated one-click fallback action — move the request to Claude, another detected CLI, or BYOK OpenCode.
只有两个窄规则:运行前默认(agentId 省略时用配置的 agent,不可用就用第一个可用的——「This chooses an agent before a run starts; it is not failure recovery」),和过期会话恢复(清掉同一个 agent 的陈旧会话,用完整 transcript 重新播种——「That recovery never changes agent families」)。
为什么反直觉但正确:自动切换 agent 会让计费、鉴权、输出风格全部悄悄改变,用户根本不知道刚才那份产物是谁做的。
Normal Artifact vs Live Artifact
| 概念 | 定义 | _Avoid_ |
|---|---|---|
| Normal Artifact | 由一个产物入口文件 + 一份产物清单表示的项目设计产出 | live artifact、通用文件上传 |
| Live Artifact | 可刷新的项目设计产出,存成 live-artifact 记录,带源数据和预览状态 | normal artifact、静态产物 |
| Artifact Entry File | 打开/渲染一个 normal artifact 的主项目文件 | 支持文件、资源、旁挂文件 |
| Artifact Manifest | 把项目文件标记为 normal artifact 的旁挂元数据,记录 kind / renderer / exports / entry | live-artifact 文档、项目元数据 |
| Active Project | 用户最近交互的项目,MCP 工具在未指定项目时可用它 | latest project、default project |
HyperFrames、导出矩阵与 mock agent
mocks/mock-agent.mjs + recordings/ + golden/。守则:「replay a mock CLI trace from mocks/ instead of burning provider budget」——维护 25 条适配器的必需品。总结:三个最独特的设计与三处取舍
三个最独特的设计
RuntimeAgentDef 里 45 个字段,只有 buildArgs 一个函数,而且是纯的。探测、启动、调用、取消、解析全在共享引擎里。结果:新增一条已知 wire format 的 CLI = 一个对象字面量 + registry 一行,最小的定义只有 629 字节;九条 ACP 适配器共用一个传输层;引擎里零个 per-agent 分支。
它的克制同样关键:契约里没有 nativeSkillLoading、没有 skillInjectionStrategy、没有 capabilities() 方法、没有 特性门表。所有想往定义里塞行为的诱惑都被文档明确拒绝了。
lint-artifact + Design Jury 双层这是本系列十个项目里独一份的能力。别的项目做「工具调用是否正确」的校验,OD 做的是「这东西看起来像不像 AI 拉的」的校验:
- 第一层
lint-artifact.ts(1 000 行):16 条规则,带 CSS 变量递归求值(深度 4)、主题作用域解析、字号折算的字距判定、三层假阳性防护。规则表和craft/anti-ai-slop.md用注释里的「keep in sync」绑成一对。 - 第二层 Design Jury:五位陪审员在同一个 CLI 会话里以五个回合评分,加权合成,≥8.0/10 才发货,最多 3 轮。跨 25 条第三方 CLI 用「连续 14 天 ≥90% 一致性」作为全量开关。
而且两层都不硬阻断:P0 命中不阻止落盘、评审不达标走 ship_best。这是「有闸门但不锁死用户」的成熟取舍。
composeSystemPrompt 把 20+ 个片段按变化频率分成四带(全局静态 → 会话稳定 → 项目稳定 → 回合可变),并且:
- 触发信号的稳定性决定块的位置:项目创建时固定的元数据信号可以插在项目稳定带;对话中途可能翻转的文本信号必须推到回合可变后缀,因为「an early insert would break the cached prefix for every section after this line」。
- 配套
describeStablePromptCache()做命中归因,未命中时逐段 diff 出是哪一段漂移了,并且刻意不在「无基线」的情况下报告全量变化以免淹没信号。 - slim 变体把方向库改成「索引 + 按需拉」——但只在 filesystem 执行画像下,因为 text_artifact 档没有工具去解引用索引。
三处必须知道的取舍
它在这十个项目里的位置
| 维度 | 其余九个编程/任务 Agent | Open Design |
|---|---|---|
| 主循环 | 各自实现 | 不实现,委托给 25 个 CLI |
| 工具系统 | 自己定义 40–74 个工具 | 零个,用被托管 CLI 的原生工具 |
| 权限模型 | 从 safe-by-construction 到五档模式 | 无,全部委托 + 边界防御 |
| 上下文工程 | 压缩、裁剪、双轨记忆 | 分层组装 + 缓存分区(20+ 层) |
| 质量保证 | 测试通过 / 编译通过 | 审美 linter + 五陪审评审 |
| 扩展机制 | 插件 / MCP / 技能 | 四平面:skills + templates + design-systems + craft |
| 交付物 | 代码变更 | HTML/PDF/PPTX/MP4 真实文件 |
| 产品形态 | CLI / TUI / IDE 插件 | 本地优先桌面 App + MCP 服务端 + Docker |
它给出的答案不是再造一个 Agent,而是造一整套它们都能读懂的文件系统(技能 + 模板 + 品牌 + 工艺),加上一层它们都能被同一套数据规格描述的插座,再加上两道它们都必须通过的审美闸门。
这个答案对不对,三个月 82k star 已经给了初步的市场判断。但它真正的工程价值在于:它证明了「适配器即数据」这条路能撑住 25 个异构 CLI,而且证明了「设计质量」这件看起来纯主观的事,有相当大一部分是可以被程序化执法的。