# CodeWhale 源码分析：一个用 Rust 写的开源 AI 编程 Agent

> **分析对象**：`Hmbown/CodeWhale`（本地路径 `参考项目/CodeWhale`）
> **分析基准 commit**：`88a158eaa2d6f1fa879bba1f1848a315b4dc6925`（2026-07-22，`fix(tui): focus v0.9.1 chrome on todos and agents (#4711)`）
> **写作日期**：2026-07-24
> **代码规模**：Rust workspace 共 18 个 crate，约 635 个 `.rs` 文件、65 万行 Rust 代码，外加一个 Next.js 官网（`web/`）
> **读者设定**：计算机初学者。每一章都遵循「先打比方 → 再看真实源码 → 逐行解释 → 说清设计取舍」的节奏。文中所有 `文件:行号` 引用均来自上述 commit，可直接对照阅读。

---

## 第 1 章 项目概览：这是什么，为什么用 Rust 写？

### 1.1 一句话介绍

CodeWhale 是一个跑在终端里的 AI 编程 agent（编程智能体）：你给它一个模型、一个任务，它会读你的代码、改文件、跑命令、检查结果，干完活或者需要你拍板时停下来。README 的开篇写得非常直白（`README.md:5-10`）：

> Codewhale is a coding agent for your terminal. … it reads your code, edits files, runs commands, checks its work, and stops when the job is done or it needs you.

它的三个卖点在 README 里被概括为（`README.md:12-25`）：

1. **No lock-in（不锁定厂商）**：DeepSeek、Claude、GPT、Kimi、GLM 等 30 多家 provider，以及你自己架的 vLLM / SGLang / Ollama（无需 API key），全部走同一套运行时和工具集；
2. **Safe by construction（结构上保证安全）**：Plan 模式只读、每个危险调用都要审批、macOS 上用 Seatbelt 沙箱、Linux 上可选 bubblewrap，仓库里的一份 `constitution.json` 会被编译成连"全权模式"都绕不过的写保护；
3. **Work that survives（工作可续命）**：Fleet（多 worker 团队）把每一步记进只增不改的台账（ledger），`fleet resume` 能接着上次断点继续。

### 1.2 身世：从 deepseek-tui 到 CodeWhale

这个项目最早叫 `deepseek-tui`，是一个只支持 DeepSeek 模型的终端小工具。社区用户不断要求接入更多模型，作者干脆把"模型"从"产品"降级成"零件"，重构成了通用 agent 运行时。README 里保留了这句话（`README.md:27-28`）：

> Born as `deepseek-tui`. Its community needed more providers, so we built one where the model is a component, not the product.

这段历史在代码里随处可见：崩溃日志目录仍叫 `~/.deepseek/crashes/`（`crates/tui/src/main.rs:1337`），环境变量兼容名仍叫 `DEEPSEEK_TUI_BIN`（`AGENTS.md:157`），核心客户端类型还叫 `DeepSeekClient`——名字是历史，行为是通用的。

### 1.3 为什么有人用 Rust 写 agent CLI？

主流的 AI 编程 agent（Claude Code、Aider、OpenAI Codex CLI 早期版）大多用 TypeScript 或 Python 写。CodeWhale 选择 Rust，从源码里能看到非常具体的回报：

- **单二进制分发**：`npm install -g codewhale` 装的其实是两个编译好的原生可执行文件（`codewhale` 调度器 + `codewhale-tui` 运行时），用户不需要装 Node 或 Python。
- **内存安全 + 高并发**：agent 的主循环要同时处理 SSE 流式响应、并行工具调用、子 agent 完成事件、用户中途插话，Rust 的 `tokio` 异步运行时和所有权模型让"边流式输出边并行跑工具"这类逻辑不容易写错。
- **性能细节可以抠到底**：项目连全局内存分配器都换成了 mimalloc（`crates/cli/src/main.rs:1-2`），release 构建开了 LTO 和单代码生成单元（`Cargo.toml:67-73`）。
- **安全的"结构性"保障**：权限、沙箱这类东西用 Rust 的类型系统表达（比如 `Result` 强制处理错误、`match` 强制覆盖所有分支），正好契合它 "Safe by construction" 的卖点。

代价也很真实：65 万行代码里充斥着防御性注释和兼容层（每一个历史 bug 都留下了 `#issue号` 注释），迭代速度天然不如脚本语言。

### 1.4 与 Claude Code 的异同（一句话版）

**相同点**：都是"终端 + 系统提示词 + 工具循环"架构的编程 agent，都读 `AGENTS.md`/`CLAUDE.md` 作为项目记忆，都有斜杠命令、Plan 模式、子 agent、MCP、hooks、技能（skills）这些概念——概念地图几乎一一对应。

**不同点**：Claude Code 是 Anthropic 官方闭源产品、绑死自家模型、用 TypeScript 实现；CodeWhale 是 MIT 开源的社区项目、模型无关（36 个内置 provider 枚举见 `crates/config/src/provider_kind.rs:13`）、用 Rust 实现，并且把"安全"做成了可验证的机制（沙箱、仓库宪法、审批协议）而不是仅靠提示词约束。

```mermaid
graph LR
    subgraph 相同骨架["两者共有的概念骨架"]
        A[终端 UI] --> B[系统提示词 + 项目记忆]
        B --> C[Agent 主循环]
        C --> D[工具调用 + 权限审批]
        D --> C
    end
    subgraph CC["Claude Code"]
        CC1[闭源 TypeScript] --> CC2[仅 Anthropic 模型]
    end
    subgraph CW["CodeWhale"]
        CW1[开源 MIT · Rust] --> CW2[36+ provider / 本地模型]
        CW1 --> CW3[沙箱 + constitution.json<br/>可验证的安全机制]
    end
```

---

## 第 2 章 全景架构：18 个 crate 与"一个巨人"

### 2.1 workspace 结构

打开根目录 `Cargo.toml`，workspace 成员一目了然（`Cargo.toml:1-22`）：

```toml
[workspace]
members = [
    "crates/agent",      # 模型注册表：ModelInfo / ModelFamily
    "crates/app-server", # 对外 runtime API（OpenAI 兼容 chat_completions）
    "crates/build-support",
    "crates/cli",        # codewhale 调度器二进制
    "crates/config",     # 配置 schema、ProviderKind、路由
    "crates/core",       # （拆分中的）核心抽象
    "crates/execpolicy", # shell 命令前缀允许/拒绝策略
    "crates/hooks",
    "crates/lane",       # Lane 注册表 + Runtime 后端
    "crates/mcp",
    "crates/protocol",   # 跨 crate 的协议类型（fleet 记录、runtime 类型）
    "crates/release",
    "crates/secrets",    # API key 的 OS 钥匙串存储
    "crates/state",      # 会话/线程持久化
    "crates/tools",      # 工具结果类型（ToolError、ApprovalRequirement 等）
    "crates/tui",        # ★ codewhale-tui：真正的运行时巨兽
    "crates/workflow",   # Workflow 的声明式 IR
    "crates/workflow-js" # Workflow 的 QuickJS 沙箱执行器
]
```

但这里有一个对读者极其重要的"建筑实况"：**crate 拆分是进行时的，不是完成时**。官方架构文档开宗明义（`docs/ARCHITECTURE.md` 开头）：

> `crates/tui` is still the live end-user runtime for the TUI, runtime API, task manager, and tool execution loop. Other workspace crates are being split out incrementally, but they are not yet the sole runtime source of truth.

也就是说，虽然名字叫 `tui`，这个 crate 里其实装着：agent 主循环（`core/engine/`）、全部 60+ 个内置工具（`tools/`）、LLM 客户端（`client.rs`）、MCP 实现（`mcp.rs`）、沙箱（`sandbox/`）、Fleet 多 agent 控制面（`fleet/`）、子 agent 系统（`tools/subagent/`）、压缩（`compaction.rs`）、提示词（`prompts.rs`）……它更像"整栋楼"，只是门牌上写着 TUI。`crates/tui/src/core/engine.rs` 一个文件就有 5133 行，`core/engine/tests.rs` 还有 11934 行测试。

### 2.2 分层图

```mermaid
graph TD
    subgraph 用户界面层
        UI1["codewhale（cli crate）<br/>调度器：找兄弟二进制、转发子命令"]
        UI2["codewhale-tui（tui crate）<br/>ratatui 交互界面"]
        UI3["codewhale exec<br/>无头模式（脚本/CI）"]
        UI4["codewhale web / serve<br/>127.0.0.1 浏览器客户端 + runtime API"]
    end

    subgraph 引擎层["引擎层（都在 crates/tui/src/core/）"]
        EN1["engine.rs<br/>Engine 结构体 + Op 处理"]
        EN2["engine/turn_loop.rs<br/>★ Agent 主循环（第 6 章）"]
        EN3["authority.rs<br/>权限真值表"]
        EN4["events.rs / ops.rs<br/>引擎⇄界面的事件与操作"]
    end

    subgraph 工具与扩展层["工具与扩展层（crates/tui/src/tools/ 等）"]
        T1["60+ 内置工具<br/>file / Bash / git / web / agent…"]
        T2["skills / hooks / plugins"]
        T3["MCP 客户端（stdio/SSE/HTTP）"]
        T4["sandbox：Seatbelt / bubblewrap"]
    end

    subgraph 模型层
        M1["client.rs · DeepSeekClient<br/>HTTP + SSE 流式"]
        M2["llm_client/mod.rs · LlmClient trait"]
        M3["config crate · ProviderKind ×36"]
    end

    UI1 --> UI2
    UI2 --> EN1 --> EN2
    UI3 --> EN1
    UI4 --> EN1
    EN2 --> T1
    EN2 --> M1
    T1 --> T4
    T2 --> T1
    T3 --> T1
    M1 --> M3
```

### 2.3 读懂这套架构的三个关键点

0. **先把 18 个 crate 各记一句话**：`cli` 是调度器；`tui` 是运行时本体；`config` 管配置 schema 与 36 家 provider 的身份；`protocol` 放跨 crate 的协议类型（fleet 记录、runtime 消息）；`tools` 放工具的结果类型（`ToolError`、`ApprovalRequirement` 等"词汇表"）；`execpolicy` 是 shell 前缀策略引擎；`secrets` 管 OS 钥匙串；`state` 管会话持久化；`workflow`/`workflow-js` 是工作流的声明式 IR 和 QuickJS 执行器；`agent`/`core`/`hooks`/`mcp`/`lane`/`app-server`/`build-support`/`release` 是正在从 `tui` 巨人身上拆下来的外围器官。记住"tui 是巨人，其余是拆分中的器官"，读代码时就不会迷路。
1. **双二进制设计**。`codewhale`（`crates/cli`）只是个"调度员"：处理 `auth`、`doctor`、`update` 这类不需要完整运行时的子命令；真正干活时它去同目录找 `codewhale-tui` 并把参数转发过去（`crates/cli/src/lib.rs:4215` 的 `locate_sibling_tui_binary()`）。AGENTS.md 特别提醒开发者这两个文件必须待在同一目录（`AGENTS.md:155-157`）。
2. **界面与引擎之间是"消息管道"而不是函数调用**。UI 通过 `Op` 枚举（`crates/tui/src/core/ops.rs:87`）给引擎发操作，引擎通过 `Event` 枚举（`core/events.rs`）往 UI 推事件。这解释了为什么 TUI 流式输出时不卡、为什么同一个引擎能同时服务 TUI、`exec` 无头模式和 web 客户端。
3. **一切围绕"前缀缓存"设计**。你会在后文反复看到 prefix cache 这个词：DeepSeek 等 provider 对"和上次请求开头完全相同的部分"有 KV 缓存折扣，所以提示词组装顺序、工具目录稳定性、压缩时机都被设计成"尽量不动请求开头"。这是这个项目非常独特的一条暗线，第 5、6、8 章都会遇到。

---

## 第 3 章 启动流程：从 `main()` 到第一帧画面

### 3.1 两个 main，各就各位

**第一站：调度器**（`crates/cli/src/main.rs`，全文仅 18 行）：

```rust
#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;   // 换内存分配器

fn main() -> std::process::ExitCode {
    #[cfg(unix)]
    unsafe {
        libc::signal(libc::SIGPIPE, libc::SIG_DFL);        // 第 12-15 行
    }
    codewhale_cli::run_cli()
}
```

这段代码虽小，却有两个典型"Rust CLI 生存技巧"：换 mimalloc 分配器提升性能；把 `SIGPIPE` 重置为默认行为——否则 `codewhale doctor | head` 这种"管道下游先退出"的常见操作会让 Rust 的 `println!` 在写破裂管道时 panic（注释里引用了 issue #4030）。这就是系统编程语言写 CLI 的日常：连自己怎么被 `head` 掐死都要管。

`run_cli()` 解析子命令后，对需要完整运行时的命令，定位并 `exec` 兄弟二进制 `codewhale-tui`（`crates/cli/src/lib.rs:4215` 附近）。

**第二站：运行时**（`crates/tui/src/main.rs:1292`）的 `main()` 做六件大事，顺序非常讲究：

```mermaid
sequenceDiagram
    participant M as main() @ main.rs:1292
    participant H as panic hook
    participant P as 插件发现
    participant T as codewhale-main 线程
    participant A as run_async_main() @ main.rs:1399
    participant U as run_tui() @ ui.rs:872

    M->>M: ① 重置 SIGPIPE（1296-1299）
    M->>M: ② Windows 控制台 UTF-8 / rustls 加密提供者（1302-1303）
    M->>M: ③ 进程加固 process_hardening（1308）<br/>必须在 tokio 和任何线程启动前
    M->>H: ④ 安装 panic 钩子（1314-1348）
    M->>P: ⑤ clap 解析 + 插件发现（1356-1372）<br/>必须在读 .env 之前
    M->>T: ⑥ 新建大栈线程跑 run_async_main（1381-1385）
    T->>A: tokio 运行时启动
    A->>A: 信号清理任务 / 提示词覆盖加载（1420-1428）
    A->>U: 无子命令 → 进入交互 TUI
```

逐条解释：

1. **进程加固先行**（1308 行）：`sandbox::process_hardening::apply_process_hardening()` 必须在 tokio 启动、任何线程 spawn 之前跑，因为加固手段（如关闭多余文件描述符）对已有线程不安全。
2. **panic 钩子**（1314-1348 行）：TUI 程序最怕 panic——它把终端切进了"备用屏幕 + 原始模式"，一崩就把用户的 shell 留在乱码状态。这个钩子先调 `emergency_restore_terminal()` 恢复终端，再把崩溃信息写进 `~/.deepseek/crashes/时间戳.log`，最后才交给默认钩子。
3. **主线程让位**（1381-1396 行）：真正的运行时不在主线程跑，而是 spawn 一个名为 `codewhale-main`、带大栈的线程。注释解释了原因（1374-1380 行）：TUI 状态机嵌套太深，debug 构建下递归处理一个弹窗事件就可能超过 macOS 主线程默认的 8 MiB 栈。
4. **子命令分流**（`run_async_main`，1399 行起）：`doctor`（环境体检）、`exec`（无头跑任务）、`web`（浏览器客户端）、`fleet`、`mcp`、`serve`、`resume`、`login` 等在这里各走各路；不带子命令才进入交互界面。

### 3.2 进入 TUI：run_tui 的"开机自检"

`crates/tui/src/tui/ui.rs:872` 的 `run_tui()` 负责把终端变成应用窗口，步骤像飞机起飞检查单：

- **OSC 8 超链接开关**（892-899 行）：让终端里的文件路径可点击，Windows 老控制台默认关；
- **终端探测带超时**（907-941 行）：`enable_raw_mode()` 放进 `spawn_blocking` 并用 `tokio::time::timeout` 包住——防止某些无响应终端让程序永远挂起，超时后还要处理"探测放弃了、但 raw mode 又迟到地开启成功"的竞态；
- **日志重定向要先于进备用屏幕**（948-964 行）：否则日志字节漏进 TUI 画面，会造成 Windows 上的"滚动恶魔"bug（issue #1909）；
- **进入备用屏幕、开鼠标捕获、括号粘贴、Kitty 键盘协议**（965 行起）：至此终端就绪，主事件循环开始。

**设计取舍**：启动代码里几乎每一步都带一个 issue 编号的注释。这反映了这个项目的工程文化——每个防御性措施都来自一个真实翻车现场。对读者来说，这是学习"生产级 TUI 要处理多少边界情况"的绝佳样本。

### 3.3 子命令全家福：一个二进制，多张面孔

`run_async_main` 里的子命令分发表（`crates/tui/src/main.rs:246` 的 `enum Commands`）揭示了这个工具的全貌。按用途整理：

| 类别 | 子命令 | 干什么 |
|---|---|---|
| 日常主力 | （无子命令） | 打开交互 TUI |
| 无头/脚本 | `exec "任务"`（`Exec`，`main.rs:1493`） | 不开界面直接跑完一个任务，给脚本和 CI 用；`--auto` 才允许工具调用 |
| 环境体检 | `doctor`（`main.rs:1439`） | 检查 key、网络、依赖工具是否就绪 |
| 认证 | `login` / `logout` / `auth` | 存取 API key、OAuth 设备码登录 |
| 会话管理 | `sessions` / `resume` / `fork` | 列出、恢复、分叉历史会话 |
| 多 agent | `fleet`（run/status/logs/resume/stop…） | 管理 worker 团队与台账 |
| 模型 | `models` | 列出并探测可用模型 |
| 扩展 | `mcp`（list/init/connect/tools） | 管理 MCP server 配置 |
| 界面之外 | `web` / `serve` | 浏览器客户端 / 常驻 runtime API 服务 |
| 策略调试 | `execpolicy` / `sandbox` / `features` | 查看命令策略、沙箱能力、特性开关 |
| 评测 | `eval` / `scorecard` / `review` | 跑评测、看记分卡、离线审 diff |
| 初始化 | `init` | 在仓库里生成 `AGENTS.md` 等项目文件 |

注意 `exec` 分支里的一个细节（`main.rs:1493` 起）：无头模式也完整加载配置、合并工作区覆盖、走同一个引擎——**无头不是阉割版，只是没有界面**。这正是"UI 与引擎靠消息管道分离"架构的红利：TUI、`exec`、`web` 三个前端复用同一个引擎。

再看一个普通用户感知不到的启动细节：`exec` 里 `--no-project-config` 参数（`main.rs:1500-1506` 注释）存在的理由是让无头评测拿到"只由显式 `--config` 决定"的可复现配置面——为可复现性特意留的开关。这类"为 CI/评测场景留的暗门"遍布代码库，是它被真实用于自动化流水线的证据。

---

## 第 4 章 输入捕获与分流：你按下回车后发生了什么

### 4.1 输入泵：一个专职读键盘的线程

TUI 的主循环不能自己阻塞着等键盘——它还要同时渲染流式输出、处理引擎事件。CodeWhale 的方案是一个专职"输入泵"线程（`crates/tui/src/tui/ui.rs:487` 的 `TerminalInputPump`）：

```rust
let handle = thread::Builder::new()
    .name("codewhale-terminal-input".to_string())   // 第 526 行
    .spawn(move || {
        while !thread_stop.load(Ordering::Acquire) {
            match event::poll(TERMINAL_INPUT_POLL_INTERVAL) {  // crossterm 轮询
                Ok(true) => match event::read() {              // 读到按键
                    Ok(event) => { tx.send(TerminalInputMessage::Event(event)) ... }
```

（`ui.rs:525-566`）这个线程用 **crossterm** 库轮询终端事件，通过 `std::sync::mpsc` 通道发给主循环；没事件时定期发 `Heartbeat` 心跳。为什么需要心跳？因为 crossterm 的 `event::read` 是阻塞调用，线程可能永远卡死（比如 Windows 控制台抽风、Unix tty 停止送字节）——主循环发现泵超过 `TERMINAL_INPUT_STALL_TIMEOUT`（5 秒，`ui.rs:470`）没心跳，就把旧线程"遗弃"（detach）并换一个新泵（`ui.rs:664` 起）。渲染库方面，画面由 **ratatui 0.30** 绘制（`crates/tui/Cargo.toml:50`），crossterm 0.29 负责事件与终端控制。

```mermaid
graph LR
    subgraph 输入泵线程["codewhale-terminal-input 线程"]
        P[event::poll / event::read<br/>crossterm 0.29]
    end
    subgraph 主循环["TUI 主循环（async）"]
        Q{通道 recv}
        R[引擎事件 drain<br/>每批最多 16 个 / 8ms]
        S[ratatui 渲染]
    end
    K[键盘/鼠标/粘贴] --> P
    P -- TerminalInputMessage::Event --> Q
    P -- Heartbeat --> Q
    Q --> R --> S
    S --> Q
```

注意主循环里的"输入公平性"设计：每轮最多只 drain 16 个引擎事件、总预算 8ms（`ui.rs:475-477`），防止模型输出洪峰把键盘输入饿死——这来自 issue #1830 的教训。

### 4.2 回车之后：五级分流

你敲下回车，按键事件到达 `KeyCode::Enter` 处理分支（`ui.rs:6139`）。接下来是一套**有先后顺序的拦截链**——像机场的层层安检：

```mermaid
flowchart TD
    E[回车键 @ ui.rs:6139] --> A{① 计划确认弹窗激活?<br/>handle_plan_choice}
    A -- 是 --> A1[按 1-4 处理计划选择]
    A -- 否 --> B{② 以 # 开头?<br/>记忆快速记录}
    B -- 是 --> B1[追加到 memory.md，不发请求]
    B -- 否 --> C{③ 以 ! 开头?<br/>handle_bang_shell_input @ ui.rs:9317}
    C -- 是 --> C1["走正常审批路径执行 shell<br/>Op::RunShellCommand"]
    C -- 否 --> D{④ 像斜杠命令?<br/>looks_like_slash_command_input @ app.rs:122}
    D -- 是 --> D1[execute_command_input<br/>内置/自定义斜杠命令]
    D -- 否 --> F[⑤ 普通消息<br/>submit_or_steer_message @ ui.rs:11662]
    F --> G{引擎正忙?}
    G -- 忙 --> G1[转为 steer 插话<br/>注入当前回合]
    G -- 闲 --> G2[dispatch_user_message @ ui.rs:8651<br/>开新回合]
```

逐级解释：

1. **计划选择**：Plan 模式下模型给出计划后，输入 `1`-`4` 是选按钮而不是发消息（`ui.rs:6159`）。
2. **`#` 快速记忆**：输入 `# 数据库用 Postgres`，这句话不进模型，而是作为带时间戳的 bullet 追加到用户记忆文件（issue #492，`ui.rs:6170-6173`）。
3. **`!` shell 直通**：`!ls -la` 直接把命令送进引擎的 shell 通道——注意它**走正常审批路径**（`Op::RunShellCommand`，`ui.rs:9331-9340`），不是开后门。
4. **斜杠命令判定**（`app.rs:122`）：`looks_like_slash_command_input` 检查去掉前导空白后是否以 `/` 或 `$` 开头（`$技能名` 是技能的快捷调用）。命中则交给命令系统（第 10 章详述），这类输入**完全不碰模型**。
5. **普通消息**：如果引擎正在跑（`app.is_loading`），这条消息变成 **steer（插话）**——通过 `rx_steer` 通道注入进行中的回合，主循环在下一个请求前把它追加进对话（第 6 章会看到消费点）；引擎空闲则正常开新回合。

**真实例子**：
- 输入 `/model deepseek-reasoner` → 第 ④ 级，切换路由，零 token 消耗；
- 模型正在改代码时输入 "顺便把测试也跑了" → 第 ⑤ 级 steer 分支，追加为新的 user 消息，模型下一个推理步就能看到；
- 输入 `!git status` → 第 ③ 级，作为 shell 工具调用执行（计入审批），输出进入对话历史；
- 输入 `$code-review` → `looks_like_slash_command_input` 把 `$` 前缀也当命令（`app.rs:124-128`），触发技能补全菜单，选中后等价于调用对应技能；
- 输入 `# 这个项目用 pnpm 不用 npm` → 第 ② 级拦截，写进记忆文件，模型本轮根本不会被惊动；
- 从剪贴板粘贴一大段日志 → 括号粘贴（bracketed paste）保证整段作为一个块进输入框，不会被当成一连串按键误触发回车。

**设计取舍**：这套分流全部发生在 UI 层，引擎只认 `Op`。好处是引擎逻辑干净（不用懂斜杠命令），代价是 UI 层的 `ui.rs` 膨胀到了 16815 行——这是这个项目"UI 厚重、引擎内聚"风格的缩影。

---

## 第 5 章 上下文组装：系统提示词是怎么拼出来的

### 5.1 总装函数

每次会话的系统提示词由 `crates/tui/src/prompts.rs:1137` 的 `system_prompt_for_mode_with_context_skills_session_and_approval()` 组装。这个函数名长得像德国火车，但它的组装顺序体现了一条贯穿全项目的铁律——文件注释里称为 **volatile-content-last invariant（易变内容靠后不变式）**（`prompts.rs:1078-1092`）：

> Blocks are appended in order from most-static to most-volatile so DeepSeek's KV prefix cache hits the longest possible byte prefix turn-over-turn.

翻译成大白话：provider 的 KV 前缀缓存按"请求开头有多少字节和上次一样"算钱，所以**永远不变的内容放最前面，每回合都可能变的内容放最后面**，让缓存命中尽可能长。

### 5.2 组装的层次

```mermaid
flowchart TD
    subgraph 缓存稳定区["① 缓存稳定区（尽量永不变）"]
        L0[本地化前言<br/>非英语会话的思维语言指令]
        L1["宪法 BASE_PROMPT<br/>prompts/text.rs:46<br/>身份/工具纪律/输出法则"]
        L2["项目上下文<br/>AGENTS.md 等指令文件"]
        L3[用户宪法块 + 项目上下文包]
        L4[技能目录 ## Skills]
        L5["核心执行纪律<br/>CORE_EXECUTION_PROFILE_PROMPT"]
        L6["压缩交接模板 COMPACT_TEMPLATE"]
    end
    subgraph 易变区["② 易变区（WorldState 片段，每回合可重建）"]
        V1[环境块：workspace/git/日期]
        V2[用户记忆 + 当前目标]
        V3[权限指令文件]
        V4[路由片段：当前模型/详细度]
        V5[跨会话交接 handoff.md]
    end
    subgraph 收尾区["③ 收尾（利用近因效应）"]
        T1[权威复述 authority recap]
        T2[本地化收尾指令]
    end
    L0 --> L1 --> L2 --> L3 --> L4 --> L5 --> L6 --> V1 --> V2 --> V3 --> V4 --> V5 --> T1 --> T2
```

对应代码逐段看（`prompts.rs:1144-1336`）：

- **宪法**（1144-1153 行）：`BASE_PROMPT` 编译期内嵌在二进制里（`prompts/text.rs:46`），开头是 `## Codewhale\n\nYou are Codewhale, an agent working alongside the user...`。它的措辞相当"文学化"——比如告诉模型"The A is already yours（你的 A 已经拿到了）"，用"老师第一天就给学生 A"的比喻让模型"停止表演、开始创造"。这种人格化提示词是 deepseek-tui 时代的遗产，也是这个项目的独特风味。
- **项目上下文**（1156、1176 行）：`load_project_context_with_parents(workspace)` 收集指令文件（下一节）。
- **技能块**（1228-1247 行）：扫描多个技能目录渲染 `## Skills` 目录，模型之后可以用 `load_skill` 工具按需加载全文。
- **核心执行纪律 + 压缩模板**（1252-1258 行）：告诉模型怎么干活、以及 `/compact` 时用什么格式写 `.codewhale/handoff.md`。
- **WorldState 易变片段**（1266-1302 行）：环境、记忆、目标、路由、交接历史，组装成独立的 `SystemBlock`。
- **收尾复述**（1310-1334 行）：最后再放一遍权限复述和语言指令——利用大模型的"近因效应"（对最后看到的内容印象最深）。

### 5.3 AGENTS.md 的收集规则

项目记忆文件的收集逻辑在 `crates/tui/src/project_context.rs`。候选文件名在 `:35` 附近的常量表里：

```rust
// project_context.rs:35-37 节选
"AGENTS.md",        // 跨 agent 的项目指令（规范名，最高优先级）
...
"CLAUDE.md",        // Claude 风格指令（兼容）
```

规则要点：

1. **优先级**：`AGENTS.md` 是规范名；`CLAUDE.md` 和各种 `instructions.md` 变体是兼容项（`project_context.rs:6-8` 注释）。老项目的 `WHALE.md` 会被**忽略并警告**（`:53`），提示用户迁移。
2. **向上回溯**：从 workspace 目录一路向父目录找，支持 monorepo——根目录一份 `AGENTS.md` 对所有子包生效（`:931` 注释）。
3. **全局文件**：还会读 `~/.codewhale/AGENTS.md`、`~/.agents/AGENTS.md`、兼容路径 `~/.deepseek/AGENTS.md`（`:68-70`）。
4. **防爆上限**：所有规则文件合并后超过 500 KB 会被截断（`MAX_RULES_BLOCK_BYTES`，`project_context.rs:92`）——防止有人（或恶意仓库）用巨型指令文件撑爆上下文。
5. **git/环境信息**：易变区的环境块由 `render_environment_block` 渲染（`prompts.rs:1267`），包含 workspace 路径、git 状态等，每回合重建，所以放在缓存稳定区之后。

**设计取舍**：CodeWhale 把"系统提示词"当成一个需要工程管理的**产物**而不是一坨字符串：恒定区/易变区分层、编译期内嵌基线、运行时 diff 重建易变片段、还有 `prompt_zones.rs` 的"三区前缀契约"在运行时校验提示词漂移（第 6 章会看到校验点）。代价是组装代码复杂；收益是长会话的 KV 缓存命中率可观测、可优化（TUI 里甚至有 `/cache` 命令查看缓存遥测）。

### 5.4 一个"请求里到底装了什么"的实例

假设你在一个带 `AGENTS.md` 的 Rust 仓库里、用中文界面、开着记忆功能发起第一回合，最终请求的 `system` 字段大致是这样一叠"三明治"：最前面是中文思维前言（"请用简体中文思考与回复"）；接着是约两千词的宪法 `BASE_PROMPT`；然后是 `AGENTS.md` 全文、用户级宪法、项目上下文包（README 摘要，上限 4000 字符，`project_context.rs:93`）；再往后是技能目录（每个技能一行简介，全文按需加载）；核心执行纪律和压缩交接模板收尾恒定区。易变区里是第一份环境块（workspace 路径、git 分支、日期）、`<user_memory>` 块、当前路由（模型 ID、详细度）。最后是权限复述和中文收尾指令。用户消息本身还会携带 per-turn 元数据（working set 摘要等）——注意它**不在系统提示词里**，因为每回合都变，放系统提示词里会毁掉整个前缀缓存（`prompts.rs:1091-1092` 注释明确记录了这个搬迁决策）。

下次你再看到"AI 助手好聪明，什么都知道"，可以想起这叠三明治：它知道的一切，要么是这叠提示词里写好的，要么是工具刚刚查回来填进历史的。没有魔法，只有组装。

---

## 第 6 章 Agent 主循环：整个项目的心脏

如果说这本书你只记住一个文件，请记住 `crates/tui/src/core/engine/turn_loop.rs`（4243 行）。agent 的主循环就住在里面。

### 6.1 循环本体：是 loop，不是递归

入口函数是 `handle_deepseek_turn`（`turn_loop.rs:286`）——名字又一次暴露 deepseek-tui 出身，但今天它驱动所有 provider。它的结构是**一个外层 `loop`（342 行）+ 一个内层流式消费 `loop`（785 行）**，不是递归也不是显式状态机：

```mermaid
flowchart TD
    START[handle_deepseek_turn @ turn_loop.rs:286] --> L0{"外层 loop @ :342<br/>每个'推理步'转一圈"}
    L0 --> C1{取消令牌触发?}
    C1 -- 是 --> INT[返回 Interrupted]
    C1 -- 否 --> C2[drain 插话 steer + 子 agent 完成事件]
    C2 --> C3[refresh_system_prompt 刷新系统提示词]
    C3 --> C4{到 max_steps?<br/>默认 1000 @ engine.rs:492}
    C4 -- 是 --> BRK1[break：步数耗尽]
    C4 -- 否 --> C5{目标 token 预算耗尽?}
    C5 -- 是 --> BRK2[break]
    C5 -- 否 --> C6{该压缩了?<br/>should_compact}
    C6 -- 是 --> C7[自动压缩后继续]
    C6 -- 否 --> C8[上下文预算预检 + LSP 诊断注入]
    C7 --> C8
    C8 --> R[组装 MessageRequest @ :658<br/>model/messages/system/tools/effort/stream=true]
    R --> S["tokio::select!<br/>取消 vs create_message_stream @ :689"]
    S --> INNER{"内层 loop @ :785<br/>逐块消费 SSE 流"}
    INNER -->|文本/thinking/工具调用增量| INNER
    INNER -->|流结束| T{tool_uses 为空?}
    T -- 有工具调用 --> X[规划并执行工具<br/>追加结果到 messages]
    X --> L0
    T -- 无工具调用 --> Z{有插话/子agent完成/<br/>REPL块/目标续接?}
    Z -- 有 --> L0
    Z -- 都没有 --> DONE[break @ :1619<br/>回合真正结束]
    DONE --> RET["返回 Completed @ :3033"]
```

### 6.2 一圈循环的逐段解读

**第 1 段：回合开头的"家务"（342-404 行）**。每圈先检查取消令牌（`cancel_token`，用户按 Esc 时触发）；然后把用户在模型干活期间的插话（steer）从 `rx_steer` 通道抽出来、作为 user 消息追加进会话（348-365 行）；再收割已经完成的子 agent 结果（372 行，第 9 章详述）；刷新系统提示词；检查步数上限和目标 token 预算。

**第 2 段：压缩与预算（406-532 行）**。调 `should_compact()` 判断是否需要自动压缩（第 8 章）；再做一次"上下文输入预算预检"——估算输入 token 超过该路由的窗口上限时，先尝试恢复（压缩/裁剪），恢复超过 `MAX_CONTEXT_RECOVERY_ATTEMPTS` 次才报错退出（497-521 行）。接着把 LSP 诊断（编译错误等）作为合成 user 消息注入（527 行）——模型在下一次推理前就能看到自己上一轮改代码改出的编译错误。

**第 3 段：组装请求（534-683 行）**。核心是这个 `MessageRequest`（658-683 行）：

```rust
let request = MessageRequest {
    model: self.session.model.clone(),
    messages: self.messages_with_turn_metadata(),   // 全部历史
    max_tokens: effective_max_output_tokens_for_route(...),
    system: self.session.system_prompt.clone(),      // 第 5 章的产物
    tools: active_tools.clone(),                     // 本步生效的工具目录
    tool_choice: if active_tools.is_some() {
        if self.config.strict_tool_mode { Some(json!("required")) }
        else { Some(json!({ "type": "auto" })) }
    } else { None },
    reasoning_effort: effective_reasoning_effort,    // 思考档位
    stream: Some(true),                              // 永远流式
    temperature: None,                               // 交给 provider 默认
    ...
};
```

注意这里有两个"缓存守门员"在请求发出前工作：`PrefixStabilityManager`（565 行）检测系统提示词或工具集相对上一回合是否漂移并上报 TUI 计数器；`frozen_prefix` 三区前缀契约（623 行）在首回合冻结基线、后续回合校验漂移（issue #2264）。

**第 4 段：流式消费（689-1360 行）**。请求通过 `tokio::select!` 与取消令牌赛跑（689-696 行，`biased` 表示优先检查取消）。SSE 流建立后进入内层 `loop`（785 行），逐事件消费：文本增量、thinking 增量、工具调用增量。这里埋着大量工程细节：

- **chunk 超时**：单个数据块超过 `stream_chunk_timeout_budget` 没到就判定 Stall（789-803 行）；
- **睡眠唤醒检测**：`Instant`（单调时钟，休眠时暂停）和 `SystemTime`（墙上时钟）双轨记录最后进度时间，两者偏差大说明"电脑合盖休眠了"而不是"网络死了"（773-779 行注释，issue #2990）；
- **透明重试**：流在没吐出任何内容前断线，最多静默重发同一个请求 `MAX_STREAM_RETRIES` 次（336-340、761-768 行）；
- **Anthropic 签名 thinking 原样回放**（736-737 行，issue #3014）：带签名的思考块必须在工具循环里逐字节回传，否则 Anthropic 接口报错。

**第 5 段：分叉点——有没有工具调用（1373-1620 行）**。流结束后：

- **有工具调用** → 进入工具规划与执行（下一节），执行完把结果追加进 `messages`，外层 `loop` 转下一圈，带着新历史再次请求模型。这就是"请求 → 工具 → 追加 → 再请求"的经典 agent 结构。
- **没有工具调用**，还有五种"续命"理由，按顺序检查（1394-1619 行）：有待注入的插话？→ 有完成的子 agent？→ 回复里有 ```repl 代码块要本地执行？→ 目标未达成需要续接（goal continuation）？→ 真的没有了才 `break`（1619 行）。这个 `break` 是**唯一的正常终止条件**。
- 另外有两个"防痴呆"守卫：`StuckGuard` 发现模型连续产出无进展回复会先警告后强停（1374-1391 行）；`ReadRepeatGuard` 防止模型无限重读同一批文件。

### 6.3 工具执行：并行但有纪律

工具执行段在 2202 行之后。模型一口气可能返回多个工具调用，引擎的规划是：

```mermaid
flowchart LR
    P[本步工具调用列表] --> G{逐个过闸}
    G -->|被守卫拦截| G1[直接返回拦截结果]
    G -->|需审批| G2[Event::ApprovalRequired<br/>等用户决定]
    G -->|放行| H{supports_parallel?}
    H -->|是| F["FuturesUnordered 并行池<br/>shell 另有信号量限流"]
    H -->|否| Q[串行执行 execute_tool_with_lock]
    F --> O[按原始顺序收集 outcomes]
    Q --> O
    O --> A[追加 tool_result 到 messages<br/>→ 外层 loop 下一圈]
```

关键代码（`turn_loop.rs:2214-2336`）：并行池用 `FuturesUnordered` 收集 future；shell 类工具额外受 `MAX_PARALLEL_SHELL_EXEC` 信号量限流；超大输出在扇出前先落盘（spillover，`tools/truncate.rs`）；取消时直接 drop 整个池子——"Dropping FuturesUnordered drops every still-active tool future … instead of merely waiting for cooperative cancellation"（2333-2335 行注释），用 Rust 的 Drop 语义实现硬取消。

### 6.4 中断机制

中断贯穿三层：① 用户按 Esc → UI 置 `cancel_token`；② 外层 loop 顶部、流式 `select!`、并行池 `select!` 都监听这个令牌；③ 审批等待（下一章）也在同一个令牌上 `select!`。所以无论模型在流式输出、工具在跑、还是弹着审批框，Esc 都能在当前步骤边界停下来，返回 `TurnOutcomeStatus::Interrupted`。

**设计取舍**：这个主循环的风格是"一个巨型函数 + 海量防御性分支"，而不是优雅的小状态机。好处是所有边界情况（休眠、断流、插话、子 agent 晚到、REPL、目标预算）的处理都摊开在同一视野里，注释引用 issue 编号可追溯；代价是 4243 行的函数对新人不友好——项目自己的应对是把测试写到 11934 行（`core/engine/tests.rs`），用测试代替结构来保证安全。

### 6.5 一回合的完整剧本：以"修一个失败的测试"为例

把前面所有零件串起来，看 `codewhale exec "fix the failing test"` 在引擎内部的完整时间线：

```mermaid
sequenceDiagram
    participant U as 用户
    participant E as 外层 loop（推理步）
    participant M as 模型 API
    participant T as 工具层
    U->>E: ① "fix the failing test"<br/>Op::SendMessage
    Note over E: 第 1 圈：压缩检查/预算预检通过
    E->>M: ② 请求（系统提示词+工具目录+用户消息）
    M-->>E: ③ 流式回复：文本"我先看看测试" + tool_use: Run{filter:""}
    E->>T: ④ 执行 Run（只读类，Auto 直接放行）
    T-->>E: ⑤ tool_result：3 个失败用例
    Note over E: 结果追加进 messages，进入第 2 圈
    E->>M: ⑥ 请求（历史含失败输出）
    M-->>E: ⑦ tool_use: read_file src/parser.rs<br/>+ grep_files "parse_header"
    E->>T: ⑧ 两个只读工具并行执行
    T-->>E: ⑨ 文件内容 + 搜索结果
    Note over E: 进入第 3 圈
    E->>M: ⑩ 请求
    M-->>E: ⑪ tool_use: edit_file（修 bug）
    E->>U: ⑫ Event::ApprovalRequired<br/>（WritesFiles → Suggest → Ask 姿态弹窗）
    U->>E: ⑬ 用户按 y 批准
    E->>T: ⑭ 执行 edit_file → LSP 诊断收集
    Note over E: 第 4 圈：LSP 诊断注入（:527）
    E->>M: ⑮ 请求（历史含诊断：无编译错误）
    M-->>E: ⑯ tool_use: Run 重跑测试
    E->>T: ⑰ 测试全绿
    Note over E: 第 5 圈
    E->>M: ⑱ 请求
    M-->>E: ⑲ 纯文本总结，无 tool_use
    Note over E: 无插话/无子agent/无REPL → break（:1619）
    E-->>U: ⑳ Completed：输出修复总结 + token 用量
```

这个剧本里有三个初学者最容易误解的点值得强调：

1. **"思考"不发生在某一台中央服务器里**，而发生在这一圈又一圈的循环里。每一圈模型只能看到历史、说一段话（可能带工具调用），然后循环把工具结果喂回去。所谓 agent，本质上是"循环 + 工具 + 历史"这三样东西的组合拳。
2. **工具不是模型执行的，是宿主程序执行的**。模型只是输出一段"我想调用 `edit_file`，参数是……"的结构化文本；真正读文件、跑命令的是 Rust 侧的引擎。这就是为什么权限闸能卡在引擎里——模型连"偷偷执行"的机会都没有。
3. **每圈都可能被打断或续命**。用户在第 ④ 步按 Esc，第 2 圈顶部就返回 Interrupted；用户在第 ⑯ 步时输入"顺便加个测试"，这条 steer 会在下一圈开头被追加进历史。循环不是黑箱，是一个随时有人进出的事务所。

---

## 第 7 章 工具系统与权限：模型的"手"和手上的"镣铐"

### 7.1 工具的定义：ToolSpec trait

所有工具实现同一个 trait（`crates/tui/src/tools/spec.rs:1167`）：

```rust
pub trait ToolSpec: Send + Sync {
    fn name(&self) -> &str;                        // API 里用的名字
    fn description(&self) -> &str;                 // 给模型看的说明
    fn input_schema(&self) -> Value;               // 参数的 JSON Schema
    fn capabilities(&self) -> Vec<ToolCapability>; // 能力标记

    fn approval_requirement(&self) -> ApprovalRequirement {
        let caps = self.capabilities();
        if caps.contains(&ToolCapability::ExecutesCode) {
            ApprovalRequirement::Required   // 会执行代码 → 必须审批
        } else if caps.contains(&ToolCapability::WritesFiles) {
            ApprovalRequirement::Suggest    // 会写文件 → 建议审批
        } else {
            ApprovalRequirement::Auto       // 只读 → 自动放行
        }
    }   // spec.rs:1181-1190
    ...
}
```

注意这个默认实现（1181-1190 行）的巧思：**审批级别不是拍脑袋配置的，而是从能力标记推导的**。一个工具只要如实声明 `ExecutesCode`（会跑代码），它就自动落入最严格的审批档——这就是 README 说的 "safe by construction" 在类型层面的体现。

### 7.2 全部内置工具一览

工具注册采用 builder 模式：`ToolRegistryBuilder` 上一串 `with_*` 方法各注册一族（`crates/tui/src/tools/registry.rs`）。Agent 模式的完整工具面由 `with_agent_runtime_surface()`（`registry.rs:1226`）组装，它内部再调 `with_agent_tools_policy()`（`:1194`）。Plan 模式则走只读面（`tool_setup.rs:60-79`）。下表按族列出**模型可见的正名 + 隐藏兼容别名**（别名用于旧会话回放，见 `:682-683` 注释）：

| 分组 | 工具名（正名 / 别名） | 功能 | 关键参数 | 例子 |
|---|---|---|---|---|
| **文件** | `File`（`read_file`/`write_file`/`edit_file`/`list_dir`） | 读写改列文件，action 分发 | `action`, `path`, `content` | `read_file src/main.rs` |
| | `apply_patch` | 应用 unified diff 补丁（feature 开关） | `patch` | 多文件一次改 |
| | `fim_edit` | 用补全模型做"中间填充"式小编辑 | `path`, `prompt` | 改一个函数体 |
| **Shell** | `Bash`（`exec_shell`/`exec_shell_wait`/`exec_shell_interact`/`exec_shell_cancel`） | 跑 shell 命令，支持后台与交互 | `command`, `timeout` | `cargo test` |
| | `terminal_run`/`terminal_send`/`terminal_wait`/`terminal_cancel`/`terminal_reset` | 有状态的 PTY 终端会话 | `command`, `session` | 起 dev server 后交互 |
| **搜索** | `grep_files`、`file_search` | 内容搜索 / 按文件名搜索 | `pattern`, `path` | 找所有 `TODO` |
| **Git** | `Git`（`git_status`/`git_diff`/`git_log`/`git_show`/`git_blame`） | 仓库状态、diff、历史、溯源 | `path` | 看未提交改动 |
| **诊断/项目** | `diagnostics` | LSP 诊断汇总（编译错误） | — | 改完代码自查 |
| | `project_map` | 项目结构地图 | — | 初识陌生仓库 |
| **测试/校验** | `Run`（`run_tests`/`run_verifiers`） | 跑测试与验收脚本 | `filter` | 跑单测并汇总失败 |
| | `validate_data` | 结构化数据校验 | `data`, `schema` | 校验 JSON |
| **Web** | `Web`（`web_search`/`fetch_url`/`wait_for_dev_server`）、`web.run` | 搜索、抓网页、等本地服务就绪 | `query`, `url` | 查文档、抓 issue |
| **任务/协作** | `tasks`（`task_create`/`task_list`/…） | 持久任务、门禁、PR 尝试记录 | `action` | 建一个可恢复任务 |
| | `github`（`github_issue_context`/`github_pr_context`/`github_comment`/`github_close_*`） | 读 issue/PR、评论、关闭 | `action`, `number` | 看 PR 上下文 |
| | `automation`（`automation_create`/…） | 定时/触发式自动化 | `action` | 每晚跑检查 |
| **进度管理** | `work_update`（`todo_*`/`checklist_*` 别名） | 维护待办清单（唯一进度面，issue #4132） | `items` | 拆解任务打勾 |
| | `update_plan` | Plan 模式下写/改计划 | `plan` | 输出实施计划 |
| | `create_goal`/`get_goal`/`update_goal` | 带 token 预算的长期目标 | `objective` | 限时重构 |
| **子 agent** | `agent` | 启动/查询/等待/取消子 agent | `action`, `prompt`, `worktree`… | 第 9 章 |
| | `agents/list`/`agents/message`/`agents/followup`/`agents/interrupt`/`agents/wait`/`agents/coordinate` | 多 agent 协调 | `agent_id` | 给子 agent 发消息 |
| **审查/记忆** | `review` | 让另一个模型审 diff | — | 自审查 |
| | `remember` | 往用户记忆文件加一条（需用户开启记忆功能才注册） | `text` | 记住用户偏好 |
| | `slop_ledger_append`/`query`/`update`/`export` | 记录"技术债残渣"台账 | `entry` | 标记遗留问题 |
| **大输出治理** | `retrieve_tool_result` | 取回被落盘的历史工具输出 | `id` | 回看超长日志 |
| | `handle_read` | 读取 `var_handle` 符号引用的内容 | `handle` | 省 token 的大对象 |
| **杂项** | `note` | 会话内随手记 | `text` | 中间结论 |
| | `request_user_input` | 模型反向问用户（弹窗） | `questions` | 缺少参数时 |
| | `revert_turn` | 撤销上一回合的工作区改动（快照） | — | "撤销刚才的修改" |
| | `load_skill` | 按需加载 SKILL.md 全文 | `name` | 第 10 章 |
| | `finance` | 金融行情数据 | `symbol` | 查股价 |
| | `pandoc_convert` | 文档格式转换（**检测到 pandoc 才注册**） | `input`, `format` | md → docx |
| | `image_ocr` | 图片 OCR（**检测到后端才注册**） | `path` | 读截图文字 |
| | `image_analyze` | 视觉模型分析图片（**配了 `[vision_model]` 才注册**） | `path` | 看 UI 截图 |
| | `speech`/`tts` | 语音合成 | `text` | 读出结果 |
| | `notify` | 桌面/终端通知（尊重用户通知配置） | `message` | 长任务完成提醒 |
| | `rlm`（`rlm_open`/`rlm_eval`/…） | 持久 RLM 会话 | `action` | 超长上下文研究 |
| | `workflow` | 声明式/JS 工作流编排 | `script` | 第 9、10 章 |
| | `start_mcp_server` | 运行中动态拉起 MCP server | `name` | 第 10 章 |

两个值得注意的细节：

1. **探测式注册**：`pandoc_convert`、`image_ocr` 这类依赖外部二进制的工具，先探测本机有没有，没有就**根本不注册**（`registry.rs:754-767` 注释），模型永远不会看到一个用不了的工具——从源头消灭幻觉式调用。
2. **模式决定工具面**：Plan 模式注册的是只读子集（`tool_setup.rs:60-79`），写工具名干脆不在目录里，"只读"不是靠提示词求模型别写，而是模型根本没有写的工具。这是 "Plan mode is read-only" 的机制实现。

再看一个表格读不出的设计：**为什么文件读写是一个 `File` 工具而不是四个独立工具？** 从注册代码（`registry.rs:660-665`）能看到，`File` 是带 `action` 参数的正名，`read_file`/`write_file`/`edit_file`/`list_dir` 只是挂在同一个实现上的兼容别名。这样做有两个现实收益：其一，工具目录变短，模型每回合要读的 schema 变少（省 token，也减少选错工具的机会）；其二，旧会话转录里的老名字仍能回放执行（`:682-683` 注释说明别名是 hidden 的）。`tasks`、`github`、`automation`、`rlm` 也都采用了同样的"一个正名 + action 参数 + 隐藏别名"模式——这是 v0.9 时代全库统一的工具面收敛（issue #4625）。

**一个审批的完整实例**：假设在默认 Ask 姿态下，模型决定执行 `Bash{command: "rm -rf target/"}`。① `Bash` 声明了 `ExecutesCode` 能力 → `approval_requirement()` 返回 `Required`（`spec.rs:1183-1184`）；② `resolve_tool_permission` 查真值表：Ask 姿态 + Required → `Prompt`；③ 引擎发 `Event::ApprovalRequired`，附带命令文本、意图摘要和两枚指纹；④ 用户看到弹窗，选"允许并记住这类命令" → 引擎把 `approval_grouping_key` 记入会话允许缓存；⑤ 后续 `rm -rf build/` 这类同形命令直接放行，但一次精确的"拒绝"只绑定精确参数（`approval_key`），不会被泛化。如果这条命令命中了 execpolicy 的拒绝前缀（比如 `rm -rf /`），它在第 ① 步之前就被策略层拦死，根本走不到审批。

### 7.3 权限审批：一张真值表 + 一次握手

审批决策全部收敛到一个函数：`resolve_tool_permission()`（`crates/tui/src/core/authority.rs:289`）。它吃三个输入：本回合权威 `TurnAuthority`（模式、auto_approve、审批姿态）、工具的 `ApprovalRequirement`、该工具是否在"不可绕过名单"上，输出三选一：`Allow` / `Prompt` / `Deny`。

```mermaid
flowchart TD
    A[工具调用] --> B{requirement == Auto?<br/>（纯只读工具）}
    B -- 是 --> AL[Allow：直接执行<br/>Never 姿态下也只读不死]
    B -- 否 --> C{姿态 == Never 且非全权?}
    C -- 是 --> D[Deny：不弹窗直接拒]
    C -- 否 --> E{在不可绕过名单?<br/>is_non_bypassable}
    E -- 是 --> P[Prompt：必须弹窗<br/>Full Access 也照弹]
    E -- 否 --> F{auto_approve / Bypass / Yolo?}
    F -- 是 --> AL
    F -- 否 --> P
```

（对应 `authority.rs:294-316`）这套真值表有单元测试逐格验证（`authority.rs:380-468`），比如"Never 姿态保持只读而不是瘫痪"、"Full Access 下普通工具自动批、不可绕过工具仍弹窗"。

需要 `Prompt` 时，引擎与 UI 的握手是这样的（`crates/tui/src/core/engine/approval.rs:71`）：

```mermaid
sequenceDiagram
    participant E as 引擎（await_tool_approval）
    participant EV as Event 通道
    participant U as TUI 审批弹窗
    participant RX as rx_approval 通道
    E->>EV: Event::ApprovalRequired{工具名, 参数,<br/>approval_key, 意图摘要}<br/>（events.rs:290）
    EV->>U: 渲染审批框（含 diff 预览）
    U->>RX: ApprovalDecision::Approved/Denied/RetryWithPolicy
    RX->>E: 返回审批结果
    Note over E,RX: 全程挂在一个 select! 上：<br/>取消令牌触发 → ToolError::cancelled（approval.rs:77-82）
```

三个设计细节值得品味：

1. **审批键与分组键**（`events.rs:290` 的 `approval_key` / `approval_grouping_key`）：用户对 `cargo test` 点过"本次会话都允许"，引擎用**精确参数指纹**记录拒绝（`#1617`）、用**有损/元数感知指纹**记录允许（v0.8.37）——允许可以宽松覆盖同类命令，拒绝必须精确到这一次参数，防止"拒绝一次 `rm -rf /` 被泛化成拒绝所有 rm"。
2. **RetryWithPolicy**：用户可以不只说"行/不行"，还可以说"用更高的沙箱策略重试"（`approval.rs:27-31`）。
3. **输入来源降级**（`authority.rs:161-180` + `ops.rs:53` 的 `UserInputProvenance`）：如果一条"用户消息"实际来自子 agent 交接、记忆召回或导入的转录，它**不能继承**"本次会话一直自动批准"的长期授权——只有真人敲键盘（`ExternalUser`）才能。这堵住了"恶意子 agent 伪造用户指令来蹭全权"的提权通道。

### 7.4 Shell 执行的安全设计：四层防线

```mermaid
flowchart TD
    CMD[模型请求跑 shell] --> L1{第 1 层：execpolicy 前缀策略<br/>crates/execpolicy<br/>trusted/denied 前缀 × 三层优先级}
    L1 --> L2{第 2 层：审批真值表<br/>authority.rs:289}
    L2 --> L3{第 3 层：仓库宪法写保护<br/>repo_law.rs}
    L3 --> L4{第 4 层：OS 沙箱包裹<br/>sandbox/}
    L4 --> RUN[真正执行]
```

- **execpolicy**（`crates/execpolicy/src/lib.rs`）：允许/拒绝的**命令前缀**规则集，分 `BuiltinDefault < Agent < User` 三个优先级层（`:13`），冲突时"高层 + 最长前缀"获胜；还带 `bash_arity.rs` 做参数个数感知。
- **仓库宪法**（`crates/tui/src/repo_law.rs`）：仓库里的 `.codewhale/constitution.json` 中带 `paths` glob 的条目会被**编译成写保护**。规则非常硬气：法律只能"加锁"不能"放权"（schema 里根本没有 allow 形状）；`ask` 在审批姿态下强制弹窗、在 Full Access 下**直接失败关闭**（因为全权不弹窗）；`block` 在任何姿态下都直接拒绝；任何解析失败都退化为"规则变少"而不是"毒化闸门"（`repo_law.rs:9-20` 注释）。被保护的写工具名单是显式的（`:30` 的 `WRITE_TOOLS = ["write_file", "edit_file", "apply_patch", "fim_edit"]`）——注释里坦言 `fim_edit` 曾经就是个漏网之鱼。
- **OS 沙箱**（`crates/tui/src/sandbox/`）：macOS 用 Seatbelt（`sandbox-exec`）且**探测成功才声明**；Linux 是用户显式开启且 `/usr/bin/bwrap` 存在时才用 bubblewrap；Windows 目前如实报告"没有"。模式映射在 `authority.rs:235`：Plan → `ReadOnly`，Agent → `WorkspaceWrite{可写根=workspace, 允许网络}`，Yolo → `DangerFullAccess`。README 里"未知价格显示为未知、不显示为 $0"的同款诚实原则在这里是"没真正包裹就不宣称有沙箱"。

---

## 第 8 章 上下文压缩与记忆：会话变长之后

### 8.1 触发：token 单信号

压缩配置与触发逻辑在 `crates/tui/src/compaction.rs`。`CompactionConfig` 默认（`:38-63`）：开启、阈值 `token_threshold: 800_000`（注释解释：这是 V4 模型 1M 窗口的 80%，且刻意晚于模型侧 60% 的"建议手动 /compact"指引，让自动压缩只做"续命兜底"）。

`should_compact()`（`:690-736`）的判定自 v0.8.11 起是**纯 token 信号**：

```rust
// compaction.rs:721-735 节选
let effective_token_threshold = config.token_threshold.saturating_sub(pinned_tokens);
// Token-only trigger (v0.8.11): the prior message-count branch was a
// 128K-era heuristic ... Token budget is the only signal that maps to
// actual model context pressure.
if message_count < MIN_SUMMARIZE_MESSAGES { return false; }  // 至少 6 条
token_estimate > effective_token_threshold
```

以前还有个"消息条数"触发分支，被删掉了，注释说得很明白：消息条数是 128K 窗口时代的启发式，对"长但每条很短"的会话会误触发压缩——而压缩会重写请求前缀，恰恰在那种场景下最贵。token 估算本身是粗算（约 4 字符 ≈ 1 token，`estimate_tokens`，`:644-652`），图片固定按 1000 token 估。

### 8.2 流程：先机械瘦身，再花钱买摘要

```mermaid
flowchart TD
    A[主循环每圈开头调 should_compact<br/>turn_loop.rs:410] --> B{超过阈值?}
    B -- 否 --> Z[继续正常推理]
    B -- 是 --> C["plan_compaction：分区<br/>保留最近 4 条 KEEP_RECENT_MESSAGES<br/>pin 住工作集路径相关消息"]
    C --> D["① 机械修剪 prune_tool_results<br/>重复的旧工具输出 → 一行摘要<br/>不花一分钱 token"]
    D --> E{修剪后低于阈值?}
    E -- 是 --> F[完事：零成本]
    E -- 否 --> G["② LLM 摘要 compact_messages_safe<br/>头 14000 + 尾 6000 字符喂给模型<br/>生成结构化摘要"]
    G --> H{结果为空?}
    H -- 是 --> I["放弃：宁可不压缩也不破坏状态<br/>turn_loop.rs:480-485"]
    H -- 否 --> J["replace_messages + 注入摘要<br/>最近 4 条原文原样保留"]
```

几个关键数字（`compaction.rs:65-84`）：最近 4 条消息永远原样保留（`KEEP_RECENT_MESSAGES`）；摘要输入截成头 14000 + 尾 6000 字符；保留的工具结果单条上限 64 KB、thinking 上限 16 KB；大上下文（50 万 token 窗口以上）时改用更大号的摘要限额表。

**一个算账的例子**：假设某会话有 60 条消息，待摘要区估算 85 万 token，pin 区（工作集路径相关消息）估算 5 万 token，阈值是默认的 80 万。有效阈值 = 80 万 − 5 万 = 75 万（`compaction.rs:722` 的 `saturating_sub`），85 万 > 75 万 → 触发压缩。第一步机械修剪把 20 条重复的旧工具输出（比如同一个长日志被 `cat` 了三次）压成一行摘要，省出 30 万；重新估算只剩 55 万 < 75 万，于是**根本不调 LLM 摘要**，零成本完成。只有当机械修剪后仍超标时，才把头 14000 字符 + 尾 6000 字符的上下文喂给模型写摘要。这个"先免费后付费"的两段式设计，把压缩从"每次都很贵"变成"大多数时候不花钱"。

压缩的执行点在主循环里（`turn_loop.rs:410-487`），失败路径的注释很能体现这个项目的风控哲学："Only update if we got valid messages (**never corrupt state**)"（`:442`）——压缩失败就继续用原始历史，宁可撑爆也不搞坏。

### 8.3 记忆体系：四条时间线

CodeWhale 的"记忆"其实有四种，时间跨度各不相同：

```mermaid
flowchart LR
    subgraph 会话内
        M1[working_set<br/>最近摸过的文件路径]
        M2[note 工具随手记]
    end
    subgraph 跨会话["跨会话（本仓库）"]
        M3[".codewhale/handoff.md<br/>压缩/退出时的交接信"]
        M4["AGENTS.md / CLAUDE.md<br/>项目指令"]
        M5[".codewhale/constitution.json<br/>仓库宪法"]
    end
    subgraph 跨项目["跨项目（用户级）"]
        M6["~/.codewhale/memory.md<br/>用户记忆（opt-in，正被 Moraine MCP 取代）"]
        M7["~/.codewhale/AGENTS.md<br/>全局指令"]
    end
```

- **handoff 交接**：`/compact` 或退出时，模型按 `COMPACT_TEMPLATE` 的格式写 `.codewhale/handoff.md`，下次启动作为"token-budget / continuity"片段注入（`prompts.rs:1293` 的 `load_handoff_block`）。
- **用户记忆**：`memory.rs` 的 legacy 机制（`~/.codewhale/memory.md` + `remember` 工具 + `#` 快捷记录）已被标记 **DEPRECATED**，正迁移到 Moraine MCP 召回（`memory.rs:1-20` 的弃用说明）——文档里如实记录这一点：这个子系统处于过渡态。
- **工作集（working set）**：引擎持续观察读写过哪些文件，压缩时把 top 24 个路径（`turn_loop.rs:408` 的 `top_paths(24)`）作为"别压掉这些"的 pin 信号。

**设计取舍**：对比 Claude Code 的 `/compact`（一次性 LLM 摘要），CodeWhale 多了两级心思：先做零成本的机械去重，再付费摘要；pin 机制让"正在改的文件"上下文不被压掉。这依然是前缀缓存经济学的延伸——压缩本身是最伤缓存的操作，所以要尽量少做、晚做、做之前先免费瘦身。

---

## 第 9 章 子 agent 与多 agent：agent 生 agent

### 9.1 模型可见的 `agent` 工具

CodeWhale 的子 agent 不是独立的 slash 命令，而是模型手里的一个普通工具 `agent`（`crates/tui/src/tools/subagent/mod.rs:6249`）。动作枚举（`:6229-6238`）：

```rust
match action.trim().to_ascii_lowercase().as_str() {
    "" | "start" | "spawn" | "run" => Ok(AgentToolAction::Start),
    "status" | "list" | "inspect" => Ok(AgentToolAction::Status),
    "peek" | "progress" => Ok(AgentToolAction::Peek),
    "wait" | "join" | "await" | "block" => Ok(AgentToolAction::Wait),
    "cancel" | "stop" | "abort" => Ok(AgentToolAction::Cancel),
    ...
}
```

`start` 的参数表（`:6264-6360` 的 JSON Schema）非常丰富，挑几个最能体现设计思想的：

- **`prompt`**：给子 agent 的聚焦任务；
- **`type` / `profile`**：角色（explore/plan/review/verifier…）或 Fleet 花名册成员，自带角色姿态、模型路由与指令叠加；
- **`model_strength: same|faster`** / `model`：子 agent 可以换更快（更便宜）的模型跑低风险任务；
- **`thinking`**：思考档位，独立于父 agent；
- **`worktree: true`**：为子 agent 开一个独立的 git worktree + 新分支，并行改代码互不踩踏；
- **`fork_context`**：只读子 agent 且同模型路由时，**fork 父 agent 的缓存前缀**——又是一个省缓存钱的设计；
- **`max_depth` / `max_steps`**：子 agent 还能再生孙 agent，但深度有硬顶。

### 9.2 深度、并发与防失控

```mermaid
flowchart TD
    P[父 agent] -->|agent start| C1[子 agent A<br/>explore · faster 模型]
    P -->|agent start worktree=true| C2[子 agent B<br/>builder · 独立 worktree]
    C1 -->|agent start| G[孙 agent<br/>max_depth 递减]
    subgraph 护栏
        L1["深度：DEFAULT_MAX_SPAWN_DEPTH<br/>subagent/mod.rs:1885<br/>+ 绝对天花板 MAX_SPAWN_DEPTH_CEILING"]
        L2["数量：DEFAULT_MAX_SUBAGENTS = 64<br/>config/subagent_limits.rs:13"]
        L3["权限：子 agent 不继承父的<br/>'会话级自动批准'（authority.rs:161）"]
    end
```

深度的计算有个微妙的 bug 史，留在 `clamp_child_max_spawn_depth()`（`:1896`）的注释里：如果只做 `spawn_depth > max_depth` 的判断，模型每次生成都重新报一个 `max_depth >= 1`，深度环就永远锁不死——所以现在是把父的剩余深度和模型请求值**相加后再钳制到全局天花板**。

### 9.3 父子通信：哨兵消息与不屏障原则

子 agent 跑完后，结果怎么回到父 agent 的对话里？答案是一条**哨兵消息** `<codewhale:subagent.done>`。主循环每圈开头调 `drain_subagent_completion_events()`（`turn_loop.rs:238-284`）收割完成事件、作为 runtime 消息追加进父会话；模型流式结束但没有工具调用时，也会先检查有没有待收割的子 agent 完成事件（`turn_loop.rs:1423`）再决定要不要结束回合。

最重要的设计决策写在 `turn_loop.rs:1425-1435` 的注释里（issue #3216）：

> do NOT barrier the parent on running children. Launching a sub-agent is not the same as joining it — the parent ends its turn and stays responsive.

父 agent **绝不**因为子 agent 还在跑就干等——回合照常结束，用户可以继续对话，子 agent 完事后通过哨兵在后续回合报到。（老版本在这里 `select!` 等待，用户体验就是"TUI 死机"。）同时引擎会注入一条"等待提示"，防止模型用 `peek`/`status`/`sleep` 轮询空转（issue #4097）。协调类工具（`agents/list`、`agents/message`、`agents/followup`、`agents/interrupt`、`agents/wait`、`agents/coordinate`，注册于 `coord.rs:682`）让父 agent 显式地给子 agent 留言、催办、打断，而不是轮询。

### 9.4 Fleet：从"生一个"到"带一队"

子 agent 之上还有一层 **Fleet**（`crates/tui/src/fleet/`）：一个本地优先的多 worker 控制面，含花名册（roster 定义 reviewer/scout/builder 等角色）、调度器、告警和**只增不改的 JSONL 台账**（`fleet/ledger.rs`：每一步 append 一条记录，管理器崩溃后靠重放记录重建状态，大产物只存磁盘引用不入台账）。`fleet resume` 能从中断点续跑，对应 README 的 "Work that survives"。

**一个典型的多 agent 场景**：用户说"把这个仓库的测试覆盖率补到 80%"。主 agent 的合理打法是——① 起一个 `explore` 类型的只读子 agent（`model_strength: "faster"`，用便宜模型）摸清哪些模块缺测试；② 同时起两个 `builder` 子 agent，各自 `worktree: true` 开独立分支并行写测试，互不踩踏；③ 自己继续干别的，回合正常结束；④ 两个 builder 先后完成，哨兵消息在后续回合汇入，主 agent 用 `review` 工具过一遍 diff，再合并。整个过程中用户随时可以和主 agent 对话——这就是"不屏障父循环"换来的体验。如果任务更大，比如"按这份清单迁移 40 个包"，就该交给 Fleet：40 个任务进 JSONL 台账排队，roster 里的 worker 角色逐个领取，机器重启后 `fleet resume` 从台账断点继续，每一步都有可审计的收据。

再往上是 **Workflow**（`crates/workflow` + `crates/workflow-js`）：模型可以写一段 JavaScript，在 **QuickJS 沙箱**（rquickjs）里执行，用 `await task(opts)` 派发子 agent、`parallel()` 扇出、`pipeline()` 串流水线，还有 token 预算全局变量（`crates/workflow-js/src/lib.rs` 的文档注释）。声明式 IR 留在 Rust 侧，命令式执行走 JS 沙箱，两者通过 `WorkflowDriver` 接缝通信、可用假驱动测试。

```mermaid
flowchart TD
    U[用户] --> W{任务规模}
    W -->|单点| A[主 agent 自己干]
    W -->|并行几件事| B[agent 工具<br/>生几个子 agent]
    W -->|带角色的团队| C[Fleet<br/>花名册 + JSONL 台账 + resume]
    W -->|复杂编排逻辑| D[Workflow<br/>QuickJS 脚本：task/parallel/pipeline]
    B --> C
    C --> D
```

---

## 第 10 章 生态：命令、扩展、MCP 与插件

### 10.1 斜杠命令宇宙

斜杠命令按组组织在 `crates/tui/src/commands/groups/`（core / session / config / memory / plugins / project / skills / utility / debug）。把各文件里的 `CommandInfo` 汇总，内置命令超过 85 个，按用途分类（括注代表性命令）：

```mermaid
mindmap
  root((斜杠命令))
    会话
      /new /clear /save /load /fork
      /resume /sessions /rename /export
      /compact /relay /purge
    模型与路由
      /model /models /modeldb /provider
      /auth /logout /balance /cost /tokens /cache
    模式与权限
      /mode /trust /review
      /undo /restore /retry /edit
    多agent
      /agent /subagents /fleet /workflow /queue /task /jobs
    项目与环境
      /init /workspace /doctor /setup /lsp /hooks /network
    个性化
      /theme /sidebar /hotbar /statusline /voice /translate /stash
    扩展
      /skill /skills /mcp /plugin /hooks
    信息与调试
      /help /status /context /diff /transcript /feedback /verbose /system /goal /anchor /attach /note /memory /constitution /debt /links /hf /home /exit
```

几个有故事命令： `/debt`（别名 `/cleanup`、`/slop`、`/canzha`）管理技术债台账——"canzha"是"残渣"拼音，暴露了中国社区的参与痕迹；`/relay`（别名"接力"）处理跨会话交接；`/hf` 是 HuggingFace 快捷方式。很多命令有中文别名（`jihua`=计划、`zidong`=自动，`commands/mod.rs:157-167`）。用户还可以在 `.codewhale/commands/` 放 Markdown 文件**自定义命令**（支持 frontmatter 限定工具集），这是社区贡献最多的扩展点。

**自定义命令长什么样？** 一个 `.codewhale/commands/review-pr.md` 文件，正文是提示词模板（`$ARGUMENTS` 占位符会被命令参数替换），frontmatter 里可以写 `allowed_tools` 限定本次会话只能调用哪些工具。于是 `/review-pr 123` 就等于"以只读工具面 + 一段预制提示词"发起一个回合——把常用的复杂提示词固化成团队共享的命令，存在仓库里随代码一起走。这和 Claude Code 的自定义 slash command 是同一玩法，进一步印证两个生态的概念互通。

### 10.2 技能（Skills）：兼容别家目录的开放策略

技能发现扫描一长串目录（`crates/tui/src/skills/roots.rs:153-251`）：`.codewhale/skills`、`.agents/skills`、`.claude/skills`、`.cursor/skills`、`.opencode/skills`、`.codex/skills`，还有扁平的 `workspace/skills`。也就是说，**你为 Claude Code / Cursor / Codex 写的技能，CodeWhale 直接能用**——这是刻意为之的兼容策略（`prompts.rs:1218-1224` 注释）。系统提示词里只放技能目录，模型用 `load_skill` 工具按需加载全文，省 token。

### 10.3 MCP：全传输支持 + 运行时热添加

MCP 实现在 `crates/tui/src/mcp.rs`，支持三种传输：`stdio`（本地子进程）、`sse`、`streamable_http`，带连接池、OAuth、按 server 超时。外部 MCP server 的工具通过 `McpToolAdapter` 包成普通 `ToolSpec` 注册（`registry.rs:1150-1157`），对模型来说和内置工具无差别。更特别的是 `start_mcp_server` 工具（`runtime_mcp.rs`）：**模型自己**可以在对话中根据上下文拉起一个新的 MCP server——`tool_setup.rs:49-56` 的注释专门记录了把它对齐到所有可执行模式的修复。

### 10.4 Hooks 与插件

Hooks（`crates/tui/src/hooks.rs:51`）在 11 个生命周期点执行用户配置的 shell 命令：`SessionStart/End`、`MessageSubmit`（可改写或拦截用户消息，`ui.rs:8678-8702`）、`ToolCallBefore/After`、`ModeChange`、`OnError`、`TurnEnd`、`SubagentSpawn/Complete`、`ShellEnv`（给 shell 工具注入临时环境变量，比如短时凭证）。插件体系（`crates/tui/src/plugins/`）在启动早期做**只读发现**（`main.rs:1356-1372` 注释强调：发现 ≠ 启用 ≠ 信任 ≠ 执行），脚本工具经 `configure_plugin_tools` 注册（`engine.rs:3531-3533`），同名时显式配置覆盖自动发现。

**设计取舍**：CodeWhale 的扩展生态策略可以概括成"能兼容就不另立标准"：技能目录兼容六家、MCP 全传输、命令用 Markdown。这降低了用户迁移成本，也是社区项目对抗官方产品的现实打法。

---

## 第 11 章 功能特性：认证、模型与那些"独家本领"

### 11.1 认证：36 家 provider 的统一门面

Provider 的身份集中在 `crates/config/src/provider_kind.rs:13` 的 `ProviderKind` 枚举——整整 36 个变体，从 DeepSeek、Anthropic、OpenAI 到月之暗面（Moonshot）、智谱（Zai）、MiniMax、阶跃（Stepfun）、小米 MiMo，再到自托管的 `Vllm`/`Sglang`/`Ollama`（免 key）。每个变体带一串 serde 别名（比如 `zhipu`、`bigmodel` 都映射到 `Zai`），用户怎么写都能对上号。

认证路径三条：`codewhale auth set --provider X` 存 key 到 **OS 钥匙串**（`crates/secrets`，Linux 需要 `libdbus-1-dev`，见 `AGENTS.md:131-134`）；环境变量直传（`ANTHROPIC_API_KEY` 等）；以及 OAuth 设备码流程（`oauth.rs`、`xai_oauth.rs`）。所有模型调用走统一的 `LlmClient` trait（`llm_client/mod.rs:54`）：`create_message` + `create_message_stream`（返回 SSE 事件流）。HTTP 层还有一个安全闸：base URL 必须是 HTTPS，或本机回环地址，或显式设 `ALLOW_INSECURE_HTTP` 环境变量，否则拒绝启动（`client.rs:624-662`）。

**三个典型的接入场景**，对应三种用户：

```bash
# 场景 A：托管模型用户——一次 auth，之后直接进 TUI
codewhale auth set --provider deepseek
codewhale

# 场景 B：完全离线/内网用户——本机 vLLM，不需要任何 key
CODEWHALE_PROVIDER=vllm VLLM_BASE_URL=http://127.0.0.1:8000/v1 \
  VLLM_MODEL=qwen3-coder codewhale exec --auto "给这个仓库写个 README"
# （这条命令行就写在 AGENTS.md:150-153 的开发者文档里）

# 场景 C：局域网自建服务——HTTP 明文必须显式自证清白
ALLOW_INSECURE_HTTP=1 CODEWHALE_BASE_URL=http://192.168.1.10:8000/v1 codewhale
```

场景 C 的报错文案（`client.rs:647-656`）本身就是一份迷你教程：它告诉你回环地址自动放行、局域网地址要设哪个环境变量、连命令示例都给了。这种"错误信息即文档"的风格在全库一以贯之——第 3 章 `codewhale-tui` 缺失时的报错（`crates/cli/src/lib.rs:4397-4405`）同样手把手教你怎么排查。

### 11.2 模型选择与思考档位

`/model` 同时切换 provider 和模型；`--model auto` / `/model auto` 开启**自动路由**（`model_routing.rs`）：按任务内容和最近上下文选具体模型与思考档位，TUI 里能看路由回执。思考档位（reasoning effort）分 `off/low/medium/high/max`，`Ctrl+T` 循环切换（`docs/MODES.md`）；`auto` 档由 `resolve_auto_effort()` 按消息内容落到具体档（`turn_loop.rs:552-558`）。

### 11.3 特色功能速览

```mermaid
mindmap
  root((特色功能))
    省钱可见
      /cache 前缀缓存遥测
      三区前缀契约 #2264
      未知价格显示未知不显示$0
    安全可验
      Seatbelt/bwrap 沙箱
      constitution.json 写保护
      审批分组键/精确键
      输入来源降级
    可恢复
      每回合工作区快照 /restore
      fleet JSONL 台账 resume
      会话保存/分叉/fork
      handoff.md 交接
    界面之外
      codewhale exec 无头模式
      codewhale web 127.0.0.1:7878
      runtime API + ACP
      LSP 改后诊断注入
    好玩的
      REPL 代码块本地执行
      语音 TTS / 翻译 / 本地化界面
      vim 模式 / 主题 / 状态栏
```

挑三个展开：

- **`codewhale web`**：在本机 `127.0.0.1:7878` 起一个浏览器客户端。**只允许回环地址**、没有 `--host` 参数、浏览器启动 URL 带一次性短效 bootstrap 凭证且绝不携带长期 token（`docs/WEB.md`）——"本地服务"的安全边界被当成一等公民设计。
- **LSP 闭环**：`File` 写/改/补丁动作后触发 LSP 诊断，诊断作为合成 user 消息在下一推理步前注入（`turn_loop.rs:527`、`core/engine/lsp_hooks.rs`）——模型改完代码立刻"看到"编译错误，形成自我修复闭环。
- **快照与撤销**：每回合前后对工作区做一次快照（存进侧边 git 仓，`snapshot/`），`/restore N` 和 `revert_turn` 工具都能回滚（`core/turn.rs:6-14`）——"让 agent 放手改"的前提是你随时能一键反悔。

**未找到/不支持**（诚实清单）：Windows 没有 OS 级沙箱（如实报告 none）；`landlock`/`seccomp` 助手代码存在但未接入子进程执行路径（`sandbox/mod.rs:14-16`）；用户记忆 legacy 机制处于弃用过渡期；crate 拆分未完成，`crates/core`、`crates/agent` 等目前只是外围抽象，不是运行时真源。

---

## 第 12 章 总结：设计哲学与取舍

### 12.1 三条贯穿始终的设计哲学

```mermaid
flowchart TD
    P1["① 模型是零件，不是产品<br/>36 provider 一个运行时<br/>路由/价格/上下文按真实路由来"]
    P2["② 安全是机制，不是承诺<br/>只读=没有写工具<br/>沙箱=真包裹才宣称<br/>宪法=编译成写保护"]
    P3["③ 省钱是工程，不是运气<br/>提示词恒定/易变分层<br/>前缀冻结校验<br/>压缩前先免费瘦身"]
    P1 --- P2 --- P3
```

### 12.2 五个最独特的设计点

1. **前缀缓存是一等架构约束**。从提示词组装顺序（volatile-content-last）、`PrefixStabilityManager` 运行时遥测、`frozen_prefix` 三区契约，到"压缩宁可晚做少做"，整个项目围绕"让 KV 缓存命中更长"做设计——在同类开源 agent 里几乎看不到第二家把这个做到这种程度。
2. **"Safe by construction" 是类型和机制层面的**。审批级别从工具能力标记推导（`spec.rs:1181`）、Plan 模式不注册写工具、`constitution.json` 编译成连 Full Access 都绕不过的写保护、输入来源不继承长期授权——安全不靠提示词求模型乖，而是模型根本没有越权的工具。
3. **输入来源（provenance）降级**。`UserInputProvenance`（`ops.rs:53`）区分"真人敲的"、"子 agent 交接的"、"记忆召回的"、"导入转录的"，非真人来源一律不能继承会话级自动批准——这是对"间接提示注入提权"这一 agent 时代新威胁的源码级防御。
4. **子 agent 的不屏障父循环 + 哨兵完成协议**。父 agent 永不因子 agent 在跑而冻结，完成结果以 `<codewhale:subagent.done>` 哨兵在后续回合自然汇入（issue #3216）——用消息协议代替阻塞等待，TUI 永远响应。
5. **生态兼容的"不另立标准"策略**。技能扫描兼容 `.claude`/`.cursor`/`.codex` 等六家目录，`AGENTS.md`/`CLAUDE.md` 都认，自定义命令就是 Markdown——社区项目用兼容性换用户。

### 12.3 Rust 带来的优势与代价

**优势**（都能在源码里指认）：单二进制分发；tokio 撑起"流式 + 并行工具 + 子 agent + 插话"的并发盘；`Drop` 语义做硬取消（drop 掉 `FuturesUnordered` 即取消全部并行工具）；类型系统把权限决策收敛成可穷尽测试的真值表（`authority.rs` 的测试逐格验证）；崩溃可控（panic 钩子恢复终端 + 崩溃落盘）。

**代价**（同样能在源码里指认）：65 万行的体量、动辄几千行的单文件（`ui.rs` 16815 行、`subagent/mod.rs` 11833 行）、每个历史 bug 留下的 `#issue` 注释密度惊人；`crates/tui` 成了"名义上是界面、实际上是整个运行时"的巨人 crate，官方文档自己承认拆分"是进行时"；编译速度对新贡献者是门槛（AGENTS.md 里专门教怎么跑定向测试）。

### 12.4 给初学者的阅读路线

如果你想顺着这份分析读源码，推荐这个顺序：

1. `README.md` → `docs/ARCHITECTURE.md`（建立地图）；
2. `crates/cli/src/main.rs`（18 行，最小的起点）→ `crates/tui/src/main.rs:1292`（启动六步）；
3. `crates/tui/src/tui/ui.rs:6139`（回车分流）→ `ui.rs:8651`（消息派发）；
4. `crates/tui/src/prompts.rs:1137`（提示词组装）；
5. **心脏**：`crates/tui/src/core/engine/turn_loop.rs:286`（主循环）；
6. `crates/tui/src/tools/spec.rs:1167`（工具 trait）→ `registry.rs:1226`（工具面）→ `authority.rs:289`（权限真值表）；
7. 按兴趣深入：`compaction.rs`（压缩）、`tools/subagent/mod.rs`（子 agent）、`fleet/ledger.rs`（台账）、`repo_law.rs`（仓库宪法）。

CodeWhale 对初学者最大的价值，不在于它"又是一个 AI 编程工具"，而在于它把"生产级 agent 要处理的每一个脏问题"——断流、休眠、注入、越权、缓存、崩溃、回滚——都写在了代码和注释里。读它，相当于读一本带 issue 编号的错题本。

---

*全文完。引用行号基于 commit `88a158e`；项目迭代很快（CHANGELOG 显示近乎日更），阅读最新代码时行号可能漂移，但模块边界与函数名保持稳定。*
