| 1 | # 运行时 API 与集成契约 |
| 2 | |
| 3 | > 英文原文:[RUNTIME_API.md](../RUNTIME_API.md)。 |
| 4 | > 最后与英文同步日期(last synced with English revision):2026-09-29。 |
| 5 | |
| 6 | `codewhale app-server` 是本地运行时的规范 API 与控制面。本地 SDK、移动端/远程控制客户端 |
| 7 | 以及编辑器集成都与它对话,而不是去抓取终端输出。它提供完整的 HTTP/SSE 运行时 |
| 8 | API(`/v1/*`)、基于 stdio 的 JSON-RPC 控制传输,以及面向手机的移动端页面。 |
| 9 | `codewhale doctor --json` 提供机器可读的健康状态,`codewhale serve --acp` 则通过 stdio |
| 10 | 使用 Agent Client Protocol 与 Zed 这类编辑器对话。 |
| 11 | |
| 12 | `codewhale serve --http` / `serve --mobile` 仍作为 `codewhale app-server --http` / |
| 13 | `--mobile` 的**兼容别名**存在;两者启动的是同一个服务器。新的集成应当以 |
| 14 | `app-server` 为目标。 |
| 15 | |
| 16 | `codewhale exec` 是另一条一次性的无头 worker 路径(stream-json、fleet worker 子进程、 |
| 17 | CI 原语)。它不属于本 API,但共享同一个运行时、提供商/模型解析、权限配置 |
| 18 | 与事件词汇表。 |
| 19 | |
| 20 | 本文档是原生工作台应用(以及其他本地监督进程)嵌入 Codewhale 引擎的稳定集成契约。 |
| 21 | |
| 22 | ## 架构 |
| 23 | |
| 24 | ``` |
| 25 | local supervisor / SDK / automation harness |
| 26 | │ |
| 27 | ├─ codewhale app-server --http → HTTP/SSE runtime API (/v1/*) [canonical] |
| 28 | ├─ codewhale app-server --mobile → runtime API + mobile control page |
| 29 | ├─ codewhale app-server --stdio → JSON-RPC control transport over stdio |
| 30 | ├─ codewhale app-server --socket → same JSON-RPC over a unix domain socket (desktop daemon) |
| 31 | ├─ codewhale doctor --json → machine-readable health & capability |
| 32 | ├─ codewhale serve --acp → ACP stdio agent for editors such as Zed |
| 33 | ├─ codewhale serve --mcp → MCP stdio server |
| 34 | ├─ codewhale serve --http/--mobile → legacy aliases for `app-server --http/--mobile` |
| 35 | └─ codewhale exec [args] → one-shot headless worker (stream-json) |
| 36 | ``` |
| 37 | |
| 38 | 引擎以仅限本地的进程运行。所有 API 默认绑定到 `localhost`。没有托管中转, |
| 39 | 不代管提供商令牌,也不泄漏任何密钥。 |
| 40 | |
| 41 | 关于线程或回合所做之事的只读记录,请参阅 |
| 42 | [`docs/RECEIPTS.md`](../RECEIPTS.md):CLI 上的 `codewhale receipts`,以及下文 |
| 43 | **线程**下的 `/receipt` 路由。 |
| 44 | |
| 45 | ## 运行时 API 入口 |
| 46 | |
| 47 | | 入口 | 传输 | 用途 | |
| 48 | |---|---|---| |
| 49 | | `codewhale web [--port 7878]` | 在 `127.0.0.1:7878` 上的 HTTP/SSE + 内嵌客户端 | 一等公民的仅回环浏览器客户端;打开默认浏览器 | |
| 50 | | `codewhale app-server --http` | 在 `127.0.0.1:7878` 上的 HTTP/SSE | 完整的 `/v1/*` 运行时 API(规范入口) | |
| 51 | | `codewhale app-server --mobile` | 回环上的 HTTP/SSE + `/mobile` | 运行时 API + 本地移动端控制页 | |
| 52 | | `codewhale app-server --stdio` | 基于 stdio 的 JSON-RPC 2.0 | 本地 SDK / 控制探针(不监听端口) | |
| 53 | | `codewhale app-server --socket [--socket-path P]` | 基于权限为 `0600` 的 unix 域套接字的 JSON-RPC 2.0 | 桌面守护进程:多客户端、对端 uid 校验、`daemon/attach` 认领握手(macOS/Linux;Windows 命名管道已预留但未实现) | |
| 54 | | `codewhale app-server` | 在 `127.0.0.1:8787` 上的 HTTP | 旧式进程内 app-server(`/healthz`、`/thread`、`/app`、`/prompt`、`/jobs`);`/prompt` 与 `/thread` 消息会通过运行时桥执行真实回合。它没有直接的 `/tool` 路由:工具只在 Engine 回合内部、在 Engine 的工具目录与审批姿态下运行。这个旧式服务器不呈现审批:它的桥只转发文本增量和回合的完成,并且没有决策路由,所以一个受审批门控的调用会一直等不到答复。受审批门控的工作请通过运行时 API 驱动(`/v1/threads/*` 事件与 `POST /v1/approvals/{approval_id}`) | |
| 55 | | `codewhale serve --http` / `--mobile` | 与 `app-server --http`/`--mobile` 相同的服务器 | 兼容别名 | |
| 56 | |
| 57 | `app-server --http` 与 `--mobile` 启动的是历史上经由 `serve --http` 访问的同一个 |
| 58 | 成熟运行时 API 服务器——路由与行为都没有变化,因此下文记录的每个端点在这两个入口上 |
| 59 | 都完全一致。运行时 API 令牌按 `--auth-token`、`CODEWHALE_RUNTIME_TOKEN`、 |
| 60 | `DEEPSEEK_RUNTIME_TOKEN` 的顺序读取;只有在绑定回环地址时才能使用 |
| 61 | `--insecure-no-auth`。`serve` 兼容别名保留各自的 `--insecure` 标志。 |
| 62 | 旧式的进程内 `codewhale app-server` 在绑定非回环主机之前,同样要求显式的 |
| 63 | `--auth-token` 或 `CODEWHALE_APP_SERVER_TOKEN`;它生成的一次性 `cwapp_*` 令牌 |
| 64 | 只能用于回环。 |
| 65 | |
| 66 | ### 工作区文件建议 |
| 67 | |
| 68 | `GET /v1/workspace/files/search?query=runtime&limit=20` 通过既有的、需认证的 |
| 69 | `/v1/*` 路由返回 `{"paths":["src/runtime.rs"]}`。它只搜索服务器配置的工作区, |
| 70 | 不搜索线程的工作区或进程的当前目录。不接受任何工作区/路径覆盖参数。 |
| 71 | 响应包含以 `/` 分隔的工作区相对文件路径,绝不包含文件内容、绝对路径或目录。 |
| 72 | |
| 73 | - `query` 是字面的文件名/路径片段,不带 `@` 前缀,最多 256 个 UTF-8 字节。缺失、 |
| 74 | 为空或只含空白的查询会在不遍历文件系统的情况下返回空列表。没有匹配项时同样 |
| 75 | 返回空列表。 |
| 76 | - `limit` 默认为 20;可接受的值是 1–100。非法的 limit、超长的查询以及未知的查询参数 |
| 77 | 都返回 HTTP 400。 |
| 78 | - 匹配复用 TUI 模糊 `@file` 发现/排序:先按大小写不敏感的路径前缀匹配,再按子串匹配, |
| 79 | 每组内按字母顺序排列。这不是 glob、子序列、内容或语义搜索,也不应用 TUI 的 |
| 80 | 个人 frecency 加权。 |
| 81 | - 发现过程共享输入区的忽略策略,包括 `.ignore` 与 `.deepseekignore`、始终可发现的 |
| 82 | AI 目录,以及有界的、被隐藏或被 gitignore 的本地引用兜底。与 TUI 中一样, |
| 83 | 对 `.agents`、`.claude`、`.cursor`、`.deepseek` 的特殊遍历会有意绕过忽略规则。 |
| 84 | 忽略文件不是保密边界。 |
| 85 | - 不遍历目录符号链接。文件在被应用结果上限之前先做规范化并过滤,确认其处于工作区之内; |
| 86 | 指向外部的符号链接与失效的文件符号链接都会被忽略。工作区内的文件符号链接 |
| 87 | 可以按其相对名称出现。建议结果是文件系统的一份快照,并不构成之后读取某个文件的授权; |
| 88 | 消费方在打开文件时必须重新校验。 |
| 89 | |
| 90 | 发现过程在异步执行器之外运行,使用共享的默认深度 10、最多 20,000 个候选, |
| 91 | 以及一个协作式的两秒发现预算。结果是最尽力而为的,不是穷尽列表;一次缓慢的文件系统 |
| 92 | 操作可能在该预算之后才完成。每次请求都会重新扫描;没有新的索引或缓存。 |
| 93 | 这个只读端点不会改动会话,也不会改动被固定下来的模型提示词/工具前缀。 |
| 94 | |
| 95 | ### 工作区文件与会话工件 |
| 96 | |
| 97 | 原生客户端(GPUI 桌面的 Files 与 Preview 模块)通过三条需认证的路由浏览并编辑 |
| 98 | 服务器配置的工作区。它们直接读写工作区;没有第二套文件存储、缓存或索引, |
| 99 | 也没有路径覆盖:工作区根目录是唯一的根目录。 |
| 100 | |
| 101 | - `GET /v1/workspace/files?path=<dir>&limit=<1-2000>` 列出单个目录。 |
| 102 | `path` 是工作区相对路径,以 `/` 分隔;空值或 `.` 表示根目录。 |
| 103 | 每个条目携带 `name`、`path`、`kind`(`file`、`directory`、`symlink`、 |
| 104 | `other`),文件还携带 `size` 与 `modified`(RFC 3339)。目录排在最前, |
| 105 | 然后名称按大小写不敏感的顺序排列。`limit` 默认为 200;`truncated` 报告是否被截断。 |
| 106 | `.git` 永不被列出或提供,符号链接只按名称列出:它们永不被跟随,因此 |
| 107 | `path=<link>` 返回 403。 |
| 108 | - `GET /v1/workspace/files/read?path=<file>&offset=<bytes>&limit=<1-4194304>` |
| 109 | 返回一个常规文件的一个字节窗口,并给出 `size`、`revision`(**整个**文件的 |
| 110 | SHA-256 十六进制值,不是该窗口的)、`modified`、 |
| 111 | `offset`、`bytes`、`truncated`、`encoding` 与 `content`。文本窗口为 |
| 112 | `utf-8`;含 NUL 字节、非法 UTF-8 或截断多字节字符的窗口为 `base64`。 |
| 113 | `limit` 默认为 256 KiB。大于 16 MiB 的文件以 413 拒绝;目录是 400; |
| 114 | 链接是 403;文件缺失是 404。 |
| 115 | - `PUT /v1/workspace/files`,请求体为 `{"path", "content", "encoding"?, |
| 116 | "expected_revision"?}`,经由 Fleet 工件所使用的同一个受限打开器 |
| 117 | 原子地写入一个文件。`encoding` 为 `utf-8`(默认)或 `base64`; |
| 118 | 超过 4 MiB 的请求体返回 413。新建文件**不需要** |
| 119 | `expected_revision`(并会在工作区内创建缺失的父目录);覆盖写入则要求 |
| 120 | 提供编辑所依据的那次读取得到的 `revision`,过期或缺失时返回 409, |
| 121 | 并在错误消息中带上当前 revision,以便客户端重新读取并合并。这是乐观并发, |
| 122 | 不是锁:两个写入者在检查与写入之间竞争时仍可能交错。响应携带 `path`、`size`、 |
| 123 | `revision`、`created` 与 `written_at`;新文件返回 201,其他情况返回 200。 |
| 124 | 经由链接写入、写入 `.git`、或写入目录都会被拒绝。 |
| 125 | |
| 126 | 每个路径在任何文件系统访问之前都会被校验:绝对路径、 |
| 127 | 反斜杠、`.` 或 `..` 组成部分都返回 400,并且沿途的每一级目录都在不跟随链接的前提下 |
| 128 | 打开(Unix 上逐组件 `O_NOFOLLOW`,Windows 上检查 reparse point)。这些路由与其他 |
| 129 | 任何 `/v1/*` 路由一样使用运行时 bearer 令牌;它们不查询模型的工具权限姿态, |
| 130 | 因为调用方是已认证的操作者,而不是模型。 |
| 131 | |
| 132 | 会话工件是会话记录为 `ArtifactRecord` 的超大工具输出 |
| 133 | (`crates/tui/src/artifacts.rs`),存储在 |
| 134 | `sessions/<id>/artifacts/` 下: |
| 135 | |
| 136 | - `GET /v1/sessions/{id}/artifacts` 列出某个已保存会话携带的记录: |
| 137 | `id`、`kind`、`tool_call_id`、`tool_name`、`created_at`、`byte_size`、 |
| 138 | `preview` 以及相对于会话的 `path`。 |
| 139 | - `GET /v1/sessions/{id}/artifacts/{artifact_id}?offset=&limit=` 读取单个 |
| 140 | 工件,使用与工作区文件读取相同的窗口、`revision` 与 `encoding` 契约。 |
| 141 | 存储路径为绝对路径或逃出会话目录的记录返回 403;记录对应的文件已消失时返回 404。 |
| 142 | |
| 143 | 这些路由只提供 SavedSession 索引到的内容(外加不可变的图片证据)。运行时回合的 |
| 144 | 溢出(spill)通过下文的回合路由读取。未绑定的运行时线程,其引擎根本没有 |
| 145 | SavedSession 索引,所以对它的溢出而言,回合记录是唯一入口。 |
| 146 | |
| 147 | #### 回合工件 |
| 148 | |
| 149 | 回合把它产出的东西,以类型化引用的形式记录在它的条目和回合自身上。构建这些引用 |
| 150 | 不做任何扫描。每条事实都记录在写入字节的地方: |
| 151 | - 文件工具和 `apply_patch` 在 `mutation.files[]` 中报告 `size`/`sha256`; |
| 152 | - 溢出报告 `artifact_digest`; |
| 153 | - 工具媒体报告 `sha256`。 |
| 154 | |
| 155 | 工作区层面的那一半来自回合自己的恢复点:`TurnRecord.workspace_snapshots` 中的 |
| 156 | `pre_turn` 与 `post_turn` 回执(见下文“工作区恢复点”),在已有的 side 仓库里按 |
| 157 | tree id 做 diff。不涉及第二个存储、快照或事件。 |
| 158 | |
| 159 | 一个引用(`TurnArtifactRef`)携带: |
| 160 | |
| 161 | | 字段 | 含义 | |
| 162 | | --- | --- | |
| 163 | | `id` | 在回合内稳定。文件的 id 是 `file_` 加上 SHA-256(path) 的前 32 位十六进制数字。溢出的 id 是 `art_<call>`。媒体的 id 是 `art_image_<sha256>`。 | |
| 164 | | `kind` | `file`、`tool_output` 或 `media`。 | |
| 165 | | `path` | 对 `file`:工作区相对路径,使用 `/` 分隔。对 `tool_output` 与 `media`:会话相对路径(`artifacts/...`)。 | |
| 166 | | `change` | 仅对 `file`:`created`、`updated`、`deleted` 或 `renamed`。重命名还带有 `previous_path`。 | |
| 167 | | `size` | 字节大小。文件已删除时不存在。 | |
| 168 | | `revision` | 整个内容的 SHA-256 十六进制值。它就是 `GET /v1/workspace/files/read` 报告为 `revision` 的值,而 file-revert 的 `expected_hash` 是 `sha256:` + `revision`。文件已删除时,或 delta blob 超过 16 MiB 时不存在。 | |
| 169 | | `content_type` | 对 `media`:确切的媒体类型。 | |
| 170 | | `session_id` | 对 `tool_output` 与 `media`:拥有这些字节的工件会话。 | |
| 171 | | `item_id`、`tool_call_id`、`tool_name` | 写入它的那次工具调用。只在工作区 delta 中看到的改动没有这些字段。 | |
| 172 | | `source` | `tool_mutation`、`tool_output_spill`、`tool_media` 或 `workspace_changed_during_turn`。 | |
| 173 | | `restore_snapshot_id` | 一个 `POST /v1/threads/{id}/file-revert` 在此线程上对该路径接受的恢复点:记录在本回合 `workspace_snapshots` 上的某个回执的 `tree_id`,因此无论线程是否绑定到已保存会话,它都归该线程所有。对工具写入,它是该调用的 `tool` 回执;对 delta 改动,它是回合的 `pre_turn` 回执。回合没有记录此类回执时不存在。 | |
| 174 | | `recorded_at` | 记录该引用的时间。 | |
| 175 | |
| 176 | 引用出现在哪里: |
| 177 | - **条目。** `TurnItemRecord.artifacts` 列出一次工具调用产出的东西。它在调用 |
| 178 | 完成时设置(无论成功或失败),所以 `item.completed` 与 `item.failed` 会实时携带它。 |
| 179 | - **旧版投影。** `artifact_refs` 由 `artifacts` 派生。它只保存仍然存在的文件的 |
| 180 | 工作区相对路径:从不包含溢出、媒体或已删除的文件。 |
| 181 | - **回合。** `TurnRecord.artifacts` 是回合汇总。它由一次合并计算得出, |
| 182 | 按最新在前排序。 |
| 183 | - 溢出与媒体引用总是保留。 |
| 184 | - 文件引用按条目顺序组合:先创建后删除则去掉该文件,先创建后更新仍为 |
| 185 | `created`,重命名会折叠它的来源。 |
| 186 | - 工作区 delta 一旦结算,就对快照能看到的每个路径的净改动、`size` 与 |
| 187 | `revision` 具有权威。它会补上没有任何工具回执点名的文件,例如 shell 和 |
| 188 | 子智能体的写入。对于快照跟踪到、但在回合结束时未改变的条目路径,它会去掉。 |
| 189 | - 汇总上限为 1000 个引用,`workspace.truncated` / `workspace.omitted` |
| 190 | 报告截断情况。 |
| 191 | |
| 192 | `TurnRecord.workspace` 跟随 delta 的生命周期。回合运行期间它是 `null`。 |
| 193 | `turn.completed` 携带它,状态为以下之一: |
| 194 | - `pending`:回合同时记录了 `pre_turn` 与 `post_turn` 回执,它们的 diff 仍在 |
| 195 | 运行。完成后,运行时发布 `turn.artifacts` |
| 196 | (`{turn_id, workspace, artifacts}`)。该事件可能在下一个回合的 |
| 197 | `turn.started` 之后才到达,所以请按 `turn_id` 关联。 |
| 198 | - `settled`:delta 已合并。`pre_turn_snapshot_id` 与 `post_turn_snapshot_id` |
| 199 | 是这一对快照的 tree id。 |
| 200 | - `unavailable`:不会有 delta。`reason` 说明原因: |
| 201 | - `snapshots_disabled`、`workspace_too_large`、`too_many_files`、 |
| 202 | `unsafe_location`、`snapshot_failed`:快照门槛。有 `pre_turn` 回执但没有 |
| 203 | `post_turn` 回执的回合保留 `pre_turn_snapshot_id`。 |
| 204 | - `not_captured`:回合没有记录恢复点:压缩或清除操作,或引擎在拍快照之前 |
| 205 | 就结束的回合。 |
| 206 | - `runtime_restarted`:进程在 delta 结算前停止。这会在启动时对账,永不重新计算。 |
| 207 | - `delta_failed`:diff 本身失败。 |
| 208 | |
| 209 | `artifacts` 仍保存工具回执记录的内容。`turn.artifacts` 会为每一种结算结果发布, |
| 210 | 所以等待 `pending` 的客户端总能收到回音。 |
| 211 | |
| 212 | delta 意味着什么,以及它看不到什么: |
| 213 | - **它是工作区 diff,不是归属。** delta 改动是回合运行期间工作区里改变的一切。 |
| 214 | 这包括同时写同一工作区的编辑器、另一个线程或后台任务。 |
| 215 | `source: workspace_changed_during_turn` 说的正是这一点。 |
| 216 | - **被排除的路径对快照不可见。** 这包括内置排除项(例如 `node_modules/`、 |
| 217 | `target/`、`dist/`、`build/`、`.next/`,以及二进制与媒体扩展名)和工作区的 |
| 218 | `.gitignore`。文件工具写入此类路径时,仍会根据其回执报告。shell 命令写入 |
| 219 | 此类路径时,则完全不会报告。 |
| 220 | - **shell 写入没有逐调用记录。** shell 命令的写入永远不会归到它的条目,只会 |
| 221 | 归到回合,而且只在启用快照时。 |
| 222 | |
| 223 | 路由: |
| 224 | |
| 225 | - `GET /v1/threads/{id}/turns/{turn_id}/artifacts` 从运行时存储的回合记录返回 |
| 226 | `{thread_id, turn_id, workspace, artifacts}`。回合运行期间,`artifacts` 由其 |
| 227 | 条目即时合并,`workspace` 为 `null`。未知回合,或属于另一个线程的回合,返回 404。 |
| 228 | - `GET /v1/threads/{id}/turns/{turn_id}/artifacts/{artifact_id}?offset=&limit=&revision=` |
| 229 | 读取一个引用。 |
| 230 | - 它使用工作区文件读取的窗口契约(`size`、`revision`、`offset`、`bytes`、 |
| 231 | `truncated`、`encoding`、`content`),并额外添加: |
| 232 | - `artifact`:该引用; |
| 233 | - `source`:`workspace`、`snapshot` 或 `session_artifact`; |
| 234 | - `current`:工作区是否仍持有这些字节;对该引用而言这不是一个问题时为 `null`。 |
| 235 | - `revision` 选择回合的某个条目记录过的中间修订。默认是该引用自己的修订。 |
| 236 | - 当工作区仍持有记录的修订时,`file` 从工作区提供(`current: true`)。 |
| 237 | 否则来自回合的 post-turn 快照(`current: false`)。 |
| 238 | - `tool_output` 或 `media` 引用在写入方使用的会话工件根下读取。适用与会话 |
| 239 | 路由相同的限制、图片清单与完整性检查。 |
| 240 | |
| 241 | | 状态 | 何时 | |
| 242 | | --- | --- | |
| 243 | | 404 | 未知的线程、回合或工件 id,或本回合从未记录过的 `revision`。 | |
| 244 | | 409 | 文件记录的修订既不在工作区也不在快照存储中(快照在每个工作区超过 50 个或 7 天后被修剪),或会话工件的字节不再哈希为记录的修订。消息会给出当前修订。 | |
| 245 | | 410 | 回合删除了该文件(用 `file-revert` 和 `restore_snapshot_id` 恢复它),或会话工件的字节已被修剪。 | |
| 246 | | 413 | 内容超过 16 MiB。 | |
| 247 | | 403 | 符号链接,或逃出其根的引用。 | |
| 248 | |
| 249 | Fleet 回执工件保留自己的路由 |
| 250 | (`GET /v1/fleet/runs/{run_id}/receipts/{task_id}/evidence`)。 |
| 251 | |
| 252 | ### 运行时与账号身份 |
| 253 | |
| 254 | `GET /v1/runtime/info` 报告 `codewhale_version`,以及由共享的 CLI/TUI 构建嵌入的 |
| 255 | 完整 40 字符 `codewhale_commit`。无法提供精确 commit 的源码归档会报告 `unknown`, |
| 256 | 让兼容客户端可以按失败即关闭(fail closed)处理,而不是接受一对含义不明的二进制组合。 |
| 257 | |
| 258 | 同一响应还会声明 `capabilities.account_session: true` 与 |
| 259 | `capabilities.turn_operation_idempotency: true`。客户端在依赖 `operation_key` |
| 260 | 之前必须要求后者;不要因为回合返回了 2xx 就推断其受支持,因为一个较旧的宽容读取方 |
| 261 | 可能忽略未知的请求字段。`capabilities.turn_operation_lookup: true` 单独声明 |
| 262 | 下文那个只读操作查询;客户端在依赖基于 GET 的丢失回合响应恢复之前必须要求它。 |
| 263 | 响应中还包含一份不含令牌的账号回执: |
| 264 | |
| 265 | ```json |
| 266 | { |
| 267 | "account": { |
| 268 | "schema_version": 1, |
| 269 | "state": "authenticated", |
| 270 | "api_base": "https://api.codewhale.net", |
| 271 | "account_id": "acct_...", |
| 272 | "session_id": "session_...", |
| 273 | "scopes": [], |
| 274 | "expires_at": "2026-08-01T20:00:00Z" |
| 275 | } |
| 276 | } |
| 277 | ``` |
| 278 | |
| 279 | 运行时从 `codewhale account login` 写入的那条精确的、按 profile 与 API 源站限定范围的 |
| 280 | 安全记录中读取这份回执;它不会再跑一遍登录流程。状态有 `signed_out`、`authenticated`、 |
| 281 | `offline_cached`、`expired` 或 `revoked`。scopes 只从显式存储的会话授权中复制, |
| 282 | 绝不从账号身份推断。访问令牌/刷新令牌、邮箱、提供商 profile 与提供商凭据 |
| 283 | 永不返回。只有在请求由运行时令牌(或显式开启的不安全回环服务器)授权时, |
| 284 | 才会包含 `account_id` 与 `session_id`;公开的 bootstrap 响应仍然可用,但会报告 |
| 285 | `signed_out`。未登录状态下的本地 Work 仍然受支持,并且绝不会隐式分配云端算力。 |
| 286 | |
| 287 | `--stdio` 控制传输是换行分隔的 JSON-RPC 2.0。可以在不消耗模型 token 的情况下探测它: |
| 288 | |
| 289 | ```bash |
| 290 | printf '%s\n' \ |
| 291 | '{"jsonrpc":"2.0","id":1,"method":"healthz"}' \ |
| 292 | '{"jsonrpc":"2.0","id":2,"method":"capabilities"}' \ |
| 293 | '{"jsonrpc":"2.0","id":3,"method":"shutdown"}' \ |
| 294 | | codewhale app-server --stdio |
| 295 | ``` |
| 296 | |
| 297 | `capabilities` 返回已声明的方法族(`thread/*`、`app/*`、 |
| 298 | `prompt/*`)以及完整的方法清单;`thread/capabilities`、 |
| 299 | `app/capabilities` 与 `prompt/capabilities` 则按族缩小范围。方法集合由 |
| 300 | `crates/app-server/src/lib.rs` 中的一个漂移测试钉住,因此 SDK 与本地集成客户端 |
| 301 | 可以依赖它不会悄悄变化。 |
| 302 | |
| 303 | ### 守护进程套接字:`codewhale app-server --socket` |
| 304 | |
| 305 | 桌面外壳(DESKTOP-APP-BRIEF §2)通过 unix 域套接字连接到一个长期存活的守护进程。 |
| 306 | 其线上协议就是 `--stdio` 传输本身——同样的换行分隔 JSON-RPC 2.0 方法,由同一份代码 |
| 307 | 分发——只是在前面加了一次握手。 |
| 308 | |
| 309 | > 注(2026-09-14):上面提到的 Tauri 桌面外壳在 2026-09-14 的产品客户端切换中正在退役, |
| 310 | > 而 DESKTOP-APP-BRIEF 这个引用是一个悬空指针(本仓库中不存在该 brief)。私有 |
| 311 | > `codehwhale-gpui` 仓库中的 GPUI 客户端是这套 HTTP 运行时 API 的继任守护进程消费方; |
| 312 | > 这里描述的套接字协议没有变化。 |
| 313 | |
| 314 | **端点。** 若给了 `--socket-path` 就用它;否则若设置了 `CODEWHALE_HOME` 则用 |
| 315 | `$CODEWHALE_HOME/run/daemon.sock`(显式指定 home 即是一条隔离边界);否则用 |
| 316 | `$XDG_RUNTIME_DIR/codewhale/daemon.sock`;再否则在 macOS 上用 |
| 317 | `~/Library/Application Support/codewhale/daemon.sock`,其他平台用 |
| 318 | `~/.codewhale/run/daemon.sock`。目录以 `0700` 创建,套接字为 `0600`, |
| 319 | 每个被接受的连接方都必须出示守护进程自身的 uid。 |
| 320 | 启动时,无人应答的套接字文件会被删除;若有一个仍在工作的,则新守护进程以 |
| 321 | `a live listener already answers on <path>; refusing to |
| 322 | replace it` 退出;路径上本就不是套接字的文件永不被触碰。在 Windows 上 `--socket` 会失败, |
| 323 | 返回一个带类型的 `UnsupportedPlatform` 错误,并点名预留管道 `\\.\pipe\codewhale-daemon` |
| 324 | ——没有静默的 TCP 回退。守护进程开始接受连接后会向 stderr 打印 |
| 325 | `codewhale daemon: listening on <path>`。 |
| 326 | |
| 327 | **握手。** 一条连接上的第一个请求必须是 `daemon/attach` |
| 328 | (在此之前也允许 `healthz`,以便外壳探测存活)。在此之前,其他每个方法都会以 |
| 329 | `-32010 attach_required` 被拒绝。 |
| 330 | |
| 331 | ```json |
| 332 | {"jsonrpc":"2.0","id":1,"method":"daemon/attach","params":{ |
| 333 | "client":{"name":"codewhale-desktop","version":"1.2.3","pid":4242}, |
| 334 | "mode":"claim", |
| 335 | "expect_daemon_version":"0.9.11"}} |
| 336 | ``` |
| 337 | |
| 338 | `mode` 为 `"claim"`(该客户端拉起了这个守护进程并管理其生命周期) |
| 339 | 或 `"attach"`(默认:一个发现了健康守护进程的访客)。当另一条连接已拥有该守护进程时, |
| 340 | claim 会以 `-32011 daemon_already_claimed` 失败 |
| 341 | (`data.owner` 指明持有者),客户端应改用 `attach` 重试。 |
| 342 | `expect_daemon_version` 若存在,必须等于守护进程的 crate 版本, |
| 343 | 否则 attach 会以 `-32013 daemon_version_skew` 失败(插件包偏差防护)。 |
| 344 | 回复报告授予的 `role`(`owner` / `attached`)、守护进程的 |
| 345 | `pid`、`version`、`socket_path` 与 `uptime_ms`、当前 `owner`,以及 |
| 346 | 活动 `connections` 数量。在一条已 attach 的连接上再次 `daemon/attach` |
| 347 | 会返回 `-32014 already_attached`。 |
| 348 | |
| 349 | **能力。** 在这条传输上,`capabilities.methods` 是钉住的 stdio 集合加上 `daemon/attach` |
| 350 | (第二项,排在 `healthz` 之后);`transport` 读作 |
| 351 | `unix-socket`。`shutdown` 会向每条连接声明,因为该方法确实存在,但只有 owner 才能调用它 |
| 352 | (见下文)。 |
| 353 | |
| 354 | **所有权。** 只有 owner 可以 `shutdown`;访客的 `shutdown` 会以 |
| 355 | `-32012 not_daemon_owner` 被拒绝,并且不会打断任何人的回合。当 |
| 356 | owner 断开连接时,这个位置就释放了,因此重新启动的外壳可以重新认领它此前留下继续运行的守护进程。 |
| 357 | owner 的 `shutdown` 会停止监听、关闭所有 |
| 358 | 连接并删除套接字文件。从客户端最后见到的 `seq` 开始回放日志 |
| 359 | 目前还不属于这条传输。 |
| 360 | |
| 361 | ### 中断一个回合 |
| 362 | |
| 363 | `thread/message` 会持续流式传输,直到回合进入终态,这可能 |
| 364 | 耗时数分钟。在回合流式传输期间,读取循环会继续轮询 stdin,因此 |
| 365 | 客户端可以发送: |
| 366 | |
| 367 | ```json |
| 368 | {"jsonrpc":"2.0","id":9,"method":"thread/interrupt","params":{"thread_id":"thr_..."}} |
| 369 | ``` |
| 370 | |
| 371 | 运行时会收到请求去中断该回合 |
| 372 | (`POST /v1/threads/{id}/turns/{turn_id}/interrupt`)。当该线程没有回合在流式传输时, |
| 373 | 回复会带上 `interrupted: false`——这不是错误,只是没有可停止的东西。被中断的 |
| 374 | `thread/message` 随后会以 `turn interrupted` 错误失败,并且它的回复会写在 |
| 375 | 中断自身回复之前,因为在该回合回退(unwind)之前,写入方由它独占。 |
| 376 | |
| 377 | 在一个进行中的回合期间发送 `shutdown` 也会先中断:它需要该回合持有的同一个 |
| 378 | 桥,否则它就会一直等待那个它本意要停止的回合。回合中途到达的其他请求会排队, |
| 379 | 并在回合结束后按顺序执行。 |
| 380 | |
| 381 | ### 运行一个提示词 |
| 382 | |
| 383 | `prompt/request` 与 `prompt/run`(字节级完全相同的别名)以及旧式 |
| 384 | HTTP `POST /prompt` 都会在运行时上执行一个**真实回合**,经由 |
| 385 | `thread/message` 使用的同一个桥。没有本地回退:app-server 中没有任何其他东西 |
| 386 | 能产生模型输出,所以一个提示词要么真的运行,要么就失败。 |
| 387 | |
| 388 | - `params.prompt` 是必需的,且必须非空(否则返回 `-32602`)。 |
| 389 | - `params.thread_id` 是可选的。带上时,提示词在该线程及其 |
| 390 | 历史上运行。不带时,运行时会为这一个回合拿一个全新线程; |
| 391 | 该回合结束时这层映射就被丢弃,因此一次性提示词无法 |
| 392 | 通过 `thread/interrupt` 寻址。当你需要能够中断时,请使用 `thread/message`。 |
| 393 | - `params.model` 只在本次调用正是创建运行时线程的那一次调用时选择模型; |
| 394 | 已有的线程保持它创建时所用的模型。 |
| 395 | - 响应携带模型实际说出的内容:`output` 是串联起来的 |
| 396 | `agent_message` 文本,`model` 是运行时为运行该回合的线程报告的模型, |
| 397 | `events` 则是真实的 |
| 398 | `response_start`/`response_delta`/`response_end` 帧。在 stdio 上,同样的 |
| 399 | 帧还会在回合运行期间流式输出到 stdout,与 |
| 400 | `thread/message` 完全一致。 |
| 401 | - 如果无法访问运行时,调用在 stdio 上以 `-32005` |
| 402 | (`runtime_unavailable`)失败,或在 `POST /prompt` 上返回 HTTP `503` 与 |
| 403 | `{"error":{"code":"runtime_unavailable", ...}}`。失败 |
| 404 | 绝不会被塑造成一个成功的 `PromptResponse`。 |
| 405 | |
| 406 | 带 `Message` 请求体的 `POST /thread` 行为相同——它运行该回合 |
| 407 | 并回复 `status: "completed"`,`events` 中带上流式帧——而 |
| 408 | 它以前只是回复 `accepted` 而什么都不做。 |
| 409 | |
| 410 | ### 线程标识与重启 |
| 411 | |
| 412 | 规范线程 owner 保留完整已保存会话图、当前分支以及线程/会话绑定。 |
| 413 | 兼容控制接口使用同一个已认证 owner;旧 SQLite 历史只作为受保护的只读导入来源。 |
| 414 | 导入会在发布规范别名之前比较完整来源图和当前叶节点。别名发布失败时, |
| 415 | 来源和已完成的规范结果都会保留,恢复时可以说明实际完成了什么。 |
| 416 | |
| 417 | 已验证的旧目标导入同一个 owner 目标存储。导入时活动目标暂停;旧数据不会启动 |
| 418 | 提供商调用。来源目标字段参与同一个受保护的来源比较。 |
| 419 | |
| 420 | 已保存会话的 fork 保留完整日志,包括非活动分支,并把已验证的本地会话目标 |
| 421 | sidecar 复制到新会话。活动本地目标复制为暂停状态;来源保持不变。 |
| 422 | 该 sidecar 与公开的 Runtime 线程目标分开。原生 Runtime 线程 fork 不会自动 |
| 423 | 继承公开线程目标。 |
| 424 | |
| 425 | `thread/create`、`thread/start`、`thread/resume` 和 `thread/fork` 携带 |
| 426 | 客户端生成的 `operation_key`。每个用户意图在发送前生成一个键;响应不确定时 |
| 427 | 保留该键,并用它恢复同一个意图。Create 将键放在 `metadata.operation_key`; |
| 428 | Start、Resume 和 Fork 使用 `operation_key`。两次有意的 fork 使用不同键。恢复查询现有 |
| 429 | owner 存储中的原始操作;响应丢失或来源历史后来增长不会创建另一个线程。 |
| 430 | |
| 431 | `POST /v1/thread-history/operations/lookup` 是只读查询。封闭请求包含 |
| 432 | `version: 1`、`operation_key`、`expected_data_dir`、`expected_execution_scope` |
| 433 | 和 `workspace`;响应为 `absent`、`pending` 或 `committed`。待决和已提交响应 |
| 434 | 包含保留的回执以及准确的操作/来源 `association`。 |
| 435 | |
| 436 | `POST /v1/thread-history/operations/recover` 在 `operation` 中接收该查询请求, |
| 437 | 并接收预期的 `association`。它在同一个 owner 下验证已保存文档、完整图、 |
| 438 | 工作区、检查点和操作/来源身份,然后明确完成已准备好的目标。 |
| 439 | 它不会从可能已变化的来源重新构造原始意图。尚未准备好的目标保持待决; |
| 440 | 变化或无法验证的目标拒绝完成。使用同一个键重复恢复会观察到同一个已提交结果。 |
| 441 | |
| 442 | 选定工作区来自已确认的 owner 或明确获准的请求。历史和旧回执不能提供权限、 |
| 443 | 凭据、端点或另一个 owner。绑定存储缺失、变化、繁忙或不兼容时明确失败。 |
| 444 | 规范目标缺失不会启动一个空的替代会话。 |
| 445 | |
| 446 | `codewhale thread resume` 和 `codewhale thread fork` 执行持久化 owner 控制, |
| 447 | 并输出已提交的线程、会话和操作回执。这两个命令不启动交互式界面。 |
| 448 | |
| 449 | 全局 `--workspace`(也可用 `--cd`)、`--profile` 和 `--config` 选择明确的 |
| 450 | 控制范围。相对路径在挂接前确定;客户端先认证 owner,再使用同一个 owner |
| 451 | 回执和已捕获的 worker 设置接纳该范围。不兼容的 profile 或配置、缺失的范围 |
| 452 | 信息或 owner 变化都会明确失败。省略这些选项时,工作区来自已确认的 owner。 |
| 453 | 线程列表仍覆盖整个存储。 |
| 454 | |
| 455 | 新的 `thread resume` 或 `thread fork` 可通过全局 `--provider`、`--model`、 |
| 456 | `--approval-policy` 和 `--sandbox-mode` 向现有 owner 解码器和权限检查提交 |
| 457 | 提议。对应的 `--set` 键为 `provider`、`model`、`default_text_model`、 |
| 458 | `approval_policy` 和 `sandbox_mode`。凭据和端点由 owner 保管:这些控制拒绝 |
| 459 | `--api-key`、`--base-url` 和其他单次运行设置。请先配置并认证所属 Runtime。 |
| 460 | 携带保留的 `--operation-key` 时,新提交的模型、提供商、策略或 sandbox 提议 |
| 461 | 都会被拒绝;恢复只观察原本已接纳的意图。 |
| 462 | |
| 463 | 交互式 `codewhale resume` 和 `codewhale fork` 使用同一个规范历史操作。 |
| 464 | 只有在非活动的本地 owner 已关闭并等待退出之后,现有 TUI 才取得会话租约和存储。 |
| 465 | 活动 owner 或不确定的交接会拒绝挂接。`--operation-key <KEY>` 恢复原始结果; |
| 466 | 可以完成已验证并准备好的目标;尚未准备好或无法验证的结果保留不确定性。 |
| 467 | |
| 468 | ### 回答澄清提问 |
| 469 | |
| 470 | 当一个无头回合调用 `request_user_input` 时,运行时会发出一个 |
| 471 | `user_input.required` 事件,携带 `request_id`。请通过运行时 API 回复: |
| 472 | |
| 473 | ``` |
| 474 | POST /v1/user-input/{thread_id}/{request_id} |
| 475 | ``` |
| 476 | |
| 477 | app-server 控制传输无法接受该回复。 |
| 478 | 带 `SubmitUserInput` 的 `app/request` 会返回 `ok: false` 与 |
| 479 | `error: "user_input_reply_unsupported"`。这是该传输的固有性质, |
| 480 | 不是遗漏:当一个回合正在流式传输时,stdio 循环只执行 |
| 481 | `thread/interrupt`,其他请求一律排队,所以从那里发出的答复会 |
| 482 | 等待那个正在等它的回合本身。 |
| 483 | |
| 484 | ## SDK 契约 |
| 485 | |
| 486 | app-server 存在的意义是让外部 SDK 无需抓取 TUI |
| 487 | 输出就能回答——*实际运行了哪条路由、生效的提供商/模型/推理/权限配置是什么、 |
| 488 | 发生了哪些事件、用了多少 token、这次运行如何结束。* 持久化的 Thread/Turn/Item 数据模型 |
| 489 | 已经承载了其中大部分内容;下表把每一项集成需求映射到本地客户端读取它的位置。 |
| 490 | |
| 491 | | 集成需求 | 来源 | 状态 | |
| 492 | |---|---|---| |
| 493 | | 路由 / 生效模型 / 计费表面 | `TurnRecord` + 线程 `model`;每次运行的 `--provider`/`--model` 覆盖 | 可用 | |
| 494 | | 权限 / 沙箱 / 审批配置 | 线程 `auto_approve`、沙箱 + 审批策略;`TurnRecord.permission_posture` + `TurnRecord.mode` 说明*那一次*运行是如何被治理的(该线程自己的 `mode` 此后可能已被切换) | 可用 | |
| 495 | | 运行 / 线程 / 回合 ID | `thread_id`、`turn_id`、SSE 事件信封 | 可用 | |
| 496 | | 事件流 | `GET /v1/threads/{id}/events`(回放 + 实时 SSE) | 可用 | |
| 497 | | 回合状态 / 终态分类 | `TurnRecord.status` + 错误摘要 | 可用 | |
| 498 | | Token 用量 | `TurnRecord.usage`;通过 `GET /v1/usage` 聚合 | 可用 | |
| 499 | | 动作回执(文件、命令、web/MCP 调用、智能体、审批及由谁决定、失败) | `GET /v1/threads/{id}/receipt`、`GET /v1/threads/{id}/turns/{turn_id}/receipt` | 可用([RECEIPTS.md](../RECEIPTS.md)) | |
| 500 | |
| 501 | 对于一次性/无头自动化,优先使用 `codewhale exec` 并显式给出 |
| 502 | `--provider <id> --model <id>`,这样一旦失败就能确定是哪一对提供商/模型。 |
| 503 | 当本地集成需要启动、恢复、引导(steer)或中断回合、列出模型/能力、跟踪事件流 |
| 504 | 或读取用量时,请使用 `app-server`。两条路径共享同一个运行时,因此路由生效的模型解析 |
| 505 | 与事件词汇表是一致的。 |
| 506 | |
| 507 | ### 发布冒烟检查 |
| 508 | |
| 509 | `scripts/release/app-server-smoke.sh` 是已提交的发布前检查: |
| 510 | |
| 511 | ```bash |
| 512 | scripts/release/app-server-smoke.sh # stdio health/capabilities probe (no tokens) |
| 513 | scripts/release/app-server-smoke.sh --matrix # + print the configured provider/model matrix |
| 514 | scripts/release/app-server-smoke.sh --matrix --real # + exec a cheap sentinel per provider |
| 515 | ``` |
| 516 | |
| 517 | stdio 探针针对一份一次性配置运行,因此它从不读取真实密钥。 |
| 518 | 矩阵从 `codewhale auth list` 发现已配置的提供商,跳过 |
| 519 | 未配置的提供商,并且只有当某个提供商有内置廉价默认模型时才把它映射到一个 |
| 520 | 廉价哨兵模型。这个内置集合是刻意保守的 |
| 521 | (目前是 `deepseek`、`zai`、`moonshot` 与 `openai`);其他每个提供商—— |
| 522 | 包括 `arcee`、`openrouter`、`xiaomi-mimo` 与 `openai-codex`——都被有意留作未映射, |
| 523 | 每次运行必须通过 `SMOKE_MODEL_<SLUG>` 指定模型,而不是使用猜测的默认值(#3205)。 |
| 524 | 任何已配置但未映射的提供商在 `--real` 模式下都会大声失败。`auth list` 只报告存在性标志, |
| 525 | 且 exec 输出会经过脱敏器,因此密钥永不会被打印。该解析器由 |
| 526 | `scripts/release/app-server-smoke.test.sh` 针对一个伪造的 `codewhale` |
| 527 | 二进制进行覆盖。 |
| 528 | |
| 529 | ## ACP stdio 适配器:`codewhale serve --acp` |
| 530 | |
| 531 | ACP 以换行分隔的 stdio JSON-RPC 投影现有 RuntimeThreadManager 与 Engine。 |
| 532 | 它不再维护独立的提供商/工具回合循环、可执行注册表、提示词组合器或会话写入器。 |
| 533 | 服务端加载实际选定的配置、profile 与插件发现结果;每个提示词复用规范线程、 |
| 534 | Core 回合、事件时间线、审批等待器和完整 Engine 会话快照。 |
| 535 | |
| 536 | 编辑器接口支持 `initialize`、`session/new`、`session/list`、`session/load` |
| 537 | (含持久 ID 前缀)、`session/prompt`、`session/cancel`、模型发现/选择, |
| 538 | 以及声明的模式/模型配置选项。新会话先持久化裸 UUID 和空检查点,不调用提供商。 |
| 539 | 连接最多保留64个空闲绑定;淘汰绑定不删除持久会话。恢复复用已有线程绑定。 |
| 540 | 完整历史、工具调用/结果配对、签名、媒体和部分执行回执通过 HTTP 同用的检查点 |
| 541 | 守卫与会话写入租约保存。ACP 展示文本可以缩短,持久历史仍是完整 Core 快照。 |
| 542 | |
| 543 | 受信任的本地 ACP profile 将 Core 收窄到文件/搜索/git/patch,以及获准的前台 |
| 544 | shell 工具。Shell 同时要求编辑器声明 terminal 支持、操作者允许 `allow_shell`; |
| 545 | 指定的外部沙箱不可用时不提供 shell。此接口不提供 MCP、动态工具、任务、PTY、 |
| 546 | 后台 shell、解释器、子智能体或 RLM 生命周期。内置工具覆盖会移除整个兼容别名族。 |
| 547 | 最终派发再次校验 profile;伪造别名、hook 改写或目录中缺失的工具不能绕过限制。 |
| 548 | Full Access 下 Plan 仍只读。Full Access 与普通审批姿态是服务端拥有的只读选项, |
| 549 | 编辑器不能放宽。Core 的类型化规则、严格 hooks、仓库约束、Headless Auto-Review |
| 550 | 与硬性下限仍生效;工作区写入不享受免审批例外,需要 guardian 的裁决会明确拒绝。 |
| 551 | |
| 552 | 工具首先显示 `pending`;只有 Core 到达最终派发才显示 `in_progress`。 |
| 553 | `completed`/`failed` 和类型化图片块来自实际 Core 结果。审批请求的私有 JSON-RPC ID |
| 554 | 绑定同一个 Runtime 铸造的待决审批与 Core 执行 ID。只有精确匹配、仍有效的 |
| 555 | `allow-once` 响应能释放等待器;错误 ID 被忽略,无效选项拒绝,取消会撤销等待器。 |
| 556 | ACP 不授予记忆权限或 Native 能力。 |
| 557 | |
| 558 | 重放复用有界事件读取器,按序号去重,并在每个事件之间处理输入;每次传输写入 |
| 559 | 最多等待30秒。重放缺口、 |
| 560 | owner 关闭或无法产生终态的存储故障会明确报错,不会重新执行。取消、EOF 和写入器 |
| 561 | 故障只中断本连接实际声明的 Core 回合;结算依赖真实终态回执并保留已完成的效果。 |
| 562 | 无法确认取消时不伪造成功。`stopReason` 为 `end_turn`、`cancelled` 或类型化的 |
| 563 | `max_turn_requests`;Core 失败仍是错误。单次提示词最多50个模型步骤(更小的配置 |
| 564 | 上限仍有效),并复用 Core 的有界最终报告响应;此 profile 不派发自主目标续跑。 |
| 565 | |
| 566 | ACP 当前声明一个独占的规范 Runtime owner。其他进程已持有该存储时,启动拒绝; |
| 567 | 会话绑定另一 Runtime 存储时也拒绝。经过身份校验的跨进程 owner 附着尚未完成验证, |
| 568 | ACP 不把活跃会话复制到随机存储。它也不向编辑器提供全部 `/v1/*` 引导、任务或 |
| 569 | 控制方法;完整运行时 API 请使用 `codewhale app-server --http`。 |
| 570 | |
| 571 | |
| 572 | ## 能力端点:`codewhale doctor --json` |
| 573 | |
| 574 | 返回一个 JSON 对象,描述当前安装的就绪状态。 |
| 575 | 适合 macOS 工作台做健康检查轮询。该命令严格是结构性且离线的:它不加载工作区凭据 |
| 576 | `.env` 文件、不检查凭据环境变量值、不打开 secret/OAuth 文件、 |
| 577 | 不探测 OS 钥匙串、不联系提供商、也不启动 MCP 进程。 |
| 578 | |
| 579 | ```bash |
| 580 | codewhale doctor --json |
| 581 | ``` |
| 582 | |
| 583 | ### 响应 schema(关键字段) |
| 584 | |
| 585 | | Field | Type | Description | |
| 586 | |---|---|---| |
| 587 | | `version` | string | 已安装的版本(例如 `"0.8.9"`) | |
| 588 | | `config_path` | string | 解析后的配置文件路径 | |
| 589 | | `config_present` | bool | 配置文件是否存在 | |
| 590 | | `paths` | object | 规范的配置、设置、状态、会话、日志、自动化与 secrets 路径 | |
| 591 | | `secret_backend` | object | 仅含元数据的文件存储形态;对系统后端与不支持的后端则为字面量 `unknown` / `not_probed` | |
| 592 | | `workspace` | string | 默认工作区目录 | |
| 593 | | `legacy_state.primary_root` | string | 为主状态路径被检查的 Codewhale 主状态根 | |
| 594 | | `legacy_state.legacy_root` | string | 为已知状态路径被检查的旧式 `.deepseek` 状态根 | |
| 595 | | `legacy_state.needs_attention` | bool | 已知的 `~/.deepseek` 状态路径是否需要人工核查,或只读会话恢复诊断发现目标文件名缺失 / 无法完成 | |
| 596 | | `legacy_state.legacy_only_count` | number | 仅存在于旧式根下的已知状态路径数 | |
| 597 | | `legacy_state.dual_present_count` | number | 同时存在于主根与旧式根下的已知状态路径数 | |
| 598 | | `legacy_state.entries` | array | 逐路径的迁移状态:`{name, primary_present, legacy_present, status}` | |
| 599 | | `legacy_state.session_recovery.status` | string | `isolated`、`no_legacy_sessions`、`migration_pending`、`migration_incomplete`、`migration_complete` 或 `scan_failed` | |
| 600 | | `legacy_state.session_recovery.read_only` | bool | 恒为 true;doctor 永不触发会话迁移,也不修改任一会话目录 | |
| 601 | | `legacy_state.session_recovery.chat_contents_read` | bool | 恒为 false;比较仅基于顶层 `.json` 文件名与文件系统元数据 | |
| 602 | | `legacy_state.session_recovery.checkpoint_internals_scanned` | bool | 恒为 false;`sessions/checkpoints/` 及其他所有目录均被跳过 | |
| 603 | | `legacy_state.session_recovery.recoverable_files` | array | 最多 100 个缺失目标文件名的有界样本,带来源与目标路径;不含对话负载 | |
| 604 | | `legacy_state.session_recovery.recoverable_file_count` | number | 缺失目标文件名的总数,含超出有界样本的条目 | |
| 605 | | `legacy_state.session_recovery.recoverable_files_truncated` | bool | 是否发现了多于 100 个可恢复文件名 | |
| 606 | | `legacy_state.session_recovery.recovery_command` | string or null | 当可加性自动恢复可用时为 `codewhale sessions`;隔离、已完整、为空或扫描失败时为 null | |
| 607 | | `api_key.source` | string | 结构性的来源状态:`config_declared`、`env_declared`、`external_auth_declared`、`secret_store_unprobed`、`secret_store_unavailable`、`oauth_unprobed`、`external_consent`、`none`、`local_runtime` 或 `unknown`;声明不等于可用性证明 | |
| 608 | | `api_key.availability` | string | 字面量 `present`、`not_required`、`not_probed`、`unavailable` 或 `unknown`;只有 `present` 与 `not_required` 能证明 Setup/Fleet 凭据在结构上已就绪 | |
| 609 | | `base_url` | string | 仅提供商 URL 的授权部分(`scheme://host[:explicit-port]`);userinfo、path、query 与 fragment 均被省略 | |
| 610 | | `default_text_model` | string | 默认模型 | |
| 611 | | `memory.enabled` | bool | 记忆功能是否开启 | |
| 612 | | `memory.path` | string | 记忆文件路径 | |
| 613 | | `memory.file_present` | bool | 记忆文件是否存在 | |
| 614 | | `mcp.config_path` | string | MCP 配置文件路径 | |
| 615 | | `mcp.present` | bool | MCP 配置是否存在 | |
| 616 | | `mcp.probe_scope` | string | `configuration`;doctor 不启动 MCP 服务器 | |
| 617 | | `mcp.live_health_checked` | bool | 对 doctor JSON 恒为 false | |
| 618 | | `mcp.servers` | array | 逐服务器的结构性结果与计数,另加单独的 `checks`;URL 的 userinfo/path/query/fragment 以及命令 argv、环境、header 与 token 值永不输出,且所有实时阶段均为 `not_checked` | |
| 619 | | `skills.selected` | string | 解析后的技能目录 | |
| 620 | | `skills.global.path` / `.present` / `.count` | — | Codewhale 全局技能目录(`~/.codewhale/skills`,并支持旧式 `~/.deepseek/skills`) | |
| 621 | | `skills.agents.path` / `.present` / `.count` | — | 工作区 `.agents/skills/` 目录 | |
| 622 | | `skills.agents_global.path` / `.present` / `.count` | — | agentskills.io 全局技能目录(`~/.agents/skills`) | |
| 623 | | `skills.local.path` / `.present` / `.count` | — | `skills/` 目录 | |
| 624 | | `skills.opencode.path` / `.present` / `.count` | — | `.opencode/skills/` 目录 | |
| 625 | | `skills.claude.path` / `.present` / `.count` | — | `.claude/skills/` 目录 | |
| 626 | | `tools.path` / `.present` / `.count` | — | 全局工具目录 | |
| 627 | | `plugins.path` / `.present` / `.count` | — | 全局插件目录 | |
| 628 | | `sandbox.available` | bool | 该操作系统上是否支持沙箱 | |
| 629 | | `sandbox.kind` | string or null | 沙箱种类(例如 `"macos_seatbelt"`) | |
| 630 | | `storage.spillover.path` / `.present` / `.count` | — | 工具输出溢出目录 | |
| 631 | | `storage.stash.path` / `.present` / `.count` | — | 输入区暂存区 | |
| 632 | |
| 633 | ### 示例 |
| 634 | |
| 635 | ```json |
| 636 | { |
| 637 | "version": "0.8.9", |
| 638 | "config_path": "/Users/you/.codewhale/config.toml", |
| 639 | "config_present": true, |
| 640 | "workspace": "/Users/you/projects/codewhale-tui", |
| 641 | "api_key": { |
| 642 | "source": "secret_store_unprobed", |
| 643 | "availability": "not_probed" |
| 644 | }, |
| 645 | "base_url": "https://api.deepseek.com", |
| 646 | "default_text_model": "deepseek-v4-pro", |
| 647 | "memory": { |
| 648 | "enabled": false, |
| 649 | "path": "/Users/you/.codewhale/memory.md", |
| 650 | "file_present": true |
| 651 | }, |
| 652 | "mcp": { |
| 653 | "config_path": "/Users/you/.codewhale/mcp.json", |
| 654 | "present": true, |
| 655 | "servers": [ |
| 656 | {"name": "filesystem", "enabled": true, "transport": "stdio", "args_count": 2, "env_count": 0, "status": "ok"} |
| 657 | ] |
| 658 | }, |
| 659 | "sandbox": { |
| 660 | "available": true, |
| 661 | "kind": "macos_seatbelt" |
| 662 | } |
| 663 | } |
| 664 | ``` |
| 665 | |
| 666 | ## HTTP/SSE 运行时 API:`codewhale app-server --http` |
| 667 | |
| 668 | ```bash |
| 669 | codewhale app-server --http [--host 127.0.0.1] [--port 7878] [--workers 2] [--auth-token TOKEN] [--insecure-no-auth] |
| 670 | codewhale app-server --mobile [--host 127.0.0.1] [--port 7878] [--auth-token TOKEN] |
| 671 | codewhale app-server --mobile --host ::1 [--port 7878] [--insecure-no-auth] |
| 672 | codewhale web [--port 7878] |
| 673 | |
| 674 | # Compatibility aliases — identical server, serve flag names: |
| 675 | codewhale serve --http [...] [--insecure] |
| 676 | codewhale serve --mobile [...] [--insecure] |
| 677 | ``` |
| 678 | |
| 679 | 默认值:主机 `127.0.0.1`、端口 `7878`、2 个 worker(限制在 1–8)。 |
| 680 | |
| 681 | 服务器默认绑定到 `localhost`。配置通过 CLI 标志完成—— |
| 682 | 没有 `[app_server]` 配置节。 |
| 683 | |
| 684 | `/v1/*` 路由需要 bearer 令牌,除非 `codewhale app-server` 在诸如 `127.0.0.1` 的 |
| 685 | 回环绑定上以 `--insecure-no-auth` 启动。移动模式 |
| 686 | 仅限回环:在 Runtime 拥有 TLS 或经过验证的 overlay 传输边界之前, |
| 687 | 非回环主机都会被拒绝。`codewhale serve` 兼容别名 |
| 688 | 用 `--insecure` 作为同一个回环逃生通道。 |
| 689 | 启动服务器前请传入 `--auth-token TOKEN` 或设置 `CODEWHALE_RUNTIME_TOKEN=TOKEN`; |
| 690 | `DEEPSEEK_RUNTIME_TOKEN` 仍作为兼容别名保留。两者都未设置时, |
| 691 | 进程会为该进程生成一个 Runtime 令牌,且**不会** |
| 692 | 打印它。`/health`、`/v1/runtime/info` 与已启用的静态客户端外壳 |
| 693 | 保持公开;Runtime 的变更操作与线程数据留在 `/v1/*` |
| 694 | 认证之后。移动模式被禁用时 `/mobile` 返回 404,启用时 |
| 695 | 提供未改动的静态外壳。 |
| 696 | |
| 697 | 已认证的客户端可以提供令牌:`Authorization: Bearer TOKEN`、 |
| 698 | `X-Codewhale-Runtime-Token: TOKEN`,或旧式的 |
| 699 | `X-DeepSeek-Runtime-Token: TOKEN`。查询字符串认证与裸 Runtime 令牌 cookie |
| 700 | 认证不受支持。 |
| 701 | |
| 702 | ### 本地浏览器客户端 |
| 703 | |
| 704 | `codewhale web` 在 `127.0.0.1` 上启动规范 Runtime API,提供 |
| 705 | 嵌入二进制的无依赖资源,打印一个一次性启动 URL, |
| 706 | 并要求操作系统在默认浏览器中打开该 URL。如果 |
| 707 | 浏览器没有打开,打印出的 URL 在十分钟内仍然可用。该 |
| 708 | 命令不能绑定非回环主机,也不能在 Runtime 认证 |
| 709 | 被禁用的情况下运行。 |
| 710 | |
| 711 | 浏览器启动 URL 包含一个随机的、短期有效的一次性 bootstrap |
| 712 | 能力,绝不是 Runtime 令牌。一个回环请求会用该 |
| 713 | 能力换取一个 |
| 714 | `codewhale_web_session=…; HttpOnly; SameSite=Strict; Path=/` cookie,其背后是 |
| 715 | 单个进程内服务器会话:它在服务器进程启动 12 小时后过期, |
| 716 | 立即消耗该能力,并重定向到 `/`。重复使用、已过期、格式错误或 |
| 717 | 非回环的 bootstrap 尝试都会失败关闭。Runtime bearer 令牌不会被 |
| 718 | 写入渲染的 HTML、浏览器存储、日志、URL 查询/fragment 或 |
| 719 | 浏览器启动参数。一次性 bootstrap 能力会打印在 |
| 720 | 本地终端,并经由操作系统浏览器启动器的参数列表传递。同用户 |
| 721 | 进程可能抢在浏览器之前完成交换,这正是该能力 |
| 722 | 只能用一次、仅限回环并在十分钟后过期的原因——也是为什么同用户 |
| 723 | 攻击者有严格比这个竞争更简单的本地途径。 |
| 724 | Web 请求需要会话 cookie 加上一个限定于源(origin)的请求证明; |
| 725 | 流使用一张新的一次性票据。初始重定向把证明放在 fragment 中,客户端将其移除并 |
| 726 | 保存到限定于源的 `sessionStorage`。重新加载或在第二个标签页中打开时, |
| 727 | 当 `Sec-Fetch-Site` 为 `same-origin` 或 `none`(直接导航)时, |
| 728 | 经过认证的 `GET /` 还会把证明嵌入一个 meta 标签。该页面使用 `no-store`、 |
| 729 | 禁止被嵌入框架,也不授予任何跨源读取权限。这让新标签页无需复用 bootstrap URL |
| 730 | 即可恢复,包括存储不可用的情况。不支持 Fetch Metadata 的客户端只能复用 fragment |
| 731 | 或它已存储的证明;恢复不会延长服务器会话,也不会替换已过期的 cookie。 |
| 732 | 跨源的 Fetch Metadata 或不匹配的 Origin 会在 web API 请求上被拒绝。显式的 |
| 733 | bearer 与 Runtime 令牌 header 客户端保持其既有行为。暂时性的流票据失败会以 |
| 734 | 有上限的退避重试;HTTP 401/403 会停止票据重试,直到打开新的会话。 |
| 735 | |
| 736 | 内嵌客户端提供一个响应式线程/搜索侧栏、Runtime 拥有的 |
| 737 | 会话事实、转录与工具回执,以及底部输入区。它可以 |
| 738 | 创建、选择、重命名与归档线程;为新线程选择提供商与模型 |
| 739 | 而不改变 Runtime 默认值;启动或引导回合;中断 |
| 740 | 工作;解决审批;并回答 Runtime 的用户输入请求。选择某个线程时 |
| 741 | 会先加载 `GET /v1/threads/{id}`,然后用 |
| 742 | `since_seq=latest_seq` 打开可回放的事件流;重连时从最新被接受的序号 |
| 743 | 继续前进,并丢弃重复事件或来自过期选择的事件。线程详情 |
| 744 | 快照包含 `pending_approvals`、`pending_user_inputs` 与 |
| 745 | `pending_dynamic_tool_calls`;客户端必须在 |
| 746 | 订阅之前先填充这些字段,这样一次重新加载就不会把请求事件位于或早于 |
| 747 | `latest_seq` 的工作搁置在那里。对已连接的客户端,解决结果也会以 `approval.decided`、 |
| 748 | `user_input.answered`、`user_input.canceled`、`tool_call.resolved`、 |
| 749 | `tool_call.canceled` 或 `tool_call.timeout` 发布。 |
| 750 | |
| 751 | 既有线程的模型、模式、权限姿态、工作区与分支在该客户端中 |
| 752 | 仅作展示。Files/Changes、PTY/终端、预览、工件、 |
| 753 | 提供商登录或全局默认值切换、Fleet 创建,以及 |
| 754 | 撤销/重试/恢复控件都没有内置在这个页面里。原生桌面 |
| 755 | 客户端通过本文记录的工作区文件、回合工件、终端与工作区恢复路由提供它们。 |
| 756 | |
| 757 | ### 移动端控制页 |
| 758 | |
| 759 | `codewhale serve --mobile` 启动同一个 HTTP/SSE 运行时 API,并在 `/mobile` 提供一个 |
| 760 | 适配手机的控制页。它只绑定回环 |
| 761 | (`127.0.0.1` 或 `::1`);非回环主机会被拒绝,因为该 Runtime |
| 762 | 表面尚未提供 TLS 或经过验证的 overlay 传输。静态 |
| 763 | HTML 页面不含 Runtime bearer,本身也不受令牌门控。当 |
| 764 | Runtime 认证启用时,CLI 会打印一个短期有效的、一次性回环 |
| 765 | bootstrap URL。该能力会创建一个 30 分钟的进程内 |
| 766 | `Max-Age=1800; HttpOnly; SameSite=Strict` 移动端会话 cookie,以及按源站限定范围的浏览器 |
| 767 | 证明。一个收到该主机范围 cookie 的兄弟端口无法仅凭它使用它。 |
| 768 | 该页面也可以一次性交换显式输入的 bearer,随后 |
| 769 | 清除它,而不是把它存进浏览器存储或 cookie。EventSource |
| 770 | 连接使用单独的短期有效、一次性流票据。 |
| 771 | |
| 772 | 移动端页面可以列出/创建线程、发送提示词、跟踪实时 SSE 事件、 |
| 773 | 引导或中断活动回合,并通过 |
| 774 | `POST /v1/approvals/{approval_id}` 解决常规工具审批。它是一个仅限本地的便捷表面; |
| 775 | 在 Runtime 拥有 TLS 或经过验证的传输边界之前, |
| 776 | 不要直接将它暴露给其他设备或公网。 |
| 777 | |
| 778 | ### 端点 |
| 779 | |
| 780 | **健康检查** |
| 781 | - `GET /health` |
| 782 | |
| 783 | **会话**(持久会话管理器) |
| 784 | - `GET /v1/sessions?limit=50&search=<fuzzy>&include_archived=false&archived_only=false&workspace=<path>&sort=recent|name|size` |
| 785 | - `GET /v1/sessions/summary?…`(相同的查询参数;投影后的行形态) |
| 786 | - `GET /v1/sessions/{id}`(加上 `?peek=true&entries=12` 可得到有界的、已脱敏的 |
| 787 | 只读窥视,而不是完整转录)。完整响应会在某个回合以 `Failed` 结束时携带 |
| 788 | `turn_outcomes`:每次失败一条 `{ status, error, ended_at, |
| 789 | after_message_count }`,最旧的在前,最多 64 条,错误文本与转录中显示的一致, |
| 790 | 且已对密钥脱敏 |
| 791 | - `PATCH /v1/sessions/{id}`(`{ "title"?: string, "archived"?: bool }`) |
| 792 | - `DELETE /v1/sessions/{id}` |
| 793 | - `POST /v1/sessions/{id}/resume-thread` 返回已经持有整个已保存会话的打开线程 |
| 794 | (`200`);没有这样的线程时,从该会话播种一个新线程(`201`),包括会话在那个 |
| 795 | 线程打开它之后又有增长的情况。 |
| 796 | - `GET /v1/sessions/{id}/artifacts` 与 `GET /v1/sessions/{id}/artifacts/{artifact_id}?offset=&limit=` |
| 797 | (参见上文的工作区文件与会话工件;运行时回合的溢出通过 |
| 798 | `GET /v1/threads/{id}/turns/{turn_id}/artifacts/{artifact_id}` 读取) |
| 799 | - `POST /v1/sessions`(`{ "thread_id": string, "title"?: string }`)把线程导出为 |
| 800 | 已保存会话。它是幂等的:只写入 id 由该线程派生的那份文档,第一次创建它(`201`), |
| 801 | 之后更新它(`200`),所以重试永远不会产生重复。线程从中恢复而来的会话保持不变。 |
| 802 | - `PUT /v1/sessions`(`{ "thread_id"?: string, "session_id"?: string }`)保存线程的 |
| 803 | 实时对话。指名一个已绑定到另一个线程的 `session_id` 会返回 `409 Conflict`。 |
| 804 | - `GET /v1/sessions/repair` 返回最近一次会话存储修复的摘要;从未运行过时为 `null` |
| 805 | |
| 806 | 会话与线程对同一对 `include_archived` / `archived_only` |
| 807 | 给出含义相同的响应,并且 `search` 与 TUI 会话选择器及任务面板(workbar) |
| 808 | 中的 Sessions 列表使用的是同一个模糊匹配(标题、id、 |
| 809 | 工作区——先子串,再子序列)。三个表面运行同一套投影 |
| 810 | (`crates/tui/src/session_projection.rs`),因此列表在 |
| 811 | 终端与仪表盘之间不可能有差异。 |
| 812 | |
| 813 | `GET /v1/sessions/summary` 返回的行与 |
| 814 | `GET /v1/threads/summary` 字段兼容——`id`、`title`、`preview`、`model`、`mode`、 |
| 815 | `workspace`、`archived`、`updated_at`——另加 `message_count`、`total_tokens`、 |
| 816 | `created_at`、`parent_session_id` 与 `is_current`。有一个需要直说的注意点: |
| 817 | `preview` 是该会话记录的**标题**,不是它的最后一条消息。会话 |
| 818 | 元数据不存储最后一条消息,而为了合成一条而读取每个转录 |
| 819 | 会让列表视图变成无界读取。完整转录 |
| 820 | 预览位于 TUI 会话选择器中,它只读取所选的那一个会话。 |
| 821 | |
| 822 | `PATCH /v1/sessions/{id}` 重命名并/或归档一个已保存会话,并返回一个 |
| 823 | 形态与线程 patch 回执相同的生命周期回执: |
| 824 | |
| 825 | ```json |
| 826 | { |
| 827 | "session": { "id": "…", "title": "Renamed", "archived": true, "…": "…" }, |
| 828 | "changes": { "title": "Renamed", "archived": true } |
| 829 | } |
| 830 | ``` |
| 831 | |
| 832 | `changes` 只列出实际发生变动的部分,因此空操作(no-op)patch 与 |
| 833 | 已生效的 patch 可以被区分开来。归档是持久且可逆的:已归档会话 |
| 834 | 仍保留在磁盘上且仍可加载,只是从默认列表中消失,并且 |
| 835 | 永不会被 `--continue` 或自动恢复选中。该路由与 TUI 选择器(`e`)及 |
| 836 | `/sessions archive <id>` 用的是同一个写入方——不存在第二套 |
| 837 | 归档概念。 |
| 838 | |
| 839 | 当一个会话在某个交互式 Codewhale 进程中处于打开状态时,该进程持有 |
| 840 | 内存中的权威副本,并在下一次自动保存时重写整个文档。因此对它的 `PATCH`、`PUT` |
| 841 | 与 `DELETE` 会失败关闭并返回 `409 Conflict`,而不是写入一个会被静默回滚的内容。 |
| 842 | 请在终端中修改它。打开它的进程持有该会话的锁(`sessions/.late-usage/<id>.live`), |
| 843 | 所以无论请求到达的是该进程内部的 API,还是另一个独立的 `codewhale serve`,这一点都成立。 |
| 844 | |
| 845 | 会话存储会在每次启动和每次 `codewhale serve` 启动时在后台修复。修复会为 Runtime |
| 846 | 存储中每个没有绑定任何会话的线程分配一个“Recovered:”会话。它会解除那些会话文档 |
| 847 | 已消失的线程的绑定;这些线程随后从它们自己的回合加载。它会把不可读的文档、空的 |
| 848 | 未绑定存储,以及没有任何会话引用的旧工件目录移到 `sessions/.set-aside/<run>/`, |
| 849 | 并在那里写入一份 `MANIFEST.jsonl`。不会删除任何东西。`GET /v1/sessions/repair` |
| 850 | 与 `codewhale doctor` 报告最近一次运行;`codewhale doctor --repair-sessions [--dry-run]` |
| 851 | 按需运行一次。 |
| 852 | |
| 853 | `GET /v1/sessions/{id}?peek=true` 返回一个有界的、已脱敏的、只读的视图, |
| 854 | 而不是转录本身:最多 12 个条目、每个最多 400 个字符 |
| 855 | (`&entries=N` 只会调低预算,绝不会把它抬过上限),工具调用与 |
| 856 | 结果被概括为一个名称和一个大小而不是内联展开,凭据形态的 |
| 857 | 子串会被掩码。`omitted_before` 报告有多少更早的消息被丢弃。 |
| 858 | 负载携带 `"live": false`,并有意不包含回合状态、`running` 或 `active` 字段——已保存的会话是一份录像, |
| 859 | 实时状态只来自已恢复线程的 SSE 流。 |
| 860 | |
| 861 | **线程**(持久运行时数据模型) |
| 862 | - `GET /v1/threads?limit=50&include_archived=false&archived_only=false` |
| 863 | - `GET /v1/threads/summary?limit=50&search=<optional>&include_archived=false&archived_only=false` |
| 864 | - `GET /v1/threads/running` |
| 865 | - `GET /v1/threads/{id}/notices` |
| 866 | - `DELETE /v1/threads/{id}/notices/{notice_id}` |
| 867 | - `POST /v1/threads` |
| 868 | - `GET /v1/threads/{id}` |
| 869 | - `PATCH /v1/threads/{id}`(请求体形态见下文) |
| 870 | - `POST /v1/threads/{id}/resume` |
| 871 | - `POST /v1/threads/{id}/fork` |
| 872 | - `GET /v1/threads/{id}/receipt` — 线程做了什么,每个动作一条 |
| 873 | (只读;形态见 [RECEIPTS.md](../RECEIPTS.md)) |
| 874 | - `GET /v1/threads/{id}/turns/{turn_id}/receipt` — 同上,针对一个回合; |
| 875 | 未知线程或不属于该线程的回合返回 `404` |
| 876 | |
| 877 | `POST /v1/threads` 除了提供商、模型、工作区与权限字段外,还接受可选的执行默认值: |
| 878 | |
| 879 | ```json |
| 880 | { |
| 881 | "model_provider": "openai-codex", |
| 882 | "model": "gpt-5.6", |
| 883 | "reasoning_effort": "high", |
| 884 | "allowed_tools": ["read_file", "search"] |
| 885 | } |
| 886 | ``` |
| 887 | |
| 888 | `reasoning_effort` 使用规范的 Runtime 词汇表(`auto`、`off`、 |
| 889 | `low`、`medium`、`high`、`xhigh`、`ultra` 或 `max`;已记录的兼容 |
| 890 | 别名会被接受,并以规范形式持久化)。`allowed_tools` 是一份对模型可见的允许名单。 |
| 891 | 省略它会保留常规的已配置目录;显式空数组(`"allowed_tools": []`) |
| 892 | 则不会向模型暴露任何工具。 |
| 893 | 两个字段都是加性(additive)的:省略它们的旧线程记录与客户端 |
| 894 | 保持之前的行为。 |
| 895 | |
| 896 | `GET /v1/threads/summary` 是 VS Code Agent View 使用的只读摘要表面。 |
| 897 | `search` 匹配线程的 `id`、`title` 与 `model`(当标题未设置时,还会匹配最近一个回合的 |
| 898 | 输入摘要——即被展示的标题)。它不扫描回合或条目正文:`preview` 只在 |
| 899 | 命中之后才被填充,因此仪表盘上一次按键不是逐线程的全量存储读取。每个条目包含 |
| 900 | `id`、`title`、`preview`、`model`、`mode`、`archived`、`updated_at`、 |
| 901 | `latest_turn_id`、`latest_turn_status`,再加工作区元数据: |
| 902 | |
| 903 | ```json |
| 904 | { |
| 905 | "id": "thread_...", |
| 906 | "title": "Implement MCP status count", |
| 907 | "preview": "The TUI footer should count project MCP servers...", |
| 908 | "model": "deepseek-v4-pro", |
| 909 | "mode": "agent", |
| 910 | "branch": "feature/runtime-api", |
| 911 | "head": "abc1234", |
| 912 | "dirty": false, |
| 913 | "workspace": "/Users/you/projects/codewhale", |
| 914 | "archived": false, |
| 915 | "updated_at": "2026-06-06T05:43:00Z", |
| 916 | "latest_turn_id": "turn_...", |
| 917 | "latest_turn_status": "completed" |
| 918 | } |
| 919 | ``` |
| 920 | |
| 921 | `branch` 在请求时从线程工作区解析得到,当工作区不是 Git 仓库或 |
| 922 | 分支无法读取时可能为 `null`。 |
| 923 | `head` 是该工作区当前可得的短 Git commit。 |
| 924 | `dirty` 在工作区有已暂存、未暂存或未跟踪的改动时为 true。 |
| 925 | 包含 `workspace` 是为了让编辑器客户端能显示某个智能体通道何时在 |
| 926 | 当前 VS Code 文件夹之外工作。 |
| 927 | |
| 928 | 线程 fork 是兄弟运行时线程,不是就地(in-place)的树投影。 |
| 929 | `thread.forked` 事件包含 `source_thread_id`;内部的回溯感知 |
| 930 | fork 还可能包含 `backtrack_depth_from_tail` 与 `dropped_turn_id`,而 |
| 931 | 锚定到具名回合的 fork(`/fork-at-turn`)会报告它们,其深度 |
| 932 | 从该回合解析得出,并点名它丢弃的第一个用户回合(不是锚点,具名回合 fork 会保留锚点, |
| 933 | 也不是夹在两者之间的无提示词回合,比如一次手动压缩)。 |
| 934 | 在 v0.8.40 中,线程列表与摘要响应仍是扁平的,因此需要 |
| 935 | 图的客户端应当从事件重建它,而不是假定列表顺序就是 |
| 936 | 一棵完整的树。 |
| 937 | |
| 938 | `GET /v1/threads/running` 是进行中工作的核算表面 |
| 939 | (#6180):至少有一个排队或进行中回合的线程,每个都带有 |
| 940 | `thread_id`、`model`、`title` 与 `active_turns`(`turn_id` + `status`)。 |
| 941 | 具备后台能力的客户端用它做退出/转后台决策——一次调用, |
| 942 | 不需要从最近回合状态去推断。归档状态被忽略(归档 |
| 943 | 没有静默门槛);空数组意味着没有自己拥有的活动工作。 |
| 944 | |
| 945 | `GET /v1/threads/{id}/notices` 是逐线程的活动通知表面 |
| 946 | (#6180):TUI 可见、且只读客户端必须呈现的那些状况—— |
| 947 | `subagent-terminal`(一个子智能体已完结)、`elevation-needed`(一个工具调用 |
| 948 | 被提权拦住)、`model-notify`(模型请用户回来)——各自带有 `turn_id` 与一个用于定位的 `subject` id。 |
| 949 | 通知是内存中的会话状态,每个线程最多 32 条(最旧的被淘汰), |
| 950 | 且永不持久化。清除:提权在它的工具调用 |
| 951 | 完成时自动清除;terminal/notify 通过 `DELETE .../notices/{notice_id}` 清除 |
| 952 | (204,未知 id 返回 404)。未知线程在两个端点上都是 404。 |
| 953 | |
| 954 | `archived_only=true` 只返回已归档线程(互斥地覆盖 |
| 955 | `include_archived`)。默认行为不变:`include_archived=false` |
| 956 | 与 `archived_only=false` 返回活动线程。于 v0.8.10 加入(#563)。 |
| 957 | |
| 958 | `PATCH /v1/threads/{id}` 请求体——每个字段都是可选的,缺失 |
| 959 | 表示“不改变”。至少必须有一个字段存在。`title` 与 `system_prompt` |
| 960 | 接受空字符串,用于清除先前设置的值。于 v0.8.10 加入(#562): |
| 961 | |
| 962 | ```json |
| 963 | { |
| 964 | "archived": true, |
| 965 | "allow_shell": false, |
| 966 | "trust_mode": false, |
| 967 | "auto_approve": false, |
| 968 | "model": "deepseek-v4-pro", |
| 969 | "mode": "agent", |
| 970 | "title": "User-set thread title", |
| 971 | "system_prompt": "You are a useful assistant.", |
| 972 | "model_provider": "custom", |
| 973 | "model_provider_id": "lm-studio" |
| 974 | } |
| 975 | ``` |
| 976 | |
| 977 | `model_provider` 切换该线程未来回合所用的提供商。它 |
| 978 | 接受内置种类(`deepseek`、`xai`、...)或已配置的路由名,与 |
| 979 | `/provider` 一样。`model_provider_id` 指名一个精确的 `[providers.<id>]` 表, |
| 980 | 并优先于路由名。目标路由会被解析,其客户端会在保存任何内容之前 |
| 981 | 被预检,因此未知或没有凭据的 |
| 982 | 提供商会遭拒绝,且什么都不会改变。若不带 `model`,线程会采用 |
| 983 | 新提供商的默认模型;`auto` 线程保持 `auto`。已加载的 |
| 984 | 引擎与会话历史被保留,下一个回合会装上新的 |
| 985 | 路由。 |
| 986 | |
| 987 | **回合**(线程内的) |
| 988 | - `POST /v1/threads/{id}/turns` |
| 989 | - `POST /v1/threads/{id}/turns/{turn_id}/steer` - 向进行中的回合注入引导。响应是一份描述实际发生了什么的回执,而不是描述尝试过什么的回执;参见 [引导送达](#引导送达)。 |
| 990 | - `POST /v1/threads/{id}/turns/{turn_id}/interrupt` |
| 991 | - `GET /v1/threads/{id}/turns/{turn_id}/artifacts` - 回合产出了什么:类型化引用加上工作区 delta 状态。参见 [回合工件](#回合工件)。 |
| 992 | - `GET /v1/threads/{id}/turns/{turn_id}/artifacts/{artifact_id}?offset=&limit=&revision=` - 从工作区、post-turn 快照或会话工件目录读取一个引用。 |
| 993 | - `POST /v1/threads/{id}/compact`(手动压缩) |
| 994 | - `POST /v1/threads/{id}/undo` - 以去掉最后 N 个回合的方式 fork 线程(`{"depth": N}`,默认 0 = 仅最后一个回合);返回 fork 出的线程以及 `original_user_text`,以便 GUI 预填输入框 |
| 995 | - `POST /v1/threads/{id}/fork-at-turn` - 在一个具名用户回合处 fork(`{"turn_id": "turn_…"}`,即 `GET /v1/threads/{id}` 报告的那个)。该 fork *保留*那个回合及其之前的每个回合,丢弃其后的回合;因此指名最后一个回合会保留整个对话。回执与 `/undo` 相同(`thread`、`original_user_text`、`original_user_images`),携带*第一个被丢弃*的用户回合的提示词——即接下来被问的是什么,即使中间夹着一个无提示词回合(如一次手动 `/compact`)——这样客户端可以把它放回输入区供编辑。源线程、它的会话文档与工作区都不受影响,也没有文件回滚:fork 是一个兄弟对话,而回退工作区会把随之留下的分支一起回退。客户端应当指名回合,而不是计算 `depth`——它们渲染的转录与这里裁剪的回合列表不是同一个列表(引导、仅图片提示词与注入的交接各自只位于一侧),一个差一的客户端计数会在回答 `201` 的同时 fork 错前缀。当该回合不是该线程的用户回合时返回 `400`。 |
| 996 | - `POST /v1/threads/{id}/patch-undo` - 回滚被丢弃回合改动过的文件,随后做同样的 fork(`{"depth": N}`);除 fork 出的线程外还返回 `patch_result`(`files_restored`、`summary`、`snapshot_label`)。所有权、信任、准入与中止规则,以及拒绝时的 `error.code` 取值,参见 [工作区恢复端点](#工作区恢复端点)。 |
| 997 | - `POST /v1/threads/{id}/file-revert` - 从线程拥有的一个恢复点中恢复恰好一个文件(`{"path", "snapshot_id", "expected_hash"}`);永不 fork 对话。参见 [工作区恢复端点](#工作区恢复端点)。 |
| 998 | - `POST /v1/threads/{id}/retry` - 以去掉最后 N 个回合的方式 fork,并立即启动一个新回合(`{"depth": N, "prompt": "..."}`;`prompt` 覆盖原始用户文本,省略时复用后者) |
| 999 | |
| 1000 | `POST /v1/threads/{id}/turns` 接受与逐回合覆盖相同的可选 |
| 1001 | `reasoning_effort` 与 `allowed_tools` 字段: |
| 1002 | |
| 1003 | ```json |
| 1004 | { |
| 1005 | "prompt": "Review this change without running tools.", |
| 1006 | "operation_key": "cwc-request-01J7Y6Q9W4", |
| 1007 | "reasoning_effort": "max", |
| 1008 | "allowed_tools": [] |
| 1009 | } |
| 1010 | ``` |
| 1011 | |
| 1012 | 同一个回合上的 `model_provider` / `model_provider_id` 字段会把 |
| 1013 | 该回合经由另一个提供商路由。已保存的线程保持其提供商。 |
| 1014 | 若不带 `model`,该回合使用那个提供商的默认模型(`auto` 线程 |
| 1015 | 保持 `auto`)。该覆盖总是会被预检,并且是 |
| 1016 | `operation_key` 指纹的一部分。 |
| 1017 | |
| 1018 | 解析是确定性的:回合覆盖优先于线程默认值, |
| 1019 | 后者优先于 Runtime 的常规配置。对工具而言,落到常规 |
| 1020 | 配置意味着常规的已配置目录;`[]` 永不被当作 |
| 1021 | 缺失。推理只在精确的提供商/模型路由被解析之后才规范化, |
| 1022 | 并且即使线程使用固定模型,`auto` 仍然是逐提示词的推理决策。请求仍然进入既有的 |
| 1023 | `Op::SendMessage` 路径与单一的 `Engine::run_turn` 循环。 |
| 1024 | |
| 1025 | 图片输入使用同样的回合路径:`"images": [{"mime": "image/png", |
| 1026 | "dataBase64": "..."}]`。客户端必须先观察到 |
| 1027 | `/v1/runtime/info` 中的 |
| 1028 | `capabilities.turn_image_inputs: true`(或隔离的 |
| 1029 | Runtime Chat 中继目录)。较旧的 HTTP 运行时会忽略未知字段,所以 |
| 1030 | 文本响应成功并不证明附件被接受了。 |
| 1031 | 该字段为空时会被省略。app-server 的 |
| 1032 | `thread/message`、`thread/request` 消息与提示词请求也接受它;那个桥 |
| 1033 | 会在转发图片字节之前检查底层 Runtime 能力。 |
| 1034 | 旧式远程 Work 命令不支持图片,并显式拒绝它们。 |
| 1035 | |
| 1036 | 新的内联图片要求有一个具名模型,且其精确解析后的路由报告 |
| 1037 | `image_input: "supported"`;Auto 以及未知/不支持的图片路由 |
| 1038 | 会在分类器或提供商分发之前被拒绝。这不会改变 |
| 1039 | 能力未知的路由既有的受信任本地附件行为。 |
| 1040 | 提示词必须非空。输入限制为 10 张图片、每张解码后 4 MiB |
| 1041 | 字节、总计 5 MiB,以及 8 MiB 的 JSON 请求体。PNG、JPEG、GIF 与 WebP |
| 1042 | 必须 MIME 匹配、base64 为规范的填充形式,且图片内容有效且有界: |
| 1043 | 每维最多 8192 像素、总计 33,554,432 像素、解码器分配 64 MiB。 |
| 1044 | Runtime 不会从这个字段去取路径或 URL。 |
| 1045 | 格式错误的图片会拒绝整个回合;调用方可以保留草稿以便 |
| 1046 | 修正。中继命令轮询使用 8 MiB 的响应预算;发送方 |
| 1047 | 必须按序列化后的字节分页,且不得越过未送达的命令。 |
| 1048 | |
| 1049 | 被接受的图片字节与顺序会保留在既有的回合记录中,并在重启、导入与 fork 后 |
| 1050 | 重建。重试会保留这些图片, |
| 1051 | 即使其可选的 `prompt` 改变了文本;撤销响应在存在时包含 |
| 1052 | `original_user_images`。带图片的记录要求 schema v3, |
| 1053 | 较旧的读取方会拒绝它。纯文本记录与操作指纹保留 |
| 1054 | 其先前表示。经验证的已存储本地图片保持既有的 |
| 1055 | 每张 5 MiB 上限以及导入/重试时既有的聚合/计数语义; |
| 1056 | 这套内部存储权限不会 |
| 1057 | 放宽精确的模型或权限检查。图片字节、MIME 与顺序参与请求 |
| 1058 | 身份,因此在同一个操作键下改变图片会冲突。 |
| 1059 | 压缩可以概括更早的上下文;保留原始附件 |
| 1060 | 并不承诺之后每次模型请求都包含它。图片像素不受 |
| 1061 | 文本密钥脱敏约束。 |
| 1062 | |
| 1063 | `operation_key` 是一个可选的幂等键,供那些可能在 Runtime 已接受回合后 |
| 1064 | 丢掉 HTTP 响应的客户端使用。它的作用域是当前 |
| 1065 | Runtime 存储与线程,最多 128 个 UTF-8 字节,且不得 |
| 1066 | 为空、不得包含首尾空白或控制字符。省略它 |
| 1067 | 会保留旧式的“创建一个新回合”行为。 |
| 1068 | |
| 1069 | 第一个被接受的请求会在发送既有的 `Op::SendMessage` 之前,把该键的 SHA-256 指纹持久地绑定到 |
| 1070 | Runtime 回合 id 与一份规范请求指纹上。一次精确的重试会在常规的 |
| 1071 | `{ "thread": ..., "turn": ... }` 响应中返回那个原始回合,并且不会再发出第二次引擎 |
| 1072 | 操作、条目或生命周期序列。在同一线程上用同一个键但不同的提供商/模型、 |
| 1073 | 提示词、推理策略、工具允许名单或 |
| 1074 | 动态工具 schema、环境或权限策略时,会失败关闭并返回 |
| 1075 | `409 Conflict`。同一个调用方键可以在另一个 |
| 1076 | 线程上独立使用。 |
| 1077 | |
| 1078 | Runtime 的私有回合操作索引中只存储限定范围的键指纹、请求指纹、线程 id 与回合 id。 |
| 1079 | 原始键永不被 |
| 1080 | 持久化或记录到日志,且请求体、凭据与附件不会被 |
| 1081 | 复制进该索引。既有的线程/回合持久化仍是进程重启后 |
| 1082 | 返回那个回合的来源。 |
| 1083 | |
| 1084 | **精确的已接受回合查询** |
| 1085 | |
| 1086 | `GET /v1/threads/{id}/turn-operations/{operation_key}` 使用与回合提交相同的 Runtime |
| 1087 | 认证。请对每个路径段做 URL 编码。它返回 |
| 1088 | `200 OK` 与既有的裸 `TurnRecord`(即 POST |
| 1089 | 响应中的 `turn` 对象),由那个精确的线程与操作键标识。它不使用 |
| 1090 | 线程的最近回合,也不要求原始请求体或当前路由 |
| 1091 | 设置与之匹配。 |
| 1092 | |
| 1093 | - `404 Not Found`:该线程/键不存在绑定,或持久化的身份 |
| 1094 | 不匹配。这些情况共用同一个泛化响应。 |
| 1095 | - `409 Conflict`:准入持有该操作声明,或其持久绑定 |
| 1096 | 不完整。请重试查询;该响应并不授权另一个回合。 |
| 1097 | - `400 Bad Request`:线程 ID 或操作键格式错误。键使用 |
| 1098 | 与 POST 相同的 128 字节与空白/控制字符规则。 |
| 1099 | - `500 Internal Server Error`:存储或既有的声明锁无法 |
| 1100 | 被安全检查。这并不证明该操作不存在。 |
| 1101 | |
| 1102 | 该查询在读取绑定与回合时,对既有的操作声明持有一把共享读锁。 |
| 1103 | 它不创建文件、不启动引擎、不发事件,也不做任何回放或恢复。 |
| 1104 | Runtime 的正常启动可能在之后的某次查询之前恢复一次不完整的准入,但 GET 本身永不做这件事。 |
| 1105 | |
| 1106 | **审批** |
| 1107 | - `POST /v1/approvals/{approval_id}`,请求体 |
| 1108 | `{ "decision": "allow" | "deny", "remember": false }` |
| 1109 | |
| 1110 | `approval_id` 由 Runtime 铸造,而不是由模型或提供商铸造。它是一个 |
| 1111 | 不透明的 `approval_<32 hex>` 能力,每个提示词唯一,绑定到发起它的线程, |
| 1112 | 且只能使用一次:当决策被送达、当提示词超时、或当回合放弃它时, |
| 1113 | Runtime 会移除它。客户端回显它所得到的值,不得自行构造、推导或猜测。 |
| 1114 | |
| 1115 | 它有意**不是**提供商的工具调用 ID。提供商每次响应都会重置自己的 |
| 1116 | 调用 ID 计数器,因此两个线程可能门控原始 ID 字节级相同的调用; |
| 1117 | 用那个值作为审批键会让一个线程的决策去解决另一个线程的调用。因此该端点只对铸造出的 ID |
| 1118 | 做一次精确匹配,且没有回退:一个原始工具调用 ID、一个过期的 ID,或一个已经 |
| 1119 | 被解决过的重放 ID 都返回 `404`,也到不了引擎。`404` |
| 1120 | 意味着该能力当前并非待决——它不是关于该审批如何被解决的证据; |
| 1121 | 那要看 `approval.decided`。 |
| 1122 | |
| 1123 | 提供商的原始调用 ID 另行以 `tool_call_id` 出现在 |
| 1124 | `pending_approvals[]` 与审批事件上。它是用于把提示词 |
| 1125 | 挂到它所门控的工具行上的关联符,绝不会被当作决策接受。 |
| 1126 | 每个线程详情中的 `pending_approvals[]` 条目是 |
| 1127 | `{ "id", "turn_id", "tool_name", "description", "intent_summary"?, "tool_call_id"?, "summary"? }`, |
| 1128 | 其中 `id` 就是上文那个能力。`summary`(也出现在 `approval.required` 上)是 |
| 1129 | 对该受门控调用的一行描述,只由工具名与其参数构建, |
| 1130 | 绝不来自模型文本(“Search the web for 'espresso'”、 |
| 1131 | “Write notes/espresso.md”);工作区内的路径为工作区相对路径。 |
| 1132 | 客户端应先展示它,并把原始参数留在其后。对任务与自动化的创建/更新, |
| 1133 | `summary` 还会写出所请求的信任模式、shell、自动批准、模式和工作区。 |
| 1134 | |
| 1135 | 在 `allow` 上带上 `"remember": true` 会为该工具及其参数类别记录一份**会话授权** |
| 1136 | (审批分组键:对简单的已知命令(例如 `git status`,其选项都是 `-s` 或 `--porcelain` |
| 1137 | 这类不带值的选项)是一个 shell 命令族——复合命令、包装命令、解释器或无法识别的命令, |
| 1138 | 带有任何其他选项的命令,或其参数就是要运行或安装的东西的命令(`go run`、`make`、 |
| 1139 | `git bisect`、包安装),按完整的规范化命令授予;shell 交互或等待调用按精确调用授予—— |
| 1140 | 一个 patch 的文件集、一个 `fetch_url` 主机、一个 MCP 工具、一种 `web.run` 动作类型——对 |
| 1141 | `open` 而言是它打开的那些主机)。Computer Use 同意与 `app_script` 调用,以及 |
| 1142 | 任何没有类别的工具,都只针对那一次精确调用授予。授权永不 |
| 1143 | 改变线程的权限姿态。该线程上之后匹配的调用无需提示词即被批准: |
| 1144 | 它们仍会发出 `approval.required`,随后是 |
| 1145 | 带 `"auto": true` 与 `grant_id` 的 `approval.decided`。创建授权会 |
| 1146 | 发出 `approval.grant_added`,携带 `{ "grant": { "grant_id", "tool_name", |
| 1147 | "scope", "summary", "granted_at" } }`;线程详情会在 |
| 1148 | `approval_grants[]` 中列出活动授权。`DELETE /v1/threads/{id}/approval-grants/{grant_id}` |
| 1149 | 撤销其中一个(发出 `approval.grant_revoked`);下一次匹配的调用 |
| 1150 | 会再次提示。归档或删除线程会结束它的所有授权 |
| 1151 | (归档会为每个授权发出 `approval.grant_revoked`;取消归档不会 |
| 1152 | 恢复它们)。授权在 Runtime 进程内是内存态:重启 |
| 1153 | 就会忘掉它们,而一个强制的(不可绕过的)提示永不会被授权回答。 |
| 1154 | |
| 1155 | **用户输入** |
| 1156 | - `POST /v1/user-input/{thread_id}/{input_id}`,请求体 |
| 1157 | `{ "answers": [{ "id": "question-id", "label": "Choice", "value": "Choice" }] }` |
| 1158 | |
| 1159 | 提交的值会被送达活动的模型回合,但会被有意 |
| 1160 | 排除在持久 Runtime 条目与事件之外。已结算的工具条目只包含 |
| 1161 | 一份中性回执与一个机器可读的 `response_redacted` 标记。 |
| 1162 | Runtime 只接受精确待决的 `(thread_id, input_id)` 请求;一个 |
| 1163 | 未知的、正在并发结算的或已经结算的 id 返回 404,且永不会 |
| 1164 | 被放入引擎邮箱。它会在移除快照权威(snapshot-authoritative)的提示 |
| 1165 | 或把答案送达引擎之前,先提交无密钥的 |
| 1166 | `user_input.answered` 回执。该结算独立于 |
| 1167 | HTTP 连接运行,因此在提交后断开连接不会留下一个半接受的提示。 |
| 1168 | 终态回合取消通过 `user_input.canceled` 遵循同样的“先回执后 |
| 1169 | 移除”顺序。 |
| 1170 | |
| 1171 | **客户端执行的动态工具** |
| 1172 | - `POST /v1/threads/{thread_id}/turns/{turn_id}/tool-calls/{call_id}/result` |
| 1173 | |
| 1174 | 结果路由中的线程与回合必须与待决调用匹配。一次调用 |
| 1175 | 最多结算一次;错误路由与重复结果返回 404。终态 |
| 1176 | 生命周期事件只携带标识符与状态,绝不携带工具结果内容。 |
| 1177 | Runtime 会在把提交的结果变为模型可用之前先提交终态生命周期事件。 |
| 1178 | 结果送达、超时与终态回合 |
| 1179 | 取消通过同一个结算所有者竞争,因此对一次调用而言,下列事件中恰好有一个是持久化的: |
| 1180 | |
| 1181 | - `tool_call.requested` — 该带类型的客户端执行调用变成待决; |
| 1182 | - `tool_call.resolved` — 结果已被 Runtime 持久接受 |
| 1183 | (`result_accepted: true`;`success` 是结果元数据,但结果内容 |
| 1184 | 被排除); |
| 1185 | - `tool_call.timeout` — 在有界等待到期前没有结果胜出; |
| 1186 | - `tool_call.canceled` — 在某个提交结果胜出之前回合已终止。 |
| 1187 | |
| 1188 | HTTP `202 Accepted` 与 `tool_call.resolved` 共享“持久接受”这层含义。 |
| 1189 | 两者都不声称模型消费了该结果:一次并发的回合 |
| 1190 | 关闭可能在接受之后关掉模型接收端。一旦 Runtime |
| 1191 | 接受了结果,该调用就是终态的,重复结果返回 404。 |
| 1192 | |
| 1193 | **事件**(SSE 回放 + 实时流) |
| 1194 | - `GET /v1/threads/{id}/events?since_seq=<u64>&replay_limit=<n>&progress=true` |
| 1195 | |
| 1196 | 游标: |
| 1197 | |
| 1198 | - `since_seq` 是逐线程的游标:发送 `seq > since_seq` 的事件。省略它(且没有 |
| 1199 | `Last-Event-ID`)时,流从线程历史的开头开始。 |
| 1200 | - 每个日志帧都带有 `id: <seq>`,因此浏览器的 `EventSource` 可以通过它在重连时 |
| 1201 | 发送的 `Last-Event-ID` 头恢复。显式的 `since_seq` 优先于该头,所以一次有意 |
| 1202 | 从 `0` 开始的回放永远不会被过期的 id 覆盖。不是十进制整数的头值会被忽略。 |
| 1203 | - `replay_limit`(最多 4096)只返回所请求历史的最新尾部;第一个返回事件上的 |
| 1204 | `previous_seq` 会精确越过被省略的那段历史。 |
| 1205 | |
| 1206 | 持久历史的解析在异步服务器 worker 之外运行,并通过一个有背压的通道, |
| 1207 | 以最多 256 个事件的有界批次送达 SSE。广播 |
| 1208 | 送达只是一次唤醒优化:一个落后的接收方会从它最后接受的游标 |
| 1209 | 打开同一个有界持久回放。 |
| 1210 | |
| 1211 | `progress=true` 会在当前游标处添加 `stream.progress` 传输帧 |
| 1212 | (`{schema_version, event, kind, thread_id, seq, state}`,`state` 为 `replaying` |
| 1213 | 或 `live`),并以 `x-codewhale-event-progress: 1` 声明它们。只有在持久历史和 |
| 1214 | 已排队的实时尾部都被排空之后,流才报告 `live`;广播落后后的恢复会让它回到 |
| 1215 | `replaying`。进度帧永远不携带新的序号。 |
| 1216 | |
| 1217 | 流打开之前的失败是普通的 HTTP 错误,带 JSON 错误体,从不是 SSE: |
| 1218 | |
| 1219 | | 状态 | 何时 | |
| 1220 | | --- | --- | |
| 1221 | | `401` / `403` | 缺少 Runtime 凭据或凭据错误 | |
| 1222 | | `404` | 未知线程 | |
| 1223 | | `400` | `replay_limit` 超过 4096 | |
| 1224 | | `500` | 无法打开持久历史(包括第一个游标之前回放 worker 崩溃) | |
| 1225 | |
| 1226 | 一旦响应为 `200`,服务器主动选择的每一种结束都是最后一个 `stream.end` 帧; |
| 1227 | 参见 [结束与恢复线程流](#结束与恢复线程流)。 |
| 1228 | |
| 1229 | **快照**(side-git 恢复点列表 + 恢复) |
| 1230 | - `GET /v1/snapshots?limit=20` |
| 1231 | - `POST /v1/snapshots/{id}/restore` |
| 1232 | |
| 1233 | `/v1/snapshots` 列出运行时工作区最近的 side-git 恢复点。 |
| 1234 | `limit` 默认为 `20`,且必须在 `1` 与 `100` 之间。`POST |
| 1235 | /v1/snapshots/{id}/restore` 从快照恢复工作区文件, |
| 1236 | 并返回 `{"restored": "<snapshot-id>"}`。它是服务器自身工作区的直接操作者表面 |
| 1237 | (与 TUI 的 `/restore <N>` 是同一个动作):它 |
| 1238 | 由 Runtime API bearer 令牌门控,而不是由任何线程的信任标志门控,并且 |
| 1239 | 当有回合在重叠工作区中活动时,它会以 `409` 被拒绝(见 |
| 1240 | 下文)。会先拍一个 `pre-restore:` 安全快照。 |
| 1241 | |
| 1242 | ```json |
| 1243 | [ |
| 1244 | { |
| 1245 | "id": "snap_...", |
| 1246 | "label": "post-turn:1", |
| 1247 | "timestamp": 1780730580 |
| 1248 | } |
| 1249 | ] |
| 1250 | ``` |
| 1251 | |
| 1252 | ### 工作区恢复端点 |
| 1253 | |
| 1254 | 有三条路由会从 side-git 快照更改工作区文件。它们共享同一条 |
| 1255 | 准入规则与同一张安全网,区别在于范围与信任。 |
| 1256 | |
| 1257 | | 路由 | 范围 | 信任 | 是否 fork 线程 | |
| 1258 | | --- | --- | --- | --- | |
| 1259 | | `POST /v1/snapshots/{id}/restore` | 整个服务器工作区 | 仅需 bearer 令牌(操作者动作) | 否 | |
| 1260 | | `POST /v1/threads/{id}/patch-undo` | 被丢弃回合改动过的文件 | 当文件将被改动时需要线程 `trust_mode` 或 `auto_approve` | 是 | |
| 1261 | | `POST /v1/threads/{id}/file-revert` | 恰好一个常规文件 | 总是需要线程 `trust_mode` 或 `auto_approve` | 否 | |
| 1262 | |
| 1263 | **准入。** 一次恢复会预定 Runtime 用于配置重载与会话检查点的同一个准入, |
| 1264 | 因此在文件被重写期间,没有新回合会启动,也没有已保存的历史会变化。如果任何线程已经在同一工作区、其嵌套检出或其父目录中 |
| 1265 | 有活动回合,请求会以 `409` 被拒绝,消息为 |
| 1266 | `already has an active turn`。 |
| 1267 | 该预定由执行 Git 变更的 worker 拥有,因此一个在请求中途断开连接的客户端 |
| 1268 | 无法提前释放它;该操作要么整体完成,要么整体失败。并发恢复会串行化。 |
| 1269 | 该预定是运行时级别的:当一次恢复的安全快照与检出在运行时, |
| 1270 | 每个线程上的新回合、引导、压缩与用户输入送达都会等它结束, |
| 1271 | 因此一个大工作区可能在恢复期间为其他地方增加数秒延迟。 |
| 1272 | 工作区目录不可用(卷未挂载、共享断开、目录缺失)的线程会以 `409` 被拒绝, |
| 1273 | 而不是被当作没有东西可恢复。 |
| 1274 | |
| 1275 | **所有权。** 一个线程恰好拥有记录在它自己回合上的那些工作区恢复点。回合运行期间, |
| 1276 | 引擎报告它拍下的每个快照——回合之前的 `pre_turn`;每次可能写入的工具调用(所有 |
| 1277 | 不是只读的调用:文件工具、shell 命令、程序、可写的 MCP 工具)之前的 `tool` 和之后的 |
| 1278 | `post_tool`;回合结束时的 `post_turn`(总是在回合结算之前)——Runtime 按顺序把它 |
| 1279 | 追加到回合记录的 `workspace_snapshots`: |
| 1280 | |
| 1281 | ```json |
| 1282 | "workspace_snapshots": [ |
| 1283 | { "kind": "pre_turn", "snapshot_id": "<commit>", "tree_id": "<tree>", "session_id": "thr_1a2b3c4d" }, |
| 1284 | { "kind": "tool", "snapshot_id": "<commit>", "tree_id": "<tree>", "session_id": "thr_1a2b3c4d", "tool_call_id": "call_…", "write_paths": ["src/lib.rs"], "changed_paths": [] }, |
| 1285 | { "kind": "post_tool", "snapshot_id": "<commit>", "tree_id": "<tree>", "session_id": "thr_1a2b3c4d", "tool_call_id": "call_…", "changed_paths": ["src/lib.rs"] }, |
| 1286 | { "kind": "post_turn", "snapshot_id": "<commit>", "tree_id": "<tree>", "session_id": "thr_1a2b3c4d", "changed_paths": [] } |
| 1287 | ] |
| 1288 | ``` |
| 1289 | |
| 1290 | `changed_paths` 列出自回合上一个回执以来内容发生变化的工作区相对路径——即该回执 |
| 1291 | 所关闭的那段时间里发生的事;它在 `pre_turn` 上不存在,无法计算时(中间某个快照失败) |
| 1292 | 也不存在。`write_paths` 设置在文件工具(`write_file`、`edit_file`、`apply_patch`)的 |
| 1293 | `tool` 回执上,值为该调用声明的路径,按它给出的写法;没有它的工具(shell 命令) |
| 1294 | 可能写入任何路径。在用户 shell 回合上,`pre_turn` 回执携带该命令的 `tool_call_id`, |
| 1295 | 因为命令从它一直运行到 `post_turn`。 |
| 1296 | |
| 1297 | 每个回执也会作为 `turn.workspace_snapshot` 事件发布(负载就是该回执)。引擎在线程 |
| 1298 | 自己的 id 下运行每个 Runtime 线程,跨越重启和引擎逐出,所以对线程自己运行的回合, |
| 1299 | `session_id` 就是线程 id;它不跟随线程的已保存会话绑定(`PUT`/`POST /v1/sessions`、 |
| 1300 | 恢复),后者只是命名一份文档。fork 会克隆其来源的回合记录,因此拥有它继承的那些 |
| 1301 | 回合的恢复点。同一工作区中另一个线程或 TUI 会话的快照永远不是候选。`tree_id` 是 |
| 1302 | 持久身份:修剪会重建 side 仓库并重写每个 commit id,但保留每棵树,而恢复点只解析为 |
| 1303 | 具有相同树、会话标签和种类的已存储快照。每次拍快照后的数量修剪会保留最新的 50 个 |
| 1304 | 快照加上最新的 50 个回合边界(`pre-turn:`/`post-turn:`),所以一个工具调用多于此数的 |
| 1305 | 回合,或来自另一个线程的突发,永远不会把最近回合自己的恢复点挤出去。在回执出现之前 |
| 1306 | 记录的回合、由 `resume-thread` 导入的回合,以及在快照关闭或不可用时运行的回合, |
| 1307 | 都没有恢复点。 |
| 1308 | |
| 1309 | **安全网。** 每次恢复都会先记录当前工作区的一个 `pre-restore:<target>` 快照。 |
| 1310 | 该标签永不会是 `/undo`、`patch-undo` 或 |
| 1311 | `file-revert` 的候选,因此这张网不会改变之后的撤销选择什么。 |
| 1312 | 对 `file-revert` 而言,备份是强制的:如果它无法被写入,或 |
| 1313 | 请求的文件被它排除(例如被 `.gitignore` 排除),请求 |
| 1314 | 失败且什么都不改变。 |
| 1315 | |
| 1316 | **`patch-undo`。** 撤销整个回合。对每个被丢弃回合的 `pre_turn` → `post_turn` |
| 1317 | 窗口,两个快照之间不同的路径必须全部属于该回合自己:只在该回合某次工具调用的时间段 |
| 1318 | 内改变(一个 `tool` → `post_tool` 时间段,或 shell 回合的整个窗口),并且在文件工具的 |
| 1319 | 时间段内,是该调用声明过的路径。在回合的任何工具都不可能写入它的时候改变的路径—— |
| 1320 | 另一个线程、编辑器、后台进程——是别人的改动,撤销会被拒绝,而不是把它回退。回合的 |
| 1321 | 每个路径都恢复到改动它的第一个被丢弃回合之前的内容,其他任何东西都不碰,所以同一 |
| 1322 | 工作区里用户或另一个线程之后的工作得以保留。然后对话完全按 `/undo` 的方式 fork。 |
| 1323 | |
| 1324 | 快照从不保存被工作区 `.gitignore` 文件或内置快照排除项(`node_modules/`、`target/`、 |
| 1325 | `dist/`、构建缓存、二进制产物)排除的路径,也不保存工作区之外的路径。声明了此类路径的 |
| 1326 | 被丢弃文件工具调用会以 `path_not_snapshotted` 被拒绝,因为没有快照能把它放回去。 |
| 1327 | shell 命令不声明路径:它在被排除路径下写入的东西(构建输出、依赖安装)不在 |
| 1328 | `patch-undo` 恢复的范围内,也不会被报告。`201` 意味着文件已被恢复 |
| 1329 | (`files_restored: true`,`summary` 中每个文件一行 `<action> <path>`, |
| 1330 | `snapshot_label` 指名 pre-turn 快照),或可证明没有任何东西可恢复 |
| 1331 | (`files_restored: false`):每个被丢弃的回合都在这里没有调用工具就运行完、没有改动 |
| 1332 | 文件,或它的文件已经回到回合前的内容。任何无法恢复的情况都会以 `409` 中止整个撤销, |
| 1333 | 什么都不改变,也不发布 fork;`error.code` 说明原因: |
| 1334 | |
| 1335 | | `error.code` | 含义 | |
| 1336 | | --- | --- | |
| 1337 | | `restore_point_unavailable` | 某个可能改动过文件的被丢弃回合没有完整记录的恢复点(较旧的记录、由 `resume-thread` 导入、快照关闭,或快照失败) | |
| 1338 | | `restore_point_pruned` | 恢复点已不在快照存储中 | |
| 1339 | | `path_not_snapshotted` | 某个被丢弃的文件工具调用写入了快照不保存的路径(被忽略、内置排除,或在工作区之外) | |
| 1340 | | `workspace_changed_since_turn` | 这些回合改动过的某个路径之后又被改动(或在两个被丢弃回合之间被改动)、某个路径在被丢弃回合运行期间但在其自身工具调用之外被改动,或某个路径不是常规文件 | |
| 1341 | | `restore_requires_trust` | 有东西要恢复,而线程不处于受信任模式或 Full Access | |
| 1342 | | `workspace_unavailable` | 工作区目录不可用 | |
| 1343 | |
| 1344 | 对前四种情况,客户端可以改为提供只作用于对话的 `POST /v1/threads/{id}/undo`, |
| 1345 | 以及针对单个文件的 `file-revert`。快照仓库、列表或比较失败以 `500` 中止,同样保留 |
| 1346 | 对话,因此一个回合永远不会在其文件改动仍留在磁盘上时被丢弃。深度与历史在任何文件 |
| 1347 | 改动之前都被校验。如果文件已恢复后 fork 无法被持久化,响应是一个 `500`,并点名被 |
| 1348 | 恢复的那个快照;原线程仍持有该回合,而 `pre-restore:` 快照持有先前的文件。 |
| 1349 | |
| 1350 | **`file-revert`。** 请求体: |
| 1351 | |
| 1352 | ```json |
| 1353 | { |
| 1354 | "path": "src/lib.rs", |
| 1355 | "snapshot_id": "3f2a…40-or-64 hex…", |
| 1356 | "expected_hash": "sha256:<64 lowercase hex digits>" |
| 1357 | } |
| 1358 | ``` |
| 1359 | |
| 1360 | - `path`:工作区相对路径,或线程工作区内的绝对路径。该 |
| 1361 | 名称是字面的(方括号、空格与 glob 字符都是文件名字节; |
| 1362 | Git 以 `--literal-pathspecs` 运行)。它必须指名一个常规文件:目录、 |
| 1363 | 路径中任何位置的符号链接以及 `.git` 组成部分都返回 `400`。 |
| 1364 | - `snapshot_id`:用户所选那次改动的精确 `tool` 或 `pre_turn` 恢复点,来自线程自己的 |
| 1365 | 回合记录:某个回执的 `snapshot_id` 或 `tree_id`(对工具调用而言,是 `tool_call_id` |
| 1366 | 匹配的那个回执),或 `GET /v1/snapshots` 为它列出的当前 commit id。它必须是记录在 |
| 1367 | 该线程某个回合上的恢复点;服务器永不自行挑选“最新的不同快照”,因为一个不相关的 |
| 1368 | 较新快照可能在保留工具改动的同时抹掉之后的用户编辑。 |
| 1369 | - `expected_hash`:客户端展示的当前文件字节的 `sha256:`, |
| 1370 | 或当客户端看到该文件为已删除时的 `absent`。它会在 |
| 1371 | 安全备份之前、以及在变更前一刻各检查一次。 |
| 1372 | |
| 1373 | 响应: |
| 1374 | |
| 1375 | - `200 {"path", "action", "snapshot_id", "snapshot_label"}` —— `action` 为 |
| 1376 | `modified`、`recreated`(文件此前缺失)或 `removed`(该快照 |
| 1377 | 不包含该文件,因此工具创建的文件被删除;其父 |
| 1378 | 目录留在原处)。 |
| 1379 | - `400`:格式错误的 `snapshot_id`/`expected_hash`、路径在工作区之外, |
| 1380 | 或两侧中任一侧不是常规文件的路径。 |
| 1381 | - `404`:未知线程。 |
| 1382 | - `409`:线程未处于受信任模式或 Full Access;重叠工作区中有活动回合; |
| 1383 | 工作区目录不可用;快照未知、已被修剪、未记录在该线程的回合上(属于另一个线程 |
| 1384 | 或某个 TUI 会话),或不是恢复点(请刷新改动记录);文件已经与快照 |
| 1385 | 一致(没有东西可回退);或文件在被审阅的 |
| 1386 | `expected_hash` 之后发生了变化(请刷新并重新审阅)。在这些情况下 |
| 1387 | 什么都不会被改动。 |
| 1388 | - `422`:请求体字段缺失或类型错误。 |
| 1389 | - `500`:Git 或文件系统失败;在安全快照之后的失败会点名 |
| 1390 | 那个快照,以便用 |
| 1391 | `POST /v1/snapshots/{id}/restore` 或 `/restore` 恢复先前的字节。 |
| 1392 | |
| 1393 | 能力探针:对这条路由做 `GET`,在端点存在的地方返回 `405`, |
| 1394 | 在较旧引擎上返回 `404`;客户端把任何非 `404` 都视为可用, |
| 1395 | 否则以说明降级。 |
| 1396 | |
| 1397 | **兼容流**(一次性、向后兼容) |
| 1398 | - `POST /v1/stream` |
| 1399 | |
| 1400 | **任务**(持久后台工作) |
| 1401 | - `GET /v1/tasks` |
| 1402 | - `POST /v1/tasks` |
| 1403 | - `GET /v1/tasks/{id}` |
| 1404 | - `POST /v1/tasks/{id}/cancel` |
| 1405 | |
| 1406 | **自动化**(按计划重复执行的工作) |
| 1407 | - `GET /v1/automations` |
| 1408 | - `POST /v1/automations` |
| 1409 | - `GET /v1/automations/{id}` |
| 1410 | - `PATCH /v1/automations/{id}` |
| 1411 | - `DELETE /v1/automations/{id}` |
| 1412 | - `POST /v1/automations/{id}/run` |
| 1413 | - `POST /v1/automations/{id}/pause` |
| 1414 | - `POST /v1/automations/{id}/resume` |
| 1415 | - `GET /v1/automations/{id}/runs?limit=20` |
| 1416 | |
| 1417 | 创建与更新请求接受一个可选的 `model`。存在时,每次 |
| 1418 | 按计划或手动触发的运行都使用该模型;省略它则保持 |
| 1419 | 运行时默认的任务模型。 |
| 1420 | |
| 1421 | **Operate**(常驻的具名操作;与 CWC |
| 1422 | `20de981` / PR #284 相同的 `OperateRecord`) |
| 1423 | |
| 1424 | - `GET /v1/operate` — 当前操作 + 计划看板 |
| 1425 | - `POST /v1/operate` — 创建(`direction`,可选 `burnRate`) |
| 1426 | - `PATCH /v1/operate` — 引导方向、`burnRate` 或 `leadPlan` |
| 1427 | - `PUT /v1/operate/plan` — 设置 `leadPlan`(`{ slices: [...] }`) |
| 1428 | - `POST /v1/operate/keepalive` — 观察消耗 / 燃烧;永不停止 |
| 1429 | - `POST /v1/operate/cancel` — 显式取消(`/v1/operate/stop` 为别名); |
| 1430 | 同时暂停 `cw-operate` keepalive,以免取消后还有东西继续花钱 |
| 1431 | - `POST /v1/operate/auto-merge/check` — 调用已落地的 |
| 1432 | `scripts/check-auto-merge.py --repo --pr --agent`(不做合并) |
| 1433 | |
| 1434 | 操作记录(`current.json`)在跨进程文件锁下持久化, |
| 1435 | 采用临时文件 + 重命名的原子写入;每次 PATCH / keepalive / 计划 |
| 1436 | 保存都会在锁内重新加载最新状态,因此并发保存会合并 |
| 1437 | 而不是丢失写入。一个改变 `direction` 的 `PATCH` 会使 |
| 1438 | 已记录的 `leadPlan` 失效(worker 停止执行被取代的切片),并 |
| 1439 | 把 keepalive 的 lead 运行提前以重新规划。`POST /v1/operate` |
| 1440 | 会装上每小时的 `cw-operate` keepalive,并立即启动它的第一次 lead-plan |
| 1441 | 运行,而不是等完第一个周期;凭据 |
| 1442 | 通过常规的 Z.ai 提供商解析获得(配置、`api_key_env`、 |
| 1443 | 密钥存储或提供商环境变量——空值算作缺失)。 |
| 1444 | |
| 1445 | `burnRate` 是 `{ "kind": "usd_per_hour", "amountUsdPerHour": number }`, |
| 1446 | 一个正数,或 `null`(无上限)。状态为 |
| 1447 | `planning | running | idle_blocked | cancelled`。节奏 |
| 1448 | (`unbounded | hold | throttle | widen`)不是状态:超目标 |
| 1449 | 则限流,低于目标则放宽,没有钱包上限式的停止。仅当 |
| 1450 | direction 为空、等待 lead 计划、缺少凭据或有 |
| 1451 | 人工门控时才是 idle-blocked。自动合并是来自 codewhale-ops `origin/main` 的 |
| 1452 | `scripts/check-auto-merge.py --repo … --pr … |
| 1453 | --agent …`(退出码 0),然后是 |
| 1454 | `scripts/auto-merge-pr.py`。不要再发明第二个检查器。 |
| 1455 | |
| 1456 | **内省** |
| 1457 | - `GET /v1/workspace/status` |
| 1458 | - `GET /v1/workspace/files/search?query=<partial>&limit=<1-100>`(参见上文的工作区文件建议) |
| 1459 | - `GET /v1/workspace/files?path=<dir>&limit=<1-2000>`、`GET /v1/workspace/files/read?path=<file>&offset=&limit=` |
| 1460 | 与 `PUT /v1/workspace/files`(参见上文的工作区文件与会话工件) |
| 1461 | - `GET /v1/skills` |
| 1462 | - `GET /v1/apps/mcp/servers` |
| 1463 | - `GET /v1/apps/mcp/tools?server=<optional>` |
| 1464 | |
| 1465 | 技能激活开关在跨进程事务锁下持久化。 |
| 1466 | 每次变更都会在原子写入前重新加载并合并最新的精确名称状态, |
| 1467 | 而 `GET /v1/skills` 会刷新那份共享状态,使另一个 Codewhale |
| 1468 | 进程的成功切换无需重启 Runtime API 就能可见。 |
| 1469 | |
| 1470 | **用量**(跨线程的 token/成本聚合) |
| 1471 | - `GET /v1/usage?since=<rfc3339>&until=<rfc3339>&group_by=<day|model|provider|thread>` |
| 1472 | |
| 1473 | `since` / `until` 是含端点的 RFC 3339 时间戳,可以省略(无 |
| 1474 | 边界)。`group_by` 默认为 `day`。桶按键升序排序。 |
| 1475 | 空时间范围产生空 `buckets`(绝不会是 404)。成本通过 |
| 1476 | 模型→定价映射计算;模型没有定价条目的回合贡献 |
| 1477 | token 但成本为 `0.0`。于 v0.8.10 加入(#564)。 |
| 1478 | |
| 1479 | ```json |
| 1480 | { |
| 1481 | "since": "2026-04-01T00:00:00Z", |
| 1482 | "until": "2026-04-30T23:59:59Z", |
| 1483 | "group_by": "day", |
| 1484 | "totals": { |
| 1485 | "input_tokens": 12345, |
| 1486 | "output_tokens": 6789, |
| 1487 | "cached_tokens": 0, |
| 1488 | "reasoning_tokens": 0, |
| 1489 | "cost_usd": 0.012, |
| 1490 | "turns": 42 |
| 1491 | }, |
| 1492 | "buckets": [ |
| 1493 | { |
| 1494 | "key": "2026-04-30", |
| 1495 | "input_tokens": 1234, |
| 1496 | "output_tokens": 678, |
| 1497 | "cached_tokens": 0, |
| 1498 | "reasoning_tokens": 0, |
| 1499 | "cost_usd": 0.001, |
| 1500 | "turns": 3 |
| 1501 | } |
| 1502 | ] |
| 1503 | } |
| 1504 | ``` |
| 1505 | |
| 1506 | ### 原生客户端路由(GPUI 桌面) |
| 1507 | |
| 1508 | 这些族在同一套 bearer 令牌传输上为 GPUI 桌面客户端服务。它们复用运行时既有的权威—— |
| 1509 | 引擎的 shell 管理器、持久线程存储、工作区限制层、 |
| 1510 | 配置的凭据管道——并不增加第二套运行时、会话存储、 |
| 1511 | 调度器或凭据存储。 |
| 1512 | |
| 1513 | **终端会话**(持久、由 Engine 拥有的 shell) |
| 1514 | |
| 1515 | 上面的 jobs 族每个作业运行一条命令。终端面板需要的是 |
| 1516 | **另一种**权威:智能体自己的终端工具所驱动的、有状态且基于 PTY 的 shell,它在多次输入之间保持 cwd 与环境。 |
| 1517 | 这些路由附着到那个会话,且从不创建会话——一个没有活动会话的名字返回 |
| 1518 | `404`,因为从一次 HTTP 请求中凭空变出一个 shell 会让客户端得到一个 |
| 1519 | Engine 并不知道的终端。输入可按路由归因: |
| 1520 | `input` 是客户端的写入通道,`terminal_send` 是智能体的。 |
| 1521 | |
| 1522 | - `GET /v1/terminal/{name}/output?cursor=<bytes>&max_bytes=<1-64KiB>&format= |
| 1523 | <base64|text>` — 可恢复的字节流。`{name, offset, next_cursor, |
| 1524 | total, dropped, encoding, data, running, exit_code}`:把 `next_cursor` |
| 1525 | 传回来即可继续;读取永不消费,因此多个客户端可以持有 |
| 1526 | 各自独立的游标;`dropped` 报告 512 KiB 环形缓冲区丢弃的字节数, |
| 1527 | 而超出 `total` 的游标会从 `total` 作答,而不是把它回显回来 |
| 1528 | - `POST /v1/terminal/{name}/input` — `{ "data", "encoding"? }`,默认 |
| 1529 | `base64`(精确字节),或用 `text` 传 UTF-8 → `{ "name", "written" }` |
| 1530 | - `POST /v1/terminal/{name}/resize` — `{ "rows", "cols" }` → 子进程绘制目标的内核 |
| 1531 | 窗口 |
| 1532 | - `POST /v1/terminal/{name}/kill` — 结束该 shell;通过 |
| 1533 | `output`(`running` / `exit_code`)观察退出,而不是看这次确认 |
| 1534 | |
| 1535 | `GET /v1/runtime/info` 声明 `terminal_stream`、`terminal_input`、 |
| 1536 | `terminal_resize` 与 `terminal_kill`。这四个在 Windows 与 OpenHarmony 构建上 |
| 1537 | 目前都是 `false`:其所有者仅支持 Unix,那些路由回答 `501`,因此客户端 |
| 1538 | 应当用这些布尔标志来门控终端控件,而不是通过一次失败请求去发现这一点。 |
| 1539 | 以下限制需要明说,否则读者会自行假设:没有 `wait_ms` 长轮询(请轮询游标), |
| 1540 | 被环形缓冲区丢弃的回滚内容随进程一起消失,重启后的 Engine |
| 1541 | 会报告没有会话,而不是假装重新附着;且 |
| 1542 | `@codewhale/runtime-sdk` 包还没有终端客户端封装——目前 |
| 1543 | 裸路由就是契约。 |
| 1544 | |
| 1545 | **作业**(操作者范围内的 shell 作业;终端表面) |
| 1546 | - `GET /v1/jobs` — 跨所有线程的每个活动与已知过期作业 |
| 1547 | - `GET /v1/threads/{id}/jobs` — 由一个线程的管理器拥有的作业: |
| 1548 | 模型启动的、子智能体启动的与客户端启动的合在一起 |
| 1549 | - `POST /v1/threads/{id}/jobs` — `{ "command", "cwd"?, "timeout_ms"?, |
| 1550 | "tty"?, "env"? }` → `201 { "job" }`;在线程投影出的沙箱策略下作为后台 shell 运行。 |
| 1551 | `tty: true` 会把 stderr 合并进 stdout, |
| 1552 | 并给命令一个终端(交互式程序必需); |
| 1553 | 后台作业永不会在 `timeout_ms` 时被杀掉。相对 `cwd` 在线程工作区内解析。 |
| 1554 | 未开启信任模式时,解析符号链接后的 `cwd` 必须仍在该工作区内,否则返回 `403`: |
| 1555 | 与 shell 工具不同,此路由不采用 `workspace_follow_symlinks` 或 `/trust add` 根目录, |
| 1556 | 所以指向工作区外的符号链接会被拒绝。作业在已解析并检查过的目录中运行, |
| 1557 | 之后重定向符号链接不会改变其运行目录;`cwd` 解析为非 UTF-8 路径时返回 `400` |
| 1558 | - `GET /v1/threads/{id}/jobs/{job_id}` — 单个作业的状态 + 元数据 |
| 1559 | - `GET /v1/threads/{id}/jobs/{job_id}/output?stream=<stdout|stderr>&cursor= |
| 1560 | <bytes>&max_bytes=<1-512KiB>&wait_ms=<0-30s>&format=<base64|text>` — |
| 1561 | 可恢复的字节流。`{job_id, stream, offset, next_cursor, total, |
| 1562 | dropped, encoding, data, status, exit_code, done}`:把 `next_cursor` |
| 1563 | 传回来即可继续;`wait_ms` 在运行中的作业上长轮询等待新字节; |
| 1564 | `done` 意味着终态且游标之后不再有内容 |
| 1565 | - `POST /v1/threads/{id}/jobs/{job_id}/stdin` — `{ "data", "encoding"?, |
| 1566 | "close"? }`:`data` 默认为 UTF-8 文本,或用 `base64`;`close: true` |
| 1567 | 发送 EOF;对 PTY 与管道作业都适用 → `204` |
| 1568 | - `POST /v1/threads/{id}/jobs/{job_id}/kill` — 在进程组上做有界的 SIGTERM → SIGKILL |
| 1569 | 升级 → `{ "job", "result" }`,带最终快照 |
| 1570 | |
| 1571 | 读取是非消费式的:多个客户端可以持有各自独立的游标,而 |
| 1572 | 轮询永远不会从引擎自己的增量消费方那里偷走输出。缓冲区 |
| 1573 | 是有界的,并有精确的丢弃核算——一个 `cursor` |
| 1574 | 落在保留窗口之后的读取方会得到越过它的 `offset` 与 `dropped > 0`, |
| 1575 | 并且必须重新锚定。被淘汰的作业保留一份尾部快照,输出 |
| 1576 | 路由就把它作为最终保留窗口提供。作业限定于创建它的 |
| 1577 | 线程,并会在该线程被移除时被杀掉;引擎的 |
| 1578 | 后台命令使用同一个逐线程管理器,因此 `GET /v1/jobs` |
| 1579 | 也是客户端看到模型派生工作的地方。 |
| 1580 | |
| 1581 | **命令**(带类型的命令目录,APPS-28) |
| 1582 | - `GET /v1/commands` — `{commands: [...]}`:TUI 自己的注册表所持有的 |
| 1583 | 每一个已注册斜杠命令,内置与用户自定义的都在内。每个条目包含:`name`、 |
| 1584 | `aliases`、`summary` 与 `usage`(英文源文本——本地化是 |
| 1585 | 客户端的表面)、`subcommands`(usage 行声明的字面动词)、`takes_arguments`、`kind`(`builtin` 为已注册代码,或 `user` |
| 1586 | 展开一个已存储模板)、`binding`(`host` 在本地运行且 |
| 1587 | 永不到达模型;`prompt` 展开进模型看到的请求)、 |
| 1588 | `discovery`(`primary` / `advanced` / `compatibility`,仅内置)、 |
| 1589 | `hidden` 用于产品不对外宣传的行,以及 `shadowed_by` / |
| 1590 | `shadowed_aliases`,用于某个用户命令占用了内置命令的拼写的情况。 |
| 1591 | |
| 1592 | 每个条目还携带输入区参数形态,按 |
| 1593 | TUI 输入区的计算方式算出,这样客户端就不必从 `usage` |
| 1594 | 重新推导它(#6230): |
| 1595 | - `requires_argument` — usage 行提到了任何参数,无论是必需还是 |
| 1596 | 可选。 |
| 1597 | - `requires_required_argument` — usage 行中有落在每个 `[optional]` 组 |
| 1598 | 之外的 `<required>` 参数。 |
| 1599 | - `composer_wants_trailing_space` — 接受该命令后会在其参数前 |
| 1600 | 留一个尾随空格。 |
| 1601 | - `palette_runs_directly` — 命令面板在选择时直接运行该命令, |
| 1602 | 而不是把它粘进输入区。 |
| 1603 | - `show_in_empty_discovery` — 当斜杠菜单在无筛选文本下打开时 |
| 1604 | 列出该命令。 |
| 1605 | |
| 1606 | 用户命令从 `takes_arguments` 推导这些:它们的参数 |
| 1607 | 永不是必需的,接受参数的模板会在输入区等待, |
| 1608 | 而不接受参数的模板则直接运行,且 `hidden` 模板不会出现在空 |
| 1609 | 发现结果中。 |
| 1610 | |
| 1611 | 这是 TUI 命令面板读取的同一个注册表,因此桌面命令面板可以 |
| 1612 | 对照它检查,而不是与它逐渐偏离。客户端必须遵守的两条规则: |
| 1613 | `binding: "host"` 的行永不会被作为模型提示词提交,且 |
| 1614 | 占用内置名称的用户命令在该拼写上胜出。 |
| 1615 | |
| 1616 | **钩子** |
| 1617 | - `GET /v1/hooks[?thread_id=...]` — `{workspace, enabled, hooks: [...], |
| 1618 | problems: [...]}`:Runtime API 线程为该工作区运行的钩子集合 |
| 1619 | (服务器工作区,或具名线程的工作区)。每个条目包含:`name`、`event`、 |
| 1620 | `command`(凭据形态的值会被掩码;每个 URL 只保留其 scheme 与 host)、`background`、`timeout_secs`, |
| 1621 | 以及 `source`(`global` 用户配置、`plugin` 已审阅插件、`project` |
| 1622 | 已信任并批准的 `.codewhale/hooks.toml`)。`problems` 逐行列出一加载时 |
| 1623 | 被拒绝或告警的钩子。 |
| 1624 | |
| 1625 | 每个 Runtime 线程都用这套集合构建自己的引擎:`tool_call_before` |
| 1626 | 可以拒绝一次调用,`shell_env` 会作用于 shell 工具,而 `tool_call_after` |
| 1627 | 与 `on_error`(针对失败的工具)作为观察者触发,与 TUI 中一样。 |
| 1628 | 客户端读取这条路由,而不是自己维护一份钩子表。 |
| 1629 | |
| 1630 | **上下文**(逐线程上下文压力,APPS-90) |
| 1631 | - `GET /v1/threads/{id}/context` — `input_tokens`(可见量表所用、 |
| 1632 | 保守的实时估算)、`billed_input_tokens`(存在时,最近一次由提供商计数的提示词大小)、`window_tokens`、 |
| 1633 | `output_cap_tokens`、`input_budget_ceiling`、`available_input_tokens`、 |
| 1634 | `compaction_trigger_tokens`、`usage_percent` 与 `pressure`。由 |
| 1635 | 活动引擎通过 `Op::GetContextBudget` 提供。每个数值字段都可为空—— |
| 1636 | 一条无法表达有界窗口的路由会报告 `null`,而不是编造一个数字—— |
| 1637 | 而 `live: false` 标记那些引擎无法被加载、只有存储中记录的该路由的静态窗口 |
| 1638 | 被解析出来的响应。 |
| 1639 | |
| 1640 | **Git**(工作区仓库操作,APPS-106) |
| 1641 | - `GET /v1/git` — 状态详情:`git_repo`、`branch`、`head` |
| 1642 | (缩写,仅供展示)、`head_oid`、`index_token`、`revision`、 |
| 1643 | `ahead`/`behind`、计数、逐文件的 porcelain `files[]` |
| 1644 | (`{path, index, worktree, staged, status, old_path?, rev}`)、`branches`、 |
| 1645 | `remotes`。`files[].path` 与 `old_path` 是工作区相对路径,与写入路由使用同一坐标系; |
| 1646 | 当工作区是其仓库的一个子目录时,工作区之外的行不会列出(计数仍是整个仓库的)。 |
| 1647 | 移出工作区的重命名显示为其来源的删除。普通 Git 过滤器和未跟踪设置照常生效。 |
| 1648 | 未跟踪目录保持折叠;只有指名工作区本身的那一行会展开为可逐个寻址的文件。 |
| 1649 | 前置条件令牌是不透明的: |
| 1650 | - `head_oid` — 完整的 HEAD commit id;在未出生分支上或无法读取 HEAD 时为 `null` |
| 1651 | - `index_token` — 整个索引(每个条目的模式、blob、stage 与路径,覆盖整个仓库)。 |
| 1652 | `git status` 只刷新 stat 信息时不会改变它。仓库读取失败时状态仍是尽力而为; |
| 1653 | 不可用的令牌为 `null`,损坏的 HEAD 无法满足守卫 |
| 1654 | - `files[].rev` — 一行:它的索引条目,加上它覆盖的每个文件的工作树状态(包括 |
| 1655 | 重命名在工作区内的来源)。内容令牌(`c-…`)包含文件字节、Unix 上的可执行位、 |
| 1656 | 符号链接目标,以及子模块的 HEAD/状态。子模块的脏内容由 porcelain 状态概括, |
| 1657 | 不做递归哈希;普通的 stage/discard 不会写入这些内容。一次读取最多哈希 |
| 1658 | 64 MiB / 4,096 个文件;超出该预算或包含超过 16 MiB 文件的行,携带仅供展示的 |
| 1659 | 大小加修改时间令牌(`s-…`)。这些令牌不能守卫写入。不可读路径、位于符号链接 |
| 1660 | 目录之下的路径、特殊文件和损坏的嵌套仓库的 `rev` 为 `null`;其他行保留各自的令牌。 |
| 1661 | 如果一个损坏的已跟踪子模块让 porcelain 中止,普通行会在不递归子模块的情况下 |
| 1662 | 恢复,不可读的子模块显示为 `status: "unknown"`、`worktree: "?"` |
| 1663 | - `revision` — 整棵树:`head_oid`、`index_token`、每一行的 `rev`,包括子目录工作区 |
| 1664 | 之外的行的工作树状态。当任何一行是仅 stat 或不可读、某次仓库读取不完整,或未跟踪 |
| 1665 | 路径被隐藏时为 `null`;这种情况下整棵树的受守卫写入不可用。客户端不得静默省略守卫 |
| 1666 | - `GET /v1/changes` — 同一个 porcelain `files[]` 投影,加上 `head_oid`、 |
| 1667 | `index_token` 与 `revision`,只是去掉了仓库外壳(branches/remotes): |
| 1668 | 只有一份权威,因此改动列表永不会与状态读取不一致 |
| 1669 | - `GET /v1/diff?path=` — 单个文件相对 `base`(`HEAD`, |
| 1670 | 或在未出生分支上的空树——它把已暂存的新增读作新 |
| 1671 | 文件)的统一 `diff`。一个 patch 覆盖已暂存+未暂存;`truncated` 报告 |
| 1672 | 512 KiB 上限。未跟踪文件会回答 `untracked: true` 与一个空的 |
| 1673 | diff——客户端自己去读该文件,而不是把它误当作 |
| 1674 | 未改动 |
| 1675 | - `GET /v1/workspace/diff?limit=` — 整棵树的 patch(默认 256 KiB, |
| 1676 | 最大 4 MiB)加一份完整的 `--numstat` `files[]` 清单 |
| 1677 | (`{path, added, deleted}`),这样即使 patch 被截断,每一行改动的文件也能渲染 |
| 1678 | - `GET /v1/git/graph?limit=` — 有界的 commit 行(`id`、`short`、 |
| 1679 | `parents`、`author`、`timestamp`、`refs`、`subject`);未出生分支是 |
| 1680 | 一张空图,不是错误 |
| 1681 | - `POST /v1/git/stage` `{ "paths": [...] }` 或 `{ "all": true }`; |
| 1682 | `POST /v1/git/unstage` 相同;`POST /v1/git/discard` `{ "paths": [...] }` |
| 1683 | (仅已跟踪路径——没有 `all`,未跟踪路径失败关闭); |
| 1684 | `POST /v1/git/commit` `{ "message", "all"? }`;stage、unstage、discard |
| 1685 | 与 commit 还接受一个可选的 `expect`(见下文);`POST /v1/git/push` |
| 1686 | `{ "remote"?, "set_upstream"? }`(`remote`,或仅提供 `set_upstream` 时使用的 `origin`,必须是已配置的远端名称);`POST /v1/git/branch` |
| 1687 | `{ "name", "create"? }` |
| 1688 | |
| 1689 | diff 与前置条件令牌的读取通过加固过的审阅命令运行(过滤器、fsmonitor、钩子、 |
| 1690 | 惰性抓取与 replace-objects 均被中和)。porcelain 状态使用普通 Git,与工作区的计数和 |
| 1691 | 过滤器一致;写入通过非交互式命令路径运行(`GIT_TERMINAL_PROMPT=0`、BatchMode ssh), |
| 1692 | 因此凭据或主机密钥提示永不会挂住一个请求。路径列表是工作区相对的,并受与文件路由 |
| 1693 | 相同的限制(穿越 → 400,`.git` → 403),在 `--` 之后以 `--literal-pathspecs` 传入, |
| 1694 | 所以 `src/*` 指名一个叫 `*` 的文件,永远不是 glob。(对整棵树的 unstage 使用 `:/` |
| 1695 | 根 pathspec。)变更操作回答 `{ok, output, status, current}`:刷新后的状态与完整的 |
| 1696 | `GET /v1/git` 详情,因此客户端在一次操作后不需要再读取任何东西,并可以用新的令牌 |
| 1697 | 串接下一次写入。不是仓库的工作区回答 `404`。 |
| 1698 | |
| 1699 | *前置条件。* stage、unstage、discard 与 commit 接受 |
| 1700 | `expect: { head?, index?, revision?, files? }`,由最近一次 `GET /v1/git` 构建。 |
| 1701 | 出现的字段会被检查,缺失的不会;`head: null` 表示“HEAD 必须仍是未出生的”。 |
| 1702 | 不带 `expect`(或带 `expect: {}`)时,写入的行为与以前完全一样。推荐用法: |
| 1703 | |
| 1704 | | 操作 | `expect` | |
| 1705 | | --- | --- | |
| 1706 | | stage / unstage `paths` | `{head, files: {path: rev}}` | |
| 1707 | | stage / unstage `all` | `{head, revision}` | |
| 1708 | | discard(总是——它会销毁编辑) | `{head, files: {path: rev}}` | |
| 1709 | | commit | `{head, index}` | |
| 1710 | | commit `all` | `{head, revision}` | |
| 1711 | |
| 1712 | 格式错误的前置条件在任何操作运行之前就回答 `400`:`head` 必须是 40 或 64 位十六进制 |
| 1713 | id 或 `null`;`index` 与 `revision` 为 64 位十六进制(显式的 `revision: null` 会被拒绝); |
| 1714 | `files` 的值必须是内容安全的 `c-` rev(`s-` 令牌回答 `400`)。`files` 的键 |
| 1715 | (工作区相对;`dir/` 与 `dir` 是同一个键)必须恰好指名所请求的路径,这样就不会有路径 |
| 1716 | 意外地没有守卫。`files` 与 `all: true` 一起时会被拒绝(请用 `revision`),在 commit 上 |
| 1717 | 也会被拒绝。`expect` 内的未知键与其他未知字段一样被拒绝。当仓库已不再匹配时,路由 |
| 1718 | 什么都不写,并回答 `409`: |
| 1719 | |
| 1720 | ```json |
| 1721 | { "error": { "message": "The repository changed since it was read (HEAD moved; src/a.rs changed). Nothing was written; refresh and review again.", |
| 1722 | "status": 409, "code": "git_state_changed" }, |
| 1723 | "stale": ["head", "files"], "stale_paths": ["src/a.rs"], |
| 1724 | "current": { "...": "the GET /v1/git detail" } } |
| 1725 | ``` |
| 1726 | |
| 1727 | `stale` 列出移动了的组成部分(`head`、`index`、`files`、`revision`);`stale_paths` |
| 1728 | 列出 `rev` 发生变化的 `files` 键。客户端从 `current` 重新渲染,保留用户的选择, |
| 1729 | 然后再次询问。 |
| 1730 | |
| 1731 | 来自同一个运行时的 stage、unstage、discard、commit 与 branch 是串行化的,因此一次 |
| 1732 | 检查和它的写入相对于该运行时的其他窗口是原子的;第二个并发写入会回答 `409`, |
| 1733 | `error.code: "git_busy"`,而不是排在一个很长的 commit 钩子后面。push 不串行化:它只 |
| 1734 | 移动远程 ref,并且可能为网络等待最多 120 秒。该锁不覆盖此运行时之外的进程——终端、 |
| 1735 | 编辑器,或 Codewhale 自己的智能体工具——它们仍可能在检查与 git 取得 `index.lock` 之间 |
| 1736 | 的那一刻改动仓库;真正并发的 git 写入随后会在 git 自己的 `index.lock` 上失败(一个携带 |
| 1737 | git 消息的 `400`)。通过 `commit-tree` 与 `update-ref` 做比较并交换的 commit 可以关闭 |
| 1738 | 这个窗口,但会跳过仓库的钩子,而审阅面板的 commit 必须运行这些钩子,所以没有采用。 |
| 1739 | |
| 1740 | **诊断**(只读日志、崩溃、进程——APPS-103) |
| 1741 | - `GET /v1/logs` → `{sources: [{dir, files: [{name, size, modified}]}]}` — |
| 1742 | 运行时的日志目录,加上来自 codewhale home 的 `audit.log[.1]`, |
| 1743 | 最新的在前,有上限 |
| 1744 | - `GET /v1/logs/{name}?offset=<bytes>&limit=<bytes>&tail=<bytes>` → |
| 1745 | `{name, size, modified, offset, bytes, truncated, encoding, content}` — |
| 1746 | 一个有界窗口;`tail` 从末尾读取,与 `offset` 互斥; |
| 1747 | `truncated` 意味着返回窗口之后还有字节 |
| 1748 | (在 EOF 处做 tail 读取为 `false`),`encoding` 为 `utf-8` 或 `base64` |
| 1749 | - `GET /v1/crashes`、`GET /v1/crashes/{name}` — 在崩溃转储目录上使用同样的 |
| 1750 | 列表/读取契约(`~/.codewhale/crashes`,合并旧式的 |
| 1751 | `~/.deepseek/crashes`) |
| 1752 | - `GET /v1/process` → `{pid, version, commit, started_at, uptime_seconds, |
| 1753 | executable, rss_bytes}` — `rss_bytes` 只在平台报告它的地方才有 |
| 1754 | (Linux `/proc`);其他地方是缺失,而不是编造 |
| 1755 | |
| 1756 | 这些路由把磁盘上已有的东西打包,供客户端侧导出; |
| 1757 | 没有遥测上传路由,也没有第二套日志存储。名称会做 |
| 1758 | basename 校验(无分隔符、无 `..`),列表有上限,读取是 |
| 1759 | 有界窗口,且符号链接永不被跟随——客户端自己打包那些 |
| 1760 | 文件。 |
| 1761 | |
| 1762 | **目标与远程姿态**(APPS-50) |
| 1763 | - `GET /v1/targets` → `{targets: [self], remote: {supported: true, |
| 1764 | attach: "client", probe: "POST /v1/remote/connect"}, ssh: {…}, |
| 1765 | cloud: {…}}` — 本运行时自己的记录作为可附着目标,加上 |
| 1766 | 逐表面的所有权;运行时不保留持久目标注册表, |
| 1767 | 因此 `POST /v1/targets` 与 `POST /v1/targets/switch` 回答 |
| 1768 | `501 Not Implemented`——目标选择由客户端拥有,且一次切换 |
| 1769 | 绝不能把运行中的任务搬到服务器侧 |
| 1770 | - `GET /v1/remote` → `{bind_host, port, loopback_only, reachable_from_lan, |
| 1771 | auth_required, mobile, tls}` — 本监听方的可达性姿态。 |
| 1772 | `tls` 总是 `false`:该 API 没有 TLS 终止器,因此非回环 |
| 1773 | 可达性假定了经过验证的 overlay(VPN/mesh),而不是裸的 LAN 信任 |
| 1774 | - `POST /v1/remote/connect` `{ "endpoint": "http://host:port" }` — 探测一个 |
| 1775 | 候选远程端未经认证的 `GET /v1/runtime/info`(仅取源站; |
| 1776 | 粘贴进来的任何路径都被丢弃)。成功时回答 `{ok, remote: {endpoint, |
| 1777 | runtime_api_version, codewhale_version, auth_required, …}, attach: |
| 1778 | "client"}`,失败时以数据形式回答 `{ok: false, reason: "unreachable" | |
| 1779 | "not a Codewhale runtime" | …}`。携带凭据的 URL |
| 1780 | 会以 400 被拒绝——远程端的令牌是在客户端侧配置的,而一条会转发令牌的连接路由 |
| 1781 | 将是一个数据外泄原语 |
| 1782 | - `GET /v1/ssh`、`GET /v1/cloud` → `{supported: false, owner: |
| 1783 | "codewhale-control-plane", reason}`;`POST /v1/ssh/connect` 与 |
| 1784 | `POST /v1/cloud/attach` → `501`:SSH 工作区供应与托管式 |
| 1785 | 云电脑属于 Apps 控制面(Managed Computer 的 ASCII Box), |
| 1786 | 而不是 Core 内的第二套权威 |
| 1787 | |
| 1788 | 远程 Codewhale 就是一个带令牌的 `serve --http` 运行时——这就是 |
| 1789 | 整个附着模型。这些路由描述并探测它;它们永不在本地机器上执行 |
| 1790 | 远程请求。 |
| 1791 | |
| 1792 | **LSP**(工作区语言智能,APPS-93) |
| 1793 | - `GET /v1/lsp` — 能力:`enabled`、受支持且带各自 |
| 1794 | 服务器命令的 `languages`、`custom_languages`、操作、轮询与诊断上限 |
| 1795 | - `GET /v1/diagnostics?path=` — 文件诊断 |
| 1796 | - `GET /v1/definition?path=&line=&character=`(从 1 开始) |
| 1797 | - `GET /v1/references?path=&line=&character=`(从 1 开始) |
| 1798 | - `GET /v1/symbols?path=&query=` — 空 query 返回文档符号 |
| 1799 | |
| 1800 | 一个惰性构建的工作区级 `LspManager` 为这些路由服务;引擎线程 |
| 1801 | 为编辑后钩子保留各自的逐线程管理器,而一个 |
| 1802 | 从不服务任何 LSP 路由的服务器也不会启动语言服务器。`path` 是 |
| 1803 | 工作区相对的,并受与文件路由相同的限制。正常缺席也是数据:没有语言服务器、 |
| 1804 | `[lsp]` 配置被禁用,或一次超时,都会回答 `200`,带 `ok: false` 与机器可读的 `reason` |
| 1805 | (`no_server`、`lsp_disabled`、`lsp_error`);格式错误的输入是 400, |
| 1806 | 文件缺失是 404。 |
| 1807 | |
| 1808 | **语音**(宿主听写,APPS-98) |
| 1809 | - `GET /v1/voice` — 能力:`available`、检测到的 `recorder` 命令、 |
| 1810 | 解析后的 `asr` `{kind, model}`、`modes`、`send_phrases`、 |
| 1811 | `max_record_seconds` |
| 1812 | - `POST /v1/voice/dictate` — 录音后转写 → `{ ok, text }` |
| 1813 | - `POST /v1/voice/send` — 同样的采集,但使用“send it” / 发送/發送 |
| 1814 | 后缀契约:`send: true` 告诉客户端提交(空的 `text` |
| 1815 | 加 `send: true` 意味着提交客户端当前的草稿) |
| 1816 | - `POST /v1/voice/control` `{ "composer": "draft text" }` — 辅助 |
| 1817 | 听写,把输入区文本展示给模型;响应中的 `assisted: false` 意味着一个 |
| 1818 | 免费的 ASR 后端(本地 whisper/Groq)处理了音频,且输入区上下文从未被看到 |
| 1819 | |
| 1820 | 运行时拥有宿主麦克风与 ASR 分发——与 TUI 的 `/voice` 命令所运行的是同一套实现,只是无头运行。 |
| 1821 | 录音是每台主机一次阻塞式采集(请求串行化;输家得到 |
| 1822 | `ok:false`/`no_speech`,而不是一个被争抢的设备)。提供商 ASR 惰性解析其 |
| 1823 | 密钥,因此本地 whisper 与 Groq 路径无需提供商认证即可工作。 |
| 1824 | 中间和最终转写均使用已选择的 ASR 后端。本地 whisper 或 Groq 失败时, |
| 1825 | 错误保留在该后端;运行时不会把录音或输入区文本改发给当前模型提供商重试。 |
| 1826 | 如需使用该提供商,必须显式选择提供商 ASR。 |
| 1827 | 失败也是数据:`no_recorder`、`no_speech`、`no_provider_auth`、 |
| 1828 | `transcription_failed`。`CODEWHALE_DISABLE_VOICE=1` 是操作者 |
| 1829 | 开关——无头的 `serve --http` 主机会报告 `available: false`, |
| 1830 | 并且每次听写调用都失败关闭。 |
| 1831 | |
| 1832 | ## 提供商与模型选择 |
| 1833 | |
| 1834 | 这三条路由是 GUI 渲染模型选择器的方式,使其内容对*本*运行时 |
| 1835 | 为真,而不是从某个版本快照猜出来的。它们在 2026-08-04 之前没有文档, |
| 1836 | 这让一个桌面集成付出了一天的代价:客户端探测了 `/v1/models`、`/v1/runtime/models` |
| 1837 | 与 `/v1/runtime/providers`(都正确地返回 404),并得出该能力 |
| 1838 | 不存在的结论。 |
| 1839 | |
| 1840 | ### `GET /v1/providers` |
| 1841 | |
| 1842 | ```json |
| 1843 | { |
| 1844 | "current": "modelstudio-token-plan", |
| 1845 | "providers": [ |
| 1846 | { |
| 1847 | "id": "modelstudio-token-plan", |
| 1848 | "model_provider_id": "modelstudio-token-plan", |
| 1849 | "display_name": "Alibaba Cloud Model Studio", |
| 1850 | "default_model": "qwen3.8-max", |
| 1851 | "has_model_catalog": true, |
| 1852 | "credentialState": "configured" |
| 1853 | } |
| 1854 | ] |
| 1855 | } |
| 1856 | ``` |
| 1857 | |
| 1858 | `current` 是活动的通用提供商 id。只有活动条目携带精确身份:活动的内置提供商 |
| 1859 | 通常会在 `model_provider_id` 中重复其规范 id,而活动的具名自定义路由会把 `current` 设为 |
| 1860 | `custom`,并在那里给出精确的已配置键(例如 `lm-studio`)。其他 |
| 1861 | 条目的精确 id 为 null;活动 `custom` 条目上的 null id 标识 |
| 1862 | 已发布的旧式根级自定义路由。请从所选条目保留这两个字段, |
| 1863 | 并把非 null 的精确 id 作为 `POST /v1/threads` 的 |
| 1864 | `model_provider_id` 回传;丢掉具名自定义 id 会把选择 |
| 1865 | 塌缩到旧式的根自定义路由。`credentialState` 是运行时既有结构性凭据分类的一个稳定、不涉密的 |
| 1866 | 投影: |
| 1867 | |
| 1868 | - `configured`:凭据材料在结构上可用; |
| 1869 | - `login_required`:该路由需要登录,或需要一个可用的登录能力; |
| 1870 | - `missing`:API 式凭据不可用; |
| 1871 | - `no_auth`:该路由显式禁用了凭据使用; |
| 1872 | - `local`:该精确路由是本地且无需密钥的; |
| 1873 | - `legacy`:该兼容路由无法被更精确地分类。 |
| 1874 | |
| 1875 | 对于活动的具名自定义提供商,该状态是根据 `model_provider_id` 所指名的精确 |
| 1876 | 路由算出的,而不是来自通用自定义提供商的默认值。 |
| 1877 | 它有意把已保存密钥与已导入令牌的细节塌缩为 |
| 1878 | `configured`,把登录/同意来源的细节塌缩为 `login_required`。 |
| 1879 | |
| 1880 | 响应从不包含端点 URL、凭据环境变量名、文件系统路径、凭据值、同意来源细节或令牌 |
| 1881 | 元数据。`credentialState` 不是提供商金丝雀:`configured` 并不 |
| 1882 | 证明端点可达、凭据有效、模型有权使用或请求会成功。下面那条模型路由也只是 |
| 1883 | 一个选择目录;非空列表并不证明该路由当前能服务请求。 |
| 1884 | |
| 1885 | ### `GET /v1/providers/{id}/models` |
| 1886 | |
| 1887 | ```json |
| 1888 | { |
| 1889 | "provider": "deepseek", |
| 1890 | "models": [ |
| 1891 | { |
| 1892 | "id": "deepseek-v4-flash-vision-exp", |
| 1893 | "image_input": "supported", |
| 1894 | "reasoning_effort": "unknown", |
| 1895 | "reasoning_effort_levels": [], |
| 1896 | "reasoning_effort_source": null |
| 1897 | } |
| 1898 | ] |
| 1899 | } |
| 1900 | ``` |
| 1901 | |
| 1902 | 对于一条精确的已配置路由,请提供 `?model_provider_id=vision-work`, |
| 1903 | 并要求响应回显同一个 `model_provider_id`。运行时会在读取模型支持能力之前, |
| 1904 | 在请求的提供商种类下解析该身份。 |
| 1905 | 未知或不匹配的身份返回 `400`。具名分页游标会绑定 |
| 1906 | 配置身份、端点与目录快照;改动其中任何一项 |
| 1907 | 都需要重新开始分页。省略该查询参数会保留旧式目录 |
| 1908 | 投影,并省略身份回显。 |
| 1909 | |
| 1910 | 某个提供商的目录。未知 id 返回 `400`;旧式的 `deepseek-cn` 别名也返回 `400`, |
| 1911 | 因为它没有提供商元数据——请使用 `deepseek`。 |
| 1912 | 空的 `models` 数组意味着运行时为该提供商没有可发现或已配置的 |
| 1913 | 模型 id;它并不报告凭据是否存在。 |
| 1914 | |
| 1915 | 这里返回的 id 正是 `POST /v1/threads` 的 |
| 1916 | `model` 字段与下面的 switch 路由所接受的值。`image_input` 是精确解析后的 |
| 1917 | 提供商/模型路由的能力状态:`supported`、`unsupported` 或 |
| 1918 | `unknown`。请让 `unknown` 保持未知,而不要从模型名或 |
| 1919 | 传输协议去推断。`supported` 描述的是模型路由;它并不意味着某个具体 |
| 1920 | 客户端实现了图片上传控件。 |
| 1921 | |
| 1922 | `reasoning_effort` 使用同样的三种能力状态,描述 |
| 1923 | 该精确模型的元数据是否公布了可选的思考强度阶梯。 |
| 1924 | `reasoning_effort_levels` 只包含来自该元数据的规范、被识别的活动强度等级。 |
| 1925 | Off 与诸如 none 之类的提供商同义词被排除: |
| 1926 | Apps/Chat 协议把 off 当作省略,而这不证明支持一个 |
| 1927 | 显式的提供商禁用命令。一个有能力推理的模型其活动强度阶梯仍可能 |
| 1928 | 未知。 |
| 1929 | 当原生兼容性会改变 Codex 等级的 |
| 1930 | 线上值时(目前是 minimal 与 auto),它们也被排除。这个投影不改变原生 |
| 1931 | 兼容行为,也不声明一个运行时无法原样发送的等级。 |
| 1932 | 在自定义端点上,不会从整个提供商的默认值或一个熟悉的模型名 |
| 1933 | 推断出任何等级。`reasoning_effort_source` 标识 `catalog`、 |
| 1934 | `codex_cli_cache` 或 `codex_app_server`;缺失、过期与无法识别的模型 |
| 1935 | 元数据保持未知。Codex roster 元数据描述的是外部 CLI 的 |
| 1936 | roster,并不证明另一个单独配置的 Runtime 凭据属于 |
| 1937 | 同一个账号,也不证明某个认证边界已被批准。 |
| 1938 | |
| 1939 | 选择具名路由时请传入 `?model_provider_id=<exact configured id>`。 |
| 1940 | 运行时会一并校验提供商种类与精确身份,在模型列表旁返回 |
| 1941 | `model_provider_id`,并保持活动 |
| 1942 | 配置不变。请求的身份为空或未知,或种类不匹配,都会返回 |
| 1943 | `400`;它永不回退到另一个具名路由。 |
| 1944 | |
| 1945 | Runtime Chat 中继以 camelCase 发布同样的强度字段 |
| 1946 | (`reasoningEffort`、`reasoningEffortLevels`、`reasoningEffortSource`)。这些 |
| 1947 | 模型事实不会启用工具执行,也不确立账号权益。 |
| 1948 | |
| 1949 | 对于线程范围的选择,请把所选条目的提供商字段与所选模型 |
| 1950 | 一同发出。当 `model_provider_id` 为 null 时请省略它: |
| 1951 | |
| 1952 | ```json |
| 1953 | { |
| 1954 | "model_provider": "custom", |
| 1955 | "model_provider_id": "lm-studio", |
| 1956 | "model": "local-vision-model" |
| 1957 | } |
| 1958 | ``` |
| 1959 | |
| 1960 | 这会在那条精确的具名自定义路由上创建一个线程,而不改变 |
| 1961 | Runtime 的提供商或模型默认值。 |
| 1962 | |
| 1963 | ### `PUT /v1/providers/{id}/key` —— 只写凭据 |
| 1964 | |
| 1965 | ```json |
| 1966 | // request |
| 1967 | { "key": "sk-…" } |
| 1968 | |
| 1969 | // response |
| 1970 | { "provider": "openai-codex", "stored": true, "backend": "keychain", |
| 1971 | "credentialState": "configured", "configPath": "/…/config.toml" } |
| 1972 | ``` |
| 1973 | |
| 1974 | 通过 `codewhale auth set --provider <id> --api-key-stdin` 所用的同一笔事务式写入 |
| 1975 | 存储提供商 API 密钥:在提供商写锁下写入密钥存储, |
| 1976 | 加上持久化到配置文档并镜像进活动运行时 |
| 1977 | 配置的 `[providers.<id>] auth_mode` 元数据标记,以便 `GET /v1/providers` 立即报告新状态。`backend` |
| 1978 | 说明哪个密钥后端持有该密钥,`configPath` 说明哪个配置 |
| 1979 | 文档携带该标记(当环境配置是工作区范围时,就是用户全局文件)。 |
| 1980 | |
| 1981 | 密钥永不被返回——没有读取凭据材料的 |
| 1982 | 路由,且密钥及其长度都不出现在响应、错误或 |
| 1983 | 日志中;响应只携带就绪度投影 |
| 1984 | (`credentialState`)。未知的提供商 id、`deepseek-cn` 旧式 |
| 1985 | 别名、空密钥、超过 4 KiB 的密钥,或含控制字符的密钥 |
| 1986 | 都返回 `400`。成功写入后得到 `credentialState: "local"` 对无密钥本地路由来说是诚实的 |
| 1987 | 输出:密钥被存下了,但该路由被分类为不需要密钥。 |
| 1988 | |
| 1989 | ### `DELETE /v1/providers/{id}/key` —— 清除 Codewhale 拥有的凭据 |
| 1990 | |
| 1991 | ```json |
| 1992 | // response |
| 1993 | { "provider": "openai", "cleared": true, "credentialState": "missing" } |
| 1994 | ``` |
| 1995 | |
| 1996 | 通过 `codewhale auth clear` 所用的同一个共享所有者清除凭据: |
| 1997 | 配置文档会先做快照,若其保存失败则恢复,且只有在这次保存落地之后 |
| 1998 | 才会去动密钥存储;被清除的标记会镜像进活动运行时配置, |
| 1999 | 使 `GET /v1/providers` 在下一次读取时就报告 `missing`,而不是等到 |
| 2000 | 重启之后。 |
| 2001 | |
| 2002 | 清除一条本就已清空的路由会返回 `cleared: true`——一个重试 |
| 2003 | 撤销的客户端不应被告知哪里出了问题。如果配置条目 |
| 2004 | 已被清空但密钥后端拒绝删除,该路由回答 `500` |
| 2005 | 并点名那个槽位:在密钥仍在钥匙串里时报告成功 |
| 2006 | 会是对一次安全动作的谎报。 |
| 2007 | |
| 2008 | ### 凭据所有权:`credentialSource` 与 `credentialWritable` |
| 2009 | |
| 2010 | 两个凭据动词都会拒绝一条其凭据不归 Codewhale 所有的路由, |
| 2011 | 而且 `GET /v1/providers` 携带同样的分类,以便客户端可以 |
| 2012 | 在提交*之前*禁用其控件,而不是晚些时候才失败: |
| 2013 | |
| 2014 | | `credentialSource` | `credentialWritable` | 含义 | |
| 2015 | | --- | --- | --- | |
| 2016 | | `secret_store` | `true` | Codewhale 自己的持久后端。唯一可写来源。 | |
| 2017 | | `config` | `false` | 配置文件中的字面密钥,它在请求时仍然胜出。 | |
| 2018 | | `external_auth` | `false` | 一个活动的对外同意(OAuth)拥有该凭据。 | |
| 2019 | | `none` | `false` | 该路由不发送凭据,或没有凭据槽位。 | |
| 2020 | |
| 2021 | 当 `credentialWritable` 为 `false` 时,`credentialWritableReason` 携带 |
| 2022 | 点名所有者的面向用户文案,且 `PUT` 与 `DELETE` 都以 `409` |
| 2023 | 与同一个理由作答。该分类是结构性的:它读取已声明的 |
| 2024 | 认证模式、同意状态,以及任何已配置 `api_key` 值的*种类*, |
| 2025 | 并且永不解析密钥、环境变量值或认证命令。它是一个 |
| 2026 | 类别,永不是一个值、一个路径或一个环境变量名。 |
| 2027 | |
| 2028 | ### `POST /v1/providers/{id}/switch` |
| 2029 | |
| 2030 | ```json |
| 2031 | // request (model is optional; omit to take the provider default) |
| 2032 | { "model": "qwen3.8-max" } |
| 2033 | |
| 2034 | // response |
| 2035 | { "provider": "modelstudio-token-plan", "model": "qwen3.8-max", |
| 2036 | "message": "…", "persisted": true } |
| 2037 | ``` |
| 2038 | |
| 2039 | **请使用它,而不是用反复 `POST /v1/config` 写入加一次重载来模拟切换。** |
| 2040 | 提供商与模型在这里一起变动,改动会在被应用之前 |
| 2041 | 针对提供商目录做校验,而 `persisted` 报告它是被写入配置还是 |
| 2042 | 仅应用于活动会话。未知提供商 id 与 `deepseek-cn` 别名 |
| 2043 | 以 `400` 被拒绝。 |
| 2044 | |
| 2045 | ## 运行时数据模型 |
| 2046 | |
| 2047 | 运行时使用持久的 Thread/Turn/Item 生命周期。 |
| 2048 | |
| 2049 | - **ThreadRecord** — `id`、`created_at`、`updated_at`、`model`、 |
| 2050 | `model_provider`(通用种类)、`model_provider_id`(可选的精确已配置 |
| 2051 | 路由)、`workspace`、`mode`、`task_id`、`system_prompt`、`latest_turn_id`、 |
| 2052 | `latest_response_bookmark`、`archived` |
| 2053 | - **TurnRecord** — `id`、`thread_id`、`status`(`queued|in_progress|completed| |
| 2054 | failed|interrupted|canceled`)、`effective_provider`、`effective_model`、 |
| 2055 | `effective_billing_surface`、时间戳、时长、用量、错误摘要、 |
| 2056 | `artifacts` 与 `workspace`(参见 [回合工件](#回合工件)) |
| 2057 | - **TurnItemRecord** — `id`、`turn_id`、`kind`(`user_message|agent_message| |
| 2058 | tool_call|file_change|command_execution|context_compaction|status|error`)、 |
| 2059 | 生命周期 `status`、`metadata`、`artifacts` 以及旧版的 `artifact_refs` 投影 |
| 2060 | |
| 2061 | 事件是只追加的,带一个全局单调递增的 `seq` 用于回放/恢复。 |
| 2062 | |
| 2063 | `effective_billing_surface` 是从服务该回合的端点推导出的 |
| 2064 | 非涉密分类。已识别的 StepFun 路由使用 `stepfun-payg` 或 |
| 2065 | `stepfun-plan`;未知与自定义端点则让它保持未设置。原始 base URL |
| 2066 | 不会被持久化到 `TurnRecord`。 |
| 2067 | |
| 2068 | ### 重启语义 |
| 2069 | |
| 2070 | - 如果进程在某个回合或条目处于 `queued` 或 `in_progress` 时重启, |
| 2071 | 恢复出来的记录会被标记为 `interrupted`,并带上 `"Interrupted by |
| 2072 | process restart"` 错误。 |
| 2073 | - 行尾换行符是事件追加的提交标记。启动时,一个不含该分隔符的尾部 |
| 2074 | JSONL 片段会被截断并 fsync,即使它的字节构成合法 JSON; |
| 2075 | 它是一次未提交的追加,其已预定的 |
| 2076 | 序列号不会被复用。以换行结尾的格式错误记录不是 |
| 2077 | 可识别的崩溃残片,在回放期间仍会失败关闭。 |
| 2078 | - 如果一个终态回合记录已落到磁盘但它的终态事件序列 |
| 2079 | 没有,第一次异步读取会把任何未解决的动态调用对账为 |
| 2080 | `tool_call.canceled`,然后发出一个 `turn.completed`。既有的终态 |
| 2081 | 调用与回合回执会被识别出来,永不会重复。 |
| 2082 | - 回合操作绑定在重启后依然存在。用同一个 `operation_key` |
| 2083 | 与请求重试会返回那个原始的已恢复回合(包括从中途进程退出恢复出的 `interrupted` |
| 2084 | 回合);不匹配的复用仍然是冲突。一个由崩溃产生、且从未取得回合的绑定会在 |
| 2085 | 启动时被丢弃,因为引擎提交只在两条记录都持久化之后才发生。 |
| 2086 | - 任务执行在同一套持久化的 |
| 2087 | 线程/回合存储之上执行自己的恢复。 |
| 2088 | |
| 2089 | ### 审批模型 |
| 2090 | |
| 2091 | - `auto_approve` 标志作用于运行时审批桥与引擎 |
| 2092 | 工具上下文。当为某个线程/回合/任务启用时,需要审批的工具 |
| 2093 | 会在非交互式运行时路径中被自动批准,shell 安全检查 |
| 2094 | 以自动批准模式运行,且派生的子智能体继承该设置。 |
| 2095 | - 省略时,`auto_approve` 默认为 `false`。 |
| 2096 | - [授权顺序](AUTHORIZATION_ORDER.md)描述了带类型的规则、 |
| 2097 | 已注册的工具要求、安全底线、仓库法律、审批 |
| 2098 | 传输与沙箱执行之间的相对位置。 |
| 2099 | |
| 2100 | ### SSE 事件流 |
| 2101 | |
| 2102 | `/v1/threads/{id}/events` 的 SSE 事件负载形态: |
| 2103 | |
| 2104 | ```json |
| 2105 | { |
| 2106 | "schema_version": 1, |
| 2107 | "seq": 42, |
| 2108 | "previous_seq": 38, |
| 2109 | "event": "item.delta", |
| 2110 | "kind": "item.delta", |
| 2111 | "thread_id": "thr_1234abcd", |
| 2112 | "turn_id": "turn_5678efgh", |
| 2113 | "item_id": "item_90ab12cd", |
| 2114 | "timestamp": "2026-02-11T20:18:49.123Z", |
| 2115 | "created_at": "2026-02-11T20:18:49.123Z", |
| 2116 | "payload": { |
| 2117 | "delta": "partial output", |
| 2118 | "kind": "agent_message" |
| 2119 | } |
| 2120 | } |
| 2121 | ``` |
| 2122 | |
| 2123 | 兼容性说明: |
| 2124 | |
| 2125 | - `schema_version` 是 HTTP/SSE 信封的 schema 版本。它与持久化的 |
| 2126 | 线程/回合/事件记录所用的运行时存储 schema 无关。 |
| 2127 | - 在既有客户端中 `event` 仍是 SSE 事件名;它被原样保留。 |
| 2128 | - `kind` 在面向带类型客户端的稳定信封中镜像 `event`。 |
| 2129 | - `seq` 在所有 Runtime 线程间全局分配。因此当其他线程交错时, |
| 2130 | 同一线程事件之间出现间断是正常的。在这个逐线程 SSE 流上, |
| 2131 | `previous_seq` 是该线程上一个已送达事件的序列号(或第一个事件请求的回放 |
| 2132 | 游标);客户端通过与它已接受的逐线程游标比较来检测丢失, |
| 2133 | 而不是要求 `seq == previous_seq + 1`。一次追加被事务性回滚后序列分配也不会回卷, |
| 2134 | 所以一次重试可以有意跳过一个未使用的值, |
| 2135 | 而不意味着有事件丢失。 |
| 2136 | - `thread.started`、`turn.started` 与 `turn.completed` 仍与以前完全一样 |
| 2137 | 作为 SSE 事件名发出。 |
| 2138 | - 对 schema 版本 1 而言,`timestamp` 仍是规范的事件时间。`created_at` |
| 2139 | 是给那些在其他地方使用 `created_at` 命名的客户端准备的等价别名; |
| 2140 | 不要把两个字段都要求为存在。 |
| 2141 | |
| 2142 | ### 结束与恢复线程流 |
| 2143 | |
| 2144 | 每当服务器结束一个已经返回 `200` 的 `/v1/threads/{id}/events` 流时,最后一帧是 |
| 2145 | `stream.end`,恰好发送一次,而且总会发送(不需要 `progress=true`): |
| 2146 | |
| 2147 | ``` |
| 2148 | event: stream.end |
| 2149 | data: {"schema_version":1,"event":"stream.end","kind":"stream.end","thread_id":"thr_1234abcd","reason":"replay_failed","last_seq":42,"retryable":true} |
| 2150 | ``` |
| 2151 | |
| 2152 | - 它是传输帧,不是日志事件:它**没有 `seq`**,也**没有 SSE `id:`**。按 `seq` 确认的 |
| 2153 | 客户端会跳过它,浏览器的 `Last-Event-ID` 停留在最后一个真实事件上。 |
| 2154 | - `last_seq` 是流结束时的游标:在该连接上送达的最后一个日志 `seq`;如果一个都没有, |
| 2155 | 则是实际的起始游标(在 `replay_limit` 尾部之后,它已经越过了被省略的历史)。它正是 |
| 2156 | 那个能无丢失、无重复地恢复的 `since_seq`。 |
| 2157 | - `retryable` 说明从 `last_seq` 恢复能否成功。客户端应以它为准,而不是以原因列表为准。 |
| 2158 | 对未知的 `reason`,按它的 `retryable` 处理。 |
| 2159 | - 没有自由文本消息。底层错误在 Runtime 日志里,其中可能包含存储路径。 |
| 2160 | |
| 2161 | | `reason` | 含义 | `retryable` | |
| 2162 | | --- | --- | --- | |
| 2163 | | `replay_failed` | 为开头回放提供数据的持久历史读取失败,包括第一个游标之后回放 worker 崩溃 | `true` | |
| 2164 | | `catch_up_failed` | 广播落后之后,从流的游标开始的持久重读无法打开或失败 | `true` | |
| 2165 | | `runtime_shutdown` | Runtime API 服务器正在停止(SIGINT、SIGTERM 或 SIGHUP;Windows 上为 Ctrl+C 或 Ctrl+Break)。打开的流会在进程退出前的一个有界排空窗口内收到此帧 | `true` | |
| 2166 | |
| 2167 | 每个 `200` 响应都带有 `x-codewhale-stream-end: 1`。有这个头时,**没有** `stream.end` |
| 2168 | 的 EOF 意味着连接或 Runtime 进程在服务器没有选择结束流的情况下死掉了:网络或代理 |
| 2169 | 中断、崩溃,或不允许排空的终止(例如 `SIGKILL`)。早于此帧的 Runtime 不发送该头; |
| 2170 | 那时 EOF 仍然含义不明,应视为连接丢失。 |
| 2171 | |
| 2172 | 客户端恢复规则: |
| 2173 | |
| 2174 | 1. 保持 `cursor` = 你接受的最后一个日志帧的 `seq`。忽略 `seq <= cursor` 的帧。 |
| 2175 | 如果某帧的 `previous_seq` 不是你的 `cursor`,说明你漏了事件:重新加载线程快照, |
| 2176 | 而不是信任本地状态。 |
| 2177 | 2. 收到 `retryable: true` 的 `stream.end` 时,在有界退避之后以 `since_seq = last_seq` |
| 2178 | 重连,并告诉用户 Runtime 说了什么(例如“Runtime 正在关闭——正在重连”)。 |
| 2179 | 收到 `retryable: false` 时,停止,显示原因,并退回到快照。 |
| 2180 | 3. 遇到没有 `stream.end` 的 EOF 或传输错误时,在有界退避之后以 `since_seq = cursor` |
| 2181 | 重连,并显示为连接问题,而不是 Runtime 错误。 |
| 2182 | 4. 流打开之前的 `401`/`403` 或 `404` 是终态(凭据或线程问题)。`5xx` 以退避重试。 |
| 2183 | 5. 永远不要从 `0` 重连来“重新开始”:回放只有按游标才是幂等的。 |
| 2184 | |
| 2185 | Fleet 流(`/v1/fleet/runs/{run_id}/events`)保留自己的结束帧, |
| 2186 | `fleet.stream.error {retryable}` 与 `fleet.replay.cursor_unavailable`。它们与 |
| 2187 | `stream.end` 不同:它们不携带游标,因为 Fleet 客户端从它接受的最后一个 Fleet 事件的 |
| 2188 | 不透明 `cursor` 恢复。 |
| 2189 | |
| 2190 | ### 引导送达 |
| 2191 | |
| 2192 | 把一条引导放进引擎邮箱,与模型读到它是两回事。引擎会丢弃一条其回合已经推进的引导, |
| 2193 | 而被中断或失败的回合会丢掉它已排队的所有内容。API 报告的是 |
| 2194 | 引擎的真实裁定,而不是那次尝试: |
| 2195 | |
| 2196 | - 当引导被接受进邮箱时,条目被持久化为 `queued`。 |
| 2197 | - **已送达。** 引擎把文本提交进了该回合的记录:条目 |
| 2198 | 变为 `completed`,`steer_count` 上升,并发出 `turn.steered` + `item.completed`。 |
| 2199 | `POST .../steer` 返回 `200` 与该回合。 |
| 2200 | - **未送达。** 该回合已经推进、被中断或先失败了:条目 |
| 2201 | 变为 `canceled`,`steer_count` 不上升,并发出 `turn.steer_dropped`, |
| 2202 | 携带 `input`、`reason` 与已结算的 `item`。`POST .../steer` |
| 2203 | 返回 `409`,因此客户端可以保留用户文本并重发,而不是 |
| 2204 | 因为一段从未被看到的引导而清空输入区。 |
| 2205 | - **仍待决。** 当引擎正处于一次长时间工具调用中时发出的引导 |
| 2206 | 无法在那次调用返回前结算,而请求不会为此挂住。 |
| 2207 | 短暂等待之后 `POST .../steer` 返回 `200`,条目仍为 `queued`; |
| 2208 | 最终的 `turn.steered` 或 `turn.steer_dropped` 事件携带裁定。 |
| 2209 | |
| 2210 | 因此,一个把 `200` 当作“模型看到了”的客户端在第三种情况下就是错的: |
| 2211 | 请读条目的状态,或等待那个事件。 |
| 2212 | |
| 2213 | 常见事件名:`thread.started`、`thread.forked`、`turn.started`、 |
| 2214 | `turn.lifecycle`、`turn.steered`、`turn.steer_dropped`、`turn.interrupt_requested`、 |
| 2215 | `turn.completed`、`turn.artifacts`、`item.started`、`item.delta`、`item.completed`、 |
| 2216 | `item.failed`、`item.interrupted`、`approval.required`、`approval.decided`、 |
| 2217 | `approval.timeout`、`user_input.required`、`user_input.answered`、 |
| 2218 | `user_input.canceled`、`tool_call.requested`、`tool_call.resolved`、 |
| 2219 | `tool_call.timeout`、`tool_call.canceled`、`sandbox.denied`、 |
| 2220 | `turn.workspace_snapshot`、`runtime.store_failure`。 |
| 2221 | |
| 2222 | `runtime.store_failure` 是运行时报告操作者自己磁盘状态的故障:会话运行时 |
| 2223 | 存储下的一个线程、回合或条目记录无法被读取、解析或写入。负载携带 `operation` |
| 2224 | (`read` | `parse` | `write`)、`record_kind`(`thread` | `turn` | `item`)、 |
| 2225 | `record_id`、`path`、完整的 `error` 链、根因 `reason`、一个 |
| 2226 | `next_action`(该移开哪个文件,或到哪里检查可用空间与 |
| 2227 | 权限),以及一行 `message`。当 `terminal` 为 `true` 时,该回合 |
| 2228 | 自己的记录不可读或不可写,且不会再有 `turn.completed`; |
| 2229 | 等待该回合的客户端应把它当作失败。 |
| 2230 | |
| 2231 | 智能体消息与推理增量会在其对应的 `item.delta` 事件被编序之前,物化进条目投影。 |
| 2232 | 为避免为每个提供商碎片做一次 fsync,相邻增量在发布之前 |
| 2233 | 被合并到配置的上限:最多 32 ms 或大约 16 KiB |
| 2234 | (一个不可分割的上游块本身可能超过字节目标)。在该未发布窗口内发生进程崩溃会丢掉最近的尾部; |
| 2235 | 没有任何持久事件声称该尾部存在过。一旦 `item.delta` 变为持久,位于其 |
| 2236 | 游标处或之后的快照就包含同一个已物化前缀。 |
| 2237 | |
| 2238 | 当某条执行策略规则导致了这次提示时,`approval.required` 事件可能包含一个 `matched_rule` 字符串。 |
| 2239 | 该字段是对客户端的解释性元数据, |
| 2240 | 不授予也不持久化权限。 |
| 2241 | |
| 2242 | `approval.required`、`approval.decided` 与 `approval.timeout` 携带两个 |
| 2243 | 不同的标识符。`approval_id` 是 **审批** 一节所述、由 Runtime 铸造的一次性能力 |
| 2244 | ——也是 `POST /v1/approvals/{id}` 唯一接受的値 |
| 2245 | ——而 `approval.required` 还会把它重复在旧式的 `id` 字段中,供较旧 |
| 2246 | 客户端使用。`tool_call_id` 是提供商的原始工具调用 ID,仅为 |
| 2247 | 关联而存在。自动解决的提示(线程 `auto_approve`,以及从不 |
| 2248 | 打开模态的 Auto-Review 姿态)也会铸造一个 `approval_id`,因此 |
| 2249 | 该字段在每条路径上只有一种含义;那些 ID 不注册等待者,对 |
| 2250 | 该端点也是惰性的。客户端绝不能把 `tool_call_id` 当作 |
| 2251 | 审批能力,也不能假定它跨线程唯一。 |
| 2252 | |
| 2253 | 线程事件流转发这些负载而不作改动。兼容回合 |
| 2254 | 流携带 `approval_id`、它的 `id` 别名与 `tool_call_id`;待决 |
| 2255 | 快照携带同一个能力与关联符,以便重连的客户端能把审批提示 |
| 2256 | 挂到它的工具行上。 |
| 2257 | |
| 2258 | ## 安全边界 |
| 2259 | |
| 2260 | - **默认仅回环。** 服务器默认绑定到 `127.0.0.1`。 |
| 2261 | `--mobile` 也仅限回环,并在 TLS |
| 2262 | 或经过验证的 overlay 传输边界存在之前拒绝非回环主机。运行时不提供 |
| 2263 | 用户隔离或 TLS。 |
| 2264 | - **可选令牌防护。** `--auth-token` 或 `DEEPSEEK_RUNTIME_TOKEN` |
| 2265 | 要求 `/v1/*` 路由带上匹配的 bearer 令牌。这是一个本地 |
| 2266 | 便利防护,不能代替公网上的 TLS、VPN 或可信反向 |
| 2267 | 代理。 |
| 2268 | - **不代管提供商令牌。** 服务器永不返回 API 密钥。 |
| 2269 | `api_key.source` 能力字段报告 `env`、`config` 或 `missing`—— |
| 2270 | 永不是密钥本身。 |
| 2271 | - **没有托管中转。** app-server 是一个由用户控制的本地进程。 |
| 2272 | 没有任何云组件。 |
| 2273 | - **能力响应**永不泄漏密钥、文件内容或会话 |
| 2274 | 消息正文。它们报告的是*元数据*:存在性、计数、状态标志。 |
| 2275 | |
| 2276 | ### CORS 允许名单 |
| 2277 | |
| 2278 | 运行时 API 自带一份内置的开发源站允许名单: |
| 2279 | `http://localhost:3000`、`http://127.0.0.1:3000`、`http://localhost:1420`、 |
| 2280 | `http://127.0.0.1:1420`、`tauri://localhost`。要添加更多源站(例如 |
| 2281 | 在 Vite 默认的 `:5173` 上开发 UI 时),可用以下任一方式: |
| 2282 | |
| 2283 | - CLI 标志(可重复):`codewhale serve --http --cors-origin http://localhost:5173` |
| 2284 | - 环境变量(逗号分隔):`DEEPSEEK_CORS_ORIGINS="http://localhost:5173,http://localhost:8080"` |
| 2285 | - 配置(`~/.codewhale/config.toml`): |
| 2286 | ```toml |
| 2287 | [runtime_api] |
| 2288 | cors_origins = ["http://localhost:5173"] |
| 2289 | ``` |
| 2290 | |
| 2291 | 用户提供的源站会**堆叠在内置默认值之上**,而不是替换它们。 |
| 2292 | 不支持通配符源站——显式允许名单模型被保留。跨源预检 |
| 2293 | 只声明 `Authorization`、 |
| 2294 | `Content-Type`、`Accept`、`X-Codewhale-Runtime-Token` 以及兼容性的 |
| 2295 | `X-DeepSeek-Runtime-Token` 请求头;自定义请求头不被 |
| 2296 | 允许。于 v0.8.10 加入(#561),于 v0.9.1 收紧(#4454)。 |
| 2297 | |
| 2298 | ## 托管 Fleet 运行时与 SDK 助手 |
| 2299 | |
| 2300 | 运行时 SDK 位于 `npm/runtime-sdk`,并作为 |
| 2301 | `@codewhale/runtime-sdk` 工作区包对外暴露。它有意保持轻薄:每个 |
| 2302 | 助手都只是调用本地 Rust 运行时 API,因此无法绕过 Codewhale 的 |
| 2303 | 沙箱、审批提示、提供商配置或 fleet 账本权威。 |
| 2304 | |
| 2305 | ```js |
| 2306 | import { createRuntimeClient } from "@codewhale/runtime-sdk"; |
| 2307 | |
| 2308 | const client = createRuntimeClient({ |
| 2309 | baseUrl: "http://127.0.0.1:7878", |
| 2310 | token: process.env.CODEWHALE_RUNTIME_TOKEN, |
| 2311 | }); |
| 2312 | |
| 2313 | const created = await client.createFleetRun({ |
| 2314 | target: "this_computer", |
| 2315 | roles: [{ name: "reviewer" }, { name: "verifier" }], |
| 2316 | workflow: { |
| 2317 | id: "release-check", |
| 2318 | kind: "parallel", |
| 2319 | tasks: [ |
| 2320 | { id: "review", name: "Review", instructions: "Review locally.", worker: { role: "reviewer" } }, |
| 2321 | { id: "verify", name: "Verify", instructions: "Verify locally.", worker: { role: "verifier" } }, |
| 2322 | ], |
| 2323 | }, |
| 2324 | }); |
| 2325 | |
| 2326 | // POST /runs only prepares durable work. This call crosses the launch gate. |
| 2327 | await client.startFleetRun(created.run.id); |
| 2328 | |
| 2329 | let cursor; |
| 2330 | for await (const event of client.fleetEvents(created.run.id, { after: cursor })) { |
| 2331 | if (event.cursor) cursor = event.cursor; |
| 2332 | if (event.event === "fleet.replay.cursor_unavailable") { |
| 2333 | // Reload getFleetRun(created.run.id), then reconnect without the old cursor. |
| 2334 | } |
| 2335 | } |
| 2336 | ``` |
| 2337 | |
| 2338 | 托管路径刻意分为两步。`POST /v1/fleet/runs` 会校验并 |
| 2339 | 持久化该运行与队列,但不启动 worker。另一个需认证的 |
| 2340 | `POST /start` 会激活它并调度执行器驱动;它的 `202` 响应 |
| 2341 | 报告 `leased: 0`,因为驱动在拥有该运行之后才做所有租借。 |
| 2342 | 创建要求具名角色、每个角色一个任务负责人、一个 `parallel` |
| 2343 | 工作流,以及一个显式的 Runtime 目标。v0.9.4 只执行 |
| 2344 | `this_computer`;`another_computer` 与 `cloud` 返回 `501`,而不是 |
| 2345 | 静默在本地执行。worker ID 按运行生成;调用方指定的 |
| 2346 | `worker_specs` 返回 `501`,直到自定义 worker 可以被赋予无冲突的 |
| 2347 | 托管身份为止。有效写入根重叠的并行任务会在运行被 |
| 2348 | 记账(journal)之前被拒绝。托管的 `security_policy` 覆盖也会 |
| 2349 | 失败关闭,直到那份文档能被端到端强制执行;可执行 |
| 2350 | 权威来自每个具名角色的工具姿态与有界的任务工作区 |
| 2351 | 范围。 |
| 2352 | |
| 2353 | Fleet 助手覆盖这套 HTTP 表面: |
| 2354 | |
| 2355 | | 助手 | 运行时 API 路由 | |
| 2356 | |---|---| |
| 2357 | | `createFleetRun(spec)` | `POST /v1/fleet/runs` | |
| 2358 | | `startFleetRun(runId)` | `POST /v1/fleet/runs/{run_id}/start` | |
| 2359 | | `listFleetRuns()` | `GET /v1/fleet/runs` | |
| 2360 | | `getFleetRun(runId)` | `GET /v1/fleet/runs/{run_id}` | |
| 2361 | | `listFleetWorkers(runId)` | `GET /v1/fleet/runs/{run_id}/workers` | |
| 2362 | | `getFleetWorker(workerId)` | `GET /v1/fleet/workers/{worker_id}` | |
| 2363 | | `interruptWorker(workerId)` | `POST /v1/fleet/workers/{worker_id}/interrupt` | |
| 2364 | | `stopWorker(workerId)` | `POST /v1/fleet/workers/{worker_id}/stop` | |
| 2365 | | `restartWorker(workerId)` | `POST /v1/fleet/workers/{worker_id}/restart` | |
| 2366 | | `stopFleetRun(runId)` | `POST /v1/fleet/runs/{run_id}/stop` | |
| 2367 | | `replayFleetEvents(runId, options)` | `GET /v1/fleet/runs/{run_id}/events/replay` | |
| 2368 | | `fleetEvents(runId, options)` | `GET /v1/fleet/runs/{run_id}/events`(SSE) | |
| 2369 | |
| 2370 | `stopWorker` 会持久地取消该 worker 的活动任务,并让 Fleet 的其余部分继续运行。 |
| 2371 | `interruptWorker` 是同一个带尝试围栏的取消转换的兼容名称。 |
| 2372 | `stopFleetRun` 会取消每一个排队或活动任务,并把整个运行标记为已取消。 |
| 2373 | |
| 2374 | 回放覆盖聚合的运行/任务转换与隐私有界的个别 |
| 2375 | worker 转换。事件正文省略提示词、工具调用 ID、完成文本、 |
| 2376 | 工件路径/校验和以及取消身份;有界的失败理由 |
| 2377 | 会经过密钥脱敏。`cursor` 是不透明的,在普通追加与 Runtime 重启之间保持稳定。 |
| 2378 | 客户端用 `after=<cursor>` 重连。一次新请求会返回 |
| 2379 | 有界的最新尾部,并在存在更早历史时标记 `history_truncated`。 |
| 2380 | 账本压缩可能移除一个旧游标;那时 JSON 端点返回 `409`, |
| 2381 | 而 SSE 端点发出 |
| 2382 | `fleet.replay.cursor_unavailable`,因此客户端会重新加载当前运行 |
| 2383 | 投影,而不是接受一个静默的空缺。 |
| 2384 | |
| 2385 | `GET /v1/runtime/info` 声明 `fleet_run_create`、`fleet_run_start`、 |
| 2386 | `fleet_event_replay`、`fleet_event_stream` 与 `fleet_local_target`。没有所请求路由的较旧 |
| 2387 | 运行时仍会产生一个带类型的 SDK |
| 2388 | `RuntimeCapabilityError`。 |
| 2389 | |
| 2390 | 验证: |
| 2391 | |
| 2392 | ```bash |
| 2393 | npm test --workspace @codewhale/runtime-sdk |
| 2394 | ``` |
| 2395 | |
| 2396 | ## 智能体运行回执 |
| 2397 | |
| 2398 | 子智能体通道把紧凑的运行回执持久化在 |
| 2399 | `.codewhale/state/subagents.v1.json`。运行时 API 把这些回执暴露为一个 |
| 2400 | 只读检查表面: |
| 2401 | |
| 2402 | | 操作 | 端点 | |
| 2403 | |---|---| |
| 2404 | | 列出已持久化的智能体运行 | `GET /v1/agent-runs` | |
| 2405 | | 检查单个运行 | `GET /v1/agent-runs/{run_id}` | |
| 2406 | | 停止单个运行 | `POST /v1/agent-runs/{run_id}/cancel` | |
| 2407 | |
| 2408 | 响应是与 `agent` 回执所呈现相同的 worker 记录形态: |
| 2409 | `spec.run_id`、`actor_kind`、生命周期 `status`、有界的 `events`、 |
| 2410 | `follow_up`、`takeover`、`artifacts`、`usage` 与 `verification`。对于较旧的记录,`run_id` |
| 2411 | 回退为 worker id,且 `{run_id}` 可以是 |
| 2412 | 运行 id 或 worker id。 |
| 2413 | |
| 2414 | 这些端点不启动也不引导子智能体。这个 API 表面存在的目的是让 |
| 2415 | app/编辑器/无头客户端可以检查 TUI 与 |
| 2416 | 父模型看到的同一批交接回执,并停止一个它们正在展示的运行。 |
| 2417 | |
| 2418 | `POST /v1/agent-runs/{run_id}/cancel` 不接受请求体。它通过与 TUI 的停止及 `agent/cancel` 工具相同的会话范围路径 |
| 2419 | 停止该运行:后代随它一起停止,且写入范围内的子智能体被改动的文件会在 |
| 2420 | 其结果中被点名,而不是被丢掉。它用 worker 记录作答: |
| 2421 | |
| 2422 | - 当记录已是终态时返回 `200`(停止一个已完成的运行是 |
| 2423 | 空操作,返回它的回执); |
| 2424 | - 当拥有它的引擎已接受停止、但在数秒内尚未记录终态 |
| 2425 | 回执时返回 `202`;请轮询 `GET /v1/agent-runs/{run_id}`; |
| 2426 | - 未知运行时返回 `404`; |
| 2427 | - 当该运行属于本运行时并未托管的会话时返回 `409`(例如 |
| 2428 | 一个单独的终端会话);请从那个会话停止它。 |
| 2429 | |
| 2430 | ## 会话生命周期(原生 UI 监督) |
| 2431 | |
| 2432 | | 操作 | 端点 | |
| 2433 | |---|---| |
| 2434 | | 列出会话 | `GET /v1/sessions` | |
| 2435 | | 列出会话摘要 | `GET /v1/sessions/summary` | |
| 2436 | | 获取会话 | `GET /v1/sessions/{id}` | |
| 2437 | | 重命名 / 归档会话 | `PATCH /v1/sessions/{id}` | |
| 2438 | | 删除会话 | `DELETE /v1/sessions/{id}` | |
| 2439 | | 会话存储修复摘要 | `GET /v1/sessions/repair` | |
| 2440 | | 恢复为线程 | `POST /v1/sessions/{id}/resume-thread` | |
| 2441 | | 创建线程 | `POST /v1/threads` | |
| 2442 | | 列出线程 | `GET /v1/threads` | |
| 2443 | | 附着到事件 | `GET /v1/threads/{id}/events?since_seq=0` | |
| 2444 | | 发送消息 | `POST /v1/threads/{id}/turns` | |
| 2445 | | 引导 | `POST /v1/threads/{id}/turns/{turn_id}/steer` | |
| 2446 | | 中断 | `POST /v1/threads/{id}/turns/{turn_id}/interrupt` | |
| 2447 | | 压缩 | `POST /v1/threads/{id}/compact` | |
| 2448 | |
| 2449 | ## 兼容性测试 |
| 2450 | |
| 2451 | 契约快照位于 `crates/protocol/tests/`。请运行: |
| 2452 | |
| 2453 | ```bash |
| 2454 | cargo test -p codewhale-protocol --test parity_protocol --locked |
| 2455 | ``` |
| 2456 | |
| 2457 | 这会校验 app-server 的事件 schema 没有偏离已记录的契约。 |
| 2458 | CI 在每次推送到 `main` 时以及发布标签上都会运行它。 |
| 2459 | |
| 2460 | app-server 的 stdio 控制表面有自己的漂移防护——所声明的 |
| 2461 | `capabilities` 方法集合被钉在 `crates/app-server/src/lib.rs`: |
| 2462 | |
| 2463 | ```bash |
| 2464 | cargo test -p codewhale-app-server capabilities |
| 2465 | ``` |
| 2466 | |
| 2467 | 发布之前,请运行无头冒烟(stdio 探针 + 可选的提供商 |
| 2468 | 矩阵,不泄漏密钥): |
| 2469 | |
| 2470 | ```bash |
| 2471 | scripts/release/app-server-smoke.sh --matrix # dry-run plan |
| 2472 | bash scripts/release/app-server-smoke.test.sh # parser self-test (fake binary) |
| 2473 | ``` |
| 2474 |