Subject:
Hmbown/CodeWhale(local path参考项目/CodeWhale) Analysis baseline commit:88a158eaa2d6f1fa879bba1f1848a315b4dc6925(2026-07-22,fix(tui): focus v0.9.1 chrome on todos and agents (#4711)) Written: 2026-07-24 Code scale: a Rust workspace of 18 crates, about 635.rsfiles and 650 000 lines of Rust, plus a Next.js site (web/) Audience: a computer-science beginner. Every chapter follows the rhythm “first an analogy → then real source → line-by-line explanation → design trade-offs.” Everyfile:linecitation is from the commit above and can be read against the tree.
Chapter 1 · Project overview: what it is, and why it is written in Rust
1.1 One-sentence introduction
CodeWhale is an AI coding agent that runs in the terminal: you give it a model and a task; it reads your code, edits files, runs commands, checks results, and stops when the job is done or it needs you to decide. The README’s opening is very blunt (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.
Its three selling points are summarized in the README as (README.md:12-25):
- No lock-in: DeepSeek, Claude, GPT, Kimi, GLM and 30-plus other providers, plus the vLLM / SGLang / Ollama you host yourself (no API key), all walk the same runtime and tool set;
- Safe by construction: Plan mode is read-only, every dangerous call needs approval, Seatbelt sandbox on macOS, optional bubblewrap on Linux, and a
constitution.jsonin the repo is compiled into write-protection that even “full access” cannot bypass; - Work that survives: Fleet (a multi-worker team) records every step into an append-only ledger;
fleet resumecan continue from the last breakpoint.
1.2 Origin: from deepseek-tui to CodeWhale
The project was first called deepseek-tui, a small terminal tool that only supported DeepSeek models. Community users kept asking for more models, so the author demoted “the model” from “the product” to “a component” and rebuilt a general agent runtime. The README still keeps this sentence (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.
This history is visible everywhere in the code: the crash-log directory is still called ~/.deepseek/crashes/ (crates/tui/src/main.rs:1337), the environment-variable compatibility name is still DEEPSEEK_TUI_BIN (AGENTS.md:157), and the core client type is still called DeepSeekClient — the names are historical; the behavior is general.
1.3 Why would anyone write an agent CLI in Rust?
Mainstream AI coding agents (Claude Code, Aider, early OpenAI Codex CLI) are mostly TypeScript or Python. CodeWhale chose Rust; the source shows very concrete payoffs:
- Single-binary distribution:
npm install -g codewhaleactually installs two compiled native executables (codewhalethe dispatcher +codewhale-tuithe runtime); the user does not need Node or Python. - Memory safety + high concurrency: the agent’s main loop must simultaneously handle SSE streams, parallel tool calls, sub-agent completion events, and user mid-turn steering. Rust’s
tokioasync runtime and ownership model make “stream while running tools in parallel” harder to get wrong. - Performance details can be squeezed to the bottom: the project even swapped the global allocator for mimalloc (
crates/cli/src/main.rs:1-2); the release build turns on LTO and a single codegen unit (Cargo.toml:67-73). - Structural safety: permission and sandbox are expressed with Rust’s type system (
Resultforces error handling,matchforces covering every branch), which matches the “Safe by construction” selling point.
The cost is equally real: 650 000 lines of code are full of defensive comments and compatibility layers (every historical bug left a #issue comment); iteration speed is naturally slower than a scripting language.
1.4 Similarities and differences with Claude Code (one-sentence version)
Same: both are coding agents with a “terminal + system prompt + tool loop” architecture; both read AGENTS.md/CLAUDE.md as project memory; both have slash commands, Plan mode, sub-agents, MCP, hooks, skills — the concept maps almost one-to-one.
Different: Claude Code is Anthropic’s official closed-source product, locked to its own models, implemented in TypeScript; CodeWhale is an MIT open-source community project, model-agnostic (36 built-in provider enums at crates/config/src/provider_kind.rs:13), implemented in Rust, and turns “safety” into verifiable mechanisms (sandbox, repo constitution, approval protocol) rather than relying only on prompt constraints.
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
可验证的安全机制]
end
Chapter 2 · Panorama: 18 crates and “one giant”
2.1 Workspace structure
Open the root Cargo.toml; workspace members are obvious (Cargo.toml:1-22):
[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 沙箱执行器
]
But here is an “architectural fact on the ground” that is extremely important for the reader: crate splitting is in progress, not finished. The official architecture document says it in the first breath (opening of docs/ARCHITECTURE.md):
crates/tuiis 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.
So although the name is tui, this crate actually holds: the agent main loop (core/engine/), all 60+ built-in tools (tools/), the LLM client (client.rs), MCP (mcp.rs), sandbox (sandbox/), the Fleet multi-agent control plane (fleet/), the sub-agent system (tools/subagent/), compaction (compaction.rs), prompts (prompts.rs)… It is more like “the whole building”; the doorplate just says TUI. crates/tui/src/core/engine.rs alone is 5133 lines; core/engine/tests.rs has another 11934 lines of tests.
2.2 Layer diagram
graph TD
subgraph 用户界面层
UI1["codewhale(cli crate)
调度器:找兄弟二进制、转发子命令"]
UI2["codewhale-tui(tui crate)
ratatui 交互界面"]
UI3["codewhale exec
无头模式(脚本/CI)"]
UI4["codewhale web / serve
127.0.0.1 浏览器客户端 + runtime API"]
end
subgraph 引擎层["引擎层(都在 crates/tui/src/core/)"]
EN1["engine.rs
Engine 结构体 + Op 处理"]
EN2["engine/turn_loop.rs
★ Agent 主循环(第 6 章)"]
EN3["authority.rs
权限真值表"]
EN4["events.rs / ops.rs
引擎⇄界面的事件与操作"]
end
subgraph 工具与扩展层["工具与扩展层(crates/tui/src/tools/ 等)"]
T1["60+ 内置工具
file / Bash / git / web / agent…"]
T2["skills / hooks / plugins"]
T3["MCP 客户端(stdio/SSE/HTTP)"]
T4["sandbox:Seatbelt / bubblewrap"]
end
subgraph 模型层
M1["client.rs · DeepSeekClient
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 Three keys to reading this architecture
- First remember one sentence per crate of the 18:
cliis the dispatcher;tuiis the runtime proper;configowns the config schema and the identity of 36 providers;protocolholds cross-crate protocol types (fleet records, runtime messages);toolsholds tool result types (ToolError,ApprovalRequirement— the “vocabulary”);execpolicyis the shell-prefix policy engine;secretsowns the OS keychain;stateowns session persistence;workflow/workflow-jsare the workflow’s declarative IR and QuickJS executor;agent/core/hooks/mcp/lane/app-server/build-support/releaseare peripheral organs being split off thetuigiant. Remember “tui is the giant, the rest are organs in the middle of a split,” and you will not get lost reading the code. - Dual-binary design.
codewhale(crates/cli) is only a “dispatcher”: it handles subcommands such asauth,doctor,updatethat do not need the full runtime; when it actually has to work it looks in the same directory forcodewhale-tuiand forwards the arguments (locate_sibling_tui_binary()atcrates/cli/src/lib.rs:4215). AGENTS.md specifically reminds developers that these two files must live in the same directory (AGENTS.md:155-157). - Between UI and engine is a “message pipe,” not function calls. The UI sends operations to the engine through the
Openum (crates/tui/src/core/ops.rs:87); the engine pushes events to the UI through theEventenum (core/events.rs). This explains why the TUI does not freeze during streaming, and why the same engine can serve TUI,execheadless mode, and the web client at once. - Everything is designed around “prefix cache.” You will see the phrase prefix cache again and again later: providers such as DeepSeek give a KV-cache discount for “the prefix of this request that is byte-identical to last time,” so prompt-assembly order, tool-catalog stability, and compaction timing are all designed to “touch the request prefix as little as possible.” This is a very distinctive dark thread of the project; Chapters 5, 6, and 8 will all meet it.
Chapter 3 · Boot: from main() to the first frame
3.1 Two mains, each in its place
First stop: the dispatcher (crates/cli/src/main.rs, only 18 lines in full):
#[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()
}
Small as this code is, it has two typical “Rust CLI survival tricks”: swap in mimalloc to improve performance; reset SIGPIPE to default behavior — otherwise a common operation such as codewhale doctor | head (“the pipe downstream exits first”) would make Rust’s println! panic when writing a broken pipe (the comment cites issue #4030). This is the daily life of writing a CLI in a systems language: you even have to care about how you yourself get killed by head.
After run_cli() parses subcommands, for commands that need the full runtime it locates and execs the sibling binary codewhale-tui (near crates/cli/src/lib.rs:4215).
Second stop: the runtime (crates/tui/src/main.rs:1292) main() does six big things, in a very particular order:
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)
必须在 tokio 和任何线程启动前
M->>H: ④ 安装 panic 钩子(1314-1348)
M->>P: ⑤ clap 解析 + 插件发现(1356-1372)
必须在读 .env 之前
M->>T: ⑥ 新建大栈线程跑 run_async_main(1381-1385)
T->>A: tokio 运行时启动
A->>A: 信号清理任务 / 提示词覆盖加载(1420-1428)
A->>U: 无子命令 → 进入交互 TUI
Explained one by one:
- Process hardening first (line 1308):
sandbox::process_hardening::apply_process_hardening()must run before tokio starts and before any thread is spawned, because hardening measures (such as closing extra file descriptors) are not safe against already-existing threads. - Panic hook (lines 1314-1348): a TUI program’s greatest fear is panic — it has switched the terminal into “alternate screen + raw mode”; one crash leaves the user’s shell in garbage. This hook first calls
emergency_restore_terminal()to restore the terminal, then writes crash info into~/.deepseek/crashes/时间戳.log, and only then hands off to the default hook. - The main thread steps aside (lines 1381-1396): the real runtime does not run on the main thread; it spawns a thread named
codewhale-mainwith a large stack. The comment explains why (lines 1374-1380): the TUI state machine nests too deep; under a debug build, recursively handling a popup event can exceed macOS’s default 8 MiB main-thread stack. - Subcommand dispatch (
run_async_main, from line 1399):doctor(environment checkup),exec(headless task),web(browser client),fleet,mcp,serve,resume,logineach take their own path here; only without a subcommand does it enter the interactive UI.
3.2 Entering the TUI: run_tui’s “preflight checklist”
run_tui() at crates/tui/src/tui/ui.rs:872 is responsible for turning the terminal into an application window; the steps look like an aircraft takeoff checklist:
- OSC 8 hyperlink switch (lines 892-899): make file paths in the terminal clickable; old Windows consoles default off;
- Terminal probe with a timeout (lines 907-941):
enable_raw_mode()is put inspawn_blockingand wrapped withtokio::time::timeout— to prevent some unresponsive terminal from hanging the program forever; after timeout it still has to handle the race “the probe gave up, but raw mode then succeeded late”; - Log redirection must precede entering the alternate screen (lines 948-964): otherwise log bytes leak into the TUI picture and cause the Windows “scroll demon” bug (issue #1909);
- Enter alternate screen, enable mouse capture, bracketed paste, Kitty keyboard protocol (from line 965): at this point the terminal is ready and the main event loop starts.
Design trade-off: almost every step in the boot code carries a comment with an issue number. This reflects the project’s engineering culture — every defensive measure comes from a real crash site. For the reader, this is an excellent sample of “how many edge cases a production TUI has to handle.”
3.3 Subcommand family portrait: one binary, many faces
The subcommand dispatch table in run_async_main (enum Commands at crates/tui/src/main.rs:246) reveals the tool’s full shape. Arranged by use:
| Category | Subcommand | What it does |
|---|---|---|
| Daily mainstay | (no subcommand) | open the interactive TUI |
| Headless / scripts | exec "任务" (Exec, main.rs:1493) |
run a task to completion without a UI, for scripts and CI; --auto is required to allow tool calls |
| Environment checkup | doctor (main.rs:1439) |
check whether key, network, and dependent tools are ready |
| Auth | login / logout / auth |
store/retrieve API keys, OAuth device-code login |
| Session management | sessions / resume / fork |
list, resume, fork historical sessions |
| Multi-agent | fleet (run/status/logs/resume/stop…) |
manage worker teams and the ledger |
| Models | models |
list and probe available models |
| Extensions | mcp (list/init/connect/tools) |
manage MCP server config |
| Beyond the UI | web / serve |
browser client / resident runtime API service |
| Policy debugging | execpolicy / sandbox / features |
inspect command policy, sandbox capability, feature flags |
| Eval | eval / scorecard / review |
run evals, see the scorecard, review a diff offline |
| Initialization | init |
generate AGENTS.md and other project files in the repo |
Note a detail in the exec branch (from main.rs:1493): headless mode also fully loads config, merges workspace overlays, and walks the same engine — headless is not a castrated edition; it just has no UI. This is the dividend of the “UI and engine separated by a message pipe” architecture: TUI, exec, and web three frontends reuse the same engine.
Look at one more boot detail ordinary users never perceive: the reason --no-project-config exists in exec (comment at main.rs:1500-1506) is to let headless evals get a reproducible config surface “determined only by explicit --config” — a switch left specifically for reproducibility. This class of “dark door left for CI/eval scenes” is everywhere in the codebase; it is evidence that it is actually used in automation pipelines.
Chapter 4 · Input capture and dispatch: what happens after you press Enter
4.1 The input pump: a thread whose only job is to read the keyboard
The TUI’s main loop cannot block waiting for the keyboard — it also has to render streaming output and handle engine events. CodeWhale’s solution is a dedicated “input pump” thread (TerminalInputPump at crates/tui/src/tui/ui.rs:487):
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) This thread polls terminal events with the crossterm library and sends them to the main loop through an std::sync::mpsc channel; when there is no event it periodically sends a Heartbeat. Why a heartbeat? Because crossterm’s event::read is a blocking call; the thread may hang forever (Windows console going haywire, a Unix tty stopping sending bytes) — if the main loop finds the pump has had no heartbeat for more than TERMINAL_INPUT_STALL_TIMEOUT (5 seconds, ui.rs:470), it “abandons” (detaches) the old thread and swaps in a new pump (from ui.rs:664). On the rendering side, the picture is drawn by ratatui 0.30 (crates/tui/Cargo.toml:50); crossterm 0.29 owns events and terminal control.
graph LR
subgraph 输入泵线程["codewhale-terminal-input 线程"]
P[event::poll / event::read
crossterm 0.29]
end
subgraph 主循环["TUI 主循环(async)"]
Q{通道 recv}
R[引擎事件 drain
每批最多 16 个 / 8ms]
S[ratatui 渲染]
end
K[键盘/鼠标/粘贴] --> P
P -- TerminalInputMessage::Event --> Q
P -- Heartbeat --> Q
Q --> R --> S
S --> Q
Note the “input fairness” design in the main loop: each round drains at most 16 engine events, with a total budget of 8ms (ui.rs:475-477), to prevent a flood of model output from starving keyboard input — a lesson from issue #1830.
4.2 After Enter: five-level dispatch
You hit Enter; the key event arrives at the KeyCode::Enter handling branch (ui.rs:6139). Next is an interception chain with a definite order — like airport security, layer by layer:
flowchart TD
E[回车键 @ ui.rs:6139] --> A{① 计划确认弹窗激活?
handle_plan_choice}
A -- 是 --> A1[按 1-4 处理计划选择]
A -- 否 --> B{② 以 # 开头?
记忆快速记录}
B -- 是 --> B1[追加到 memory.md,不发请求]
B -- 否 --> C{③ 以 ! 开头?
handle_bang_shell_input @ ui.rs:9317}
C -- 是 --> C1["走正常审批路径执行 shell
Op::RunShellCommand"]
C -- 否 --> D{④ 像斜杠命令?
looks_like_slash_command_input @ app.rs:122}
D -- 是 --> D1[execute_command_input
内置/自定义斜杠命令]
D -- 否 --> F[⑤ 普通消息
submit_or_steer_message @ ui.rs:11662]
F --> G{引擎正忙?}
G -- 忙 --> G1[转为 steer 插话
注入当前回合]
G -- 闲 --> G2[dispatch_user_message @ ui.rs:8651
开新回合]
Explained level by level:
- Plan choice: after the model produces a plan in Plan mode, typing
1-4selects a button rather than sending a message (ui.rs:6159). #quick memory: typing# 数据库用 Postgres, this sentence does not go to the model; it is appended as a timestamped bullet to the user memory file (issue #492,ui.rs:6170-6173).!shell passthrough:!ls -lasends the command directly into the engine’s shell channel — note it walks the normal approval path (Op::RunShellCommand,ui.rs:9331-9340); it is not a back door.- Slash-command test (
app.rs:122):looks_like_slash_command_inputchecks whether, after stripping leading whitespace, it starts with/or$($技能名is a skill shortcut). A hit is handed to the command system (Chapter 10); this class of input never touches the model. - Ordinary message: if the engine is running (
app.is_loading), this message becomes a steer — injected into the in-progress turn through therx_steerchannel; the main loop appends it to the conversation before the next request (Chapter 6 will show the consumption point); if the engine is idle, a new turn is opened normally.
Real examples:
- Typing /model deepseek-reasoner → level ④, switch routing, zero token spend;
- While the model is editing code, typing “顺便把测试也跑了” → level ⑤ steer branch, appended as a new user message; the model’s next reasoning step can see it;
- Typing !git status → level ③, executed as a shell tool call (counts toward approval), output enters conversation history;
- Typing $code-review → looks_like_slash_command_input also treats the $ prefix as a command (app.rs:124-128), triggers the skill-completion menu; selecting it is equivalent to invoking the corresponding skill;
- Typing # 这个项目用 pnpm 不用 npm → level ② intercept, written into the memory file; the model is not disturbed this round at all;
- Pasting a large log from the clipboard → bracketed paste guarantees the whole block enters the input box as one piece, and is not treated as a cascade of keypresses that accidentally fires Enter.
Design trade-off: this dispatch all happens in the UI layer; the engine only recognizes Op. The benefit is clean engine logic (it does not have to understand slash commands); the cost is that the UI layer’s ui.rs inflates to 16815 lines — a microcosm of this project’s “thick UI, cohesive engine” style.
Chapter 5 · Context assembly: how the system prompt is put together
5.1 The assembly function
Each session’s system prompt is assembled by system_prompt_for_mode_with_context_skills_session_and_approval() at crates/tui/src/prompts.rs:1137. The function name is as long as a German train, but its assembly order embodies an iron law that runs through the whole project — the file comment calls it the 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.
In spoken-technical English: the provider’s KV prefix cache bills by “how many bytes at the start of the request are the same as last time,” so put forever-unchanging content first, put content that may change every turn last, so the cache hit is as long as possible.
5.2 Assembly layers
flowchart TD
subgraph 缓存稳定区["① 缓存稳定区(尽量永不变)"]
L0[本地化前言
非英语会话的思维语言指令]
L1["宪法 BASE_PROMPT
prompts/text.rs:46
身份/工具纪律/输出法则"]
L2["项目上下文
AGENTS.md 等指令文件"]
L3[用户宪法块 + 项目上下文包]
L4[技能目录 ## Skills]
L5["核心执行纪律
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
Looking at the corresponding code section by section (prompts.rs:1144-1336):
- Constitution (lines 1144-1153):
BASE_PROMPTis embedded in the binary at compile time (prompts/text.rs:46); it starts with## Codewhale\n\nYou are Codewhale, an agent working alongside the user.... Its wording is quite “literary” — for example it tells the model “The A is already yours,” using the metaphor of “the teacher gives the student an A on day one” to make the model “stop performing and start creating.” This personified prompt is a legacy of the deepseek-tui era, and a distinctive flavor of this project. - Project context (lines 1156, 1176):
load_project_context_with_parents(workspace)collects instruction files (next section). - Skills block (lines 1228-1247): scan several skill directories and render a
## Skillscatalog; the model can later use theload_skilltool to load full text on demand. - Core execution discipline + compaction template (lines 1252-1258): tell the model how to work, and what format to use for
.codewhale/handoff.mdon/compact. - WorldState volatile fragments (lines 1266-1302): environment, memory, goals, routing, handoff history, assembled into independent
SystemBlocks. - Closing recap (lines 1310-1334): put the permission recap and language instruction once more at the end — exploiting LLMs’ “recency effect” (the last content seen leaves the strongest impression).
5.3 AGENTS.md collection rules
Project-memory file collection lives in crates/tui/src/project_context.rs. Candidate filenames are in the constant table near :35:
// project_context.rs:35-37 节选
"AGENTS.md", // 跨 agent 的项目指令(规范名,最高优先级)
...
"CLAUDE.md", // Claude 风格指令(兼容)
Rule points:
- Priority:
AGENTS.mdis the canonical name;CLAUDE.mdand variousinstructions.mdvariants are compatibility items (comment atproject_context.rs:6-8). An old project’sWHALE.mdis ignored with a warning (:53), prompting the user to migrate. - Walk upward: from the workspace directory all the way to parent directories, supporting monorepos — one
AGENTS.mdat the root applies to all subpackages (comment at:931). - Global files: also reads
~/.codewhale/AGENTS.md,~/.agents/AGENTS.md, and the compatibility path~/.deepseek/AGENTS.md(:68-70). - Anti-explosion cap: after all rule files are merged, anything over 500 KB is truncated (
MAX_RULES_BLOCK_BYTES,project_context.rs:92) — to prevent someone (or a malicious repo) from blowing the context with a giant instruction file. - git/environment info: the environment block in the volatile zone is rendered by
render_environment_block(prompts.rs:1267), including workspace path, git status, etc., rebuilt every turn, so it is placed after the cache-stable zone.
Design trade-off: CodeWhale treats the “system prompt” as an artifact that needs engineering management, not a blob of string: constant/volatile layering, compile-time embedded baseline, runtime-diff rebuild of volatile fragments, plus prompt_zones.rs’s “three-zone prefix contract” that validates prompt drift at runtime (Chapter 6 will show the check point). The cost is complex assembly code; the payoff is that a long session’s KV-cache hit rate is observable and tunable (the TUI even has a /cache command to inspect cache telemetry).
5.4 An instance of “what actually went into the request”
Suppose you are in a Rust repo with an AGENTS.md, using a Chinese UI, with memory on, starting the first turn. The final request’s system field is roughly this sandwich: at the front, a Chinese thinking preface (“请用简体中文思考与回复”); then about two thousand words of constitution BASE_PROMPT; then the full AGENTS.md, user-level constitution, project-context pack (README summary, cap 4000 characters, project_context.rs:93); then the skill catalog (one-line intro per skill, full text loaded on demand); core execution discipline and compaction-handoff template close the constant zone. In the volatile zone: the first environment block (workspace path, git branch, date), a <user_memory> block, the current route (model ID, verbosity). Finally the permission recap and Chinese closing instruction. The user message itself also carries per-turn metadata (working-set summary, etc.) — note it is not in the system prompt, because it changes every turn; putting it in the system prompt would destroy the entire prefix cache (the comment at prompts.rs:1091-1092 explicitly records this relocation decision).
The next time you see “the AI assistant is so smart, it knows everything,” you can remember this sandwich: everything it knows is either written in this stack of prompts, or just fetched by a tool and stuffed into history. No magic, only assembly.
Chapter 6 · The agent main loop: the heart of the whole project
If you remember only one file from this book, remember crates/tui/src/core/engine/turn_loop.rs (4243 lines). The agent’s main loop lives in it.
6.1 The loop body: it is a loop, not recursion
The entry function is handle_deepseek_turn (turn_loop.rs:286) — the name once more exposes the deepseek-tui origin, but today it drives every provider. Its structure is an outer loop (line 342) + an inner streaming-consumption loop (line 785), neither recursion nor an explicit state machine:
flowchart TD
START[handle_deepseek_turn @ turn_loop.rs:286] --> L0{"外层 loop @ :342
每个'推理步'转一圈"}
L0 --> C1{取消令牌触发?}
C1 -- 是 --> INT[返回 Interrupted]
C1 -- 否 --> C2[drain 插话 steer + 子 agent 完成事件]
C2 --> C3[refresh_system_prompt 刷新系统提示词]
C3 --> C4{到 max_steps?
默认 1000 @ engine.rs:492}
C4 -- 是 --> BRK1[break:步数耗尽]
C4 -- 否 --> C5{目标 token 预算耗尽?}
C5 -- 是 --> BRK2[break]
C5 -- 否 --> C6{该压缩了?
should_compact}
C6 -- 是 --> C7[自动压缩后继续]
C6 -- 否 --> C8[上下文预算预检 + LSP 诊断注入]
C7 --> C8
C8 --> R[组装 MessageRequest @ :658
model/messages/system/tools/effort/stream=true]
R --> S["tokio::select!
取消 vs create_message_stream @ :689"]
S --> INNER{"内层 loop @ :785
逐块消费 SSE 流"}
INNER -->|文本/thinking/工具调用增量| INNER
INNER -->|流结束| T{tool_uses 为空?}
T -- 有工具调用 --> X[规划并执行工具
追加结果到 messages]
X --> L0
T -- 无工具调用 --> Z{有插话/子agent完成/
REPL块/目标续接?}
Z -- 有 --> L0
Z -- 都没有 --> DONE[break @ :1619
回合真正结束]
DONE --> RET["返回 Completed @ :3033"]
6.2 One circuit of the loop, section by section
Section 1: “housework” at the start of a turn (lines 342-404). Each circuit first checks the cancel token (cancel_token, fired when the user presses Esc); then pulls user steers from while the model was working off the rx_steer channel and appends them as user messages into the session (lines 348-365); then harvests already-completed sub-agent results (line 372, Chapter 9); refreshes the system prompt; checks the step cap and the goal token budget.
Section 2: compaction and budget (lines 406-532). Call should_compact() to decide whether automatic compaction is needed (Chapter 8); then do a “context input-budget pre-check” — if estimated input tokens exceed that route’s window cap, first try recovery (compact/trim); only after recovery exceeds MAX_CONTEXT_RECOVERY_ATTEMPTS times does it error out (lines 497-521). Then inject LSP diagnostics (compile errors, etc.) as a synthetic user message (line 527) — so the model can see the compile errors its last round of code edits produced, before the next reasoning step.
Section 3: assembling the request (lines 534-683). The core is this MessageRequest (lines 658-683):
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 默认
...
};
Note two “cache gatekeepers” working before the request is sent: PrefixStabilityManager (line 565) detects whether the system prompt or tool set has drifted relative to the previous turn and reports TUI counters; the frozen_prefix three-zone prefix contract (line 623) freezes a baseline on the first turn and validates drift on later turns (issue #2264).
Section 4: streaming consumption (lines 689-1360). The request races the cancel token through tokio::select! (lines 689-696; biased means check cancel first). After the SSE stream is established, enter the inner loop (line 785) and consume event by event: text deltas, thinking deltas, tool-call deltas. A large amount of engineering detail is buried here:
- chunk timeout: if a single data block does not arrive within
stream_chunk_timeout_budget, it is judged Stall (lines 789-803); - sleep-wake detection:
Instant(monotonic clock, paused during sleep) andSystemTime(wall clock) dual-track the last progress time; a large deviation between them means “the laptop lid was closed,” not “the network died” (comment at lines 773-779, issue #2990); - transparent retry: if the stream disconnects before emitting any content, the same request is silently resent up to
MAX_STREAM_RETRIEStimes (lines 336-340, 761-768); - Anthropic signed thinking replayed verbatim (lines 736-737, issue #3014): a signed thinking block must be sent back byte-for-byte in the tool loop, or the Anthropic API errors.
Section 5: the fork — are there tool calls (lines 1373-1620). After the stream ends:
- There are tool calls → enter tool planning and execution (next section); after execution, append results into
messages; the outerloopturns another circuit, requesting the model again with the new history. This is the classic agent structure of “request → tool → append → request again.” - There are no tool calls, there are still five “keep-alive” reasons, checked in order (lines 1394-1619): pending steer to inject? → completed sub-agent? → a
``repl code block in the reply that should be executed locally? → goal not yet achieved, need continuation? → only if truly none of these does itbreak(line 1619). Thisbreak` is the only normal termination condition. - Additionally two “anti-dementia” guards:
StuckGuardfinds the model producing consecutive no-progress replies and first warns then force-stops (lines 1374-1391);ReadRepeatGuardprevents the model from infinitely rereading the same batch of files.
6.3 Tool execution: parallel, but with discipline
The tool-execution section is after line 2202. The model may return several tool calls in one breath; the engine’s planning is:
flowchart LR
P[本步工具调用列表] --> G{逐个过闸}
G -->|被守卫拦截| G1[直接返回拦截结果]
G -->|需审批| G2[Event::ApprovalRequired
等用户决定]
G -->|放行| H{supports_parallel?}
H -->|是| F["FuturesUnordered 并行池
shell 另有信号量限流"]
H -->|否| Q[串行执行 execute_tool_with_lock]
F --> O[按原始顺序收集 outcomes]
Q --> O
O --> A[追加 tool_result 到 messages
→ 外层 loop 下一圈]
Key code (turn_loop.rs:2214-2336): the parallel pool collects futures with FuturesUnordered; shell-class tools are additionally rate-limited by a MAX_PARALLEL_SHELL_EXEC semaphore; oversized output is spilled to disk before fan-out (tools/truncate.rs); on cancel the whole pool is dropped directly — “Dropping FuturesUnordered drops every still-active tool future … instead of merely waiting for cooperative cancellation” (comment at lines 2333-2335), using Rust’s Drop semantics to implement hard cancel.
6.4 Interrupt mechanism
Interrupt runs through three layers: ① the user presses Esc → the UI sets cancel_token; ② the top of the outer loop, the streaming select!, and the parallel-pool select! all listen on this token; ③ approval waiting (next chapter) also select!s on the same token. So whether the model is streaming, a tool is running, or an approval dialog is up, Esc can stop at the current step boundary and return TurnOutcomeStatus::Interrupted.
Design trade-off: this main loop’s style is “one giant function + a sea of defensive branches,” not an elegant small state machine. The benefit is that handling of every edge case (sleep, stream break, steer, late sub-agent, REPL, goal budget) is spread in the same field of view, with comments citing issue numbers for traceability; the cost is that a 4243-line function is unfriendly to newcomers — the project’s own answer is to write 11934 lines of tests (core/engine/tests.rs), using tests instead of structure to guarantee safety.
6.5 A full turn’s script: “fix a failing test” as example
String all the previous parts together and look at the complete internal timeline of codewhale exec "fix the failing test":
sequenceDiagram
participant U as 用户
participant E as 外层 loop(推理步)
participant M as 模型 API
participant T as 工具层
U->>E: ① "fix the failing test"
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
+ 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
(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 用量
Three points in this script that beginners most easily misunderstand deserve emphasis:
- “Thinking” does not happen on some central server; it happens in this circuit after circuit of the loop. Each circuit the model can only see history, say a stretch of words (possibly with tool calls), then the loop feeds tool results back. An agent, at bottom, is the combination punch of “loop + tools + history.”
- Tools are not executed by the model; they are executed by the host program. The model only outputs a stretch of structured text “I want to call
edit_file, parameters are…”; the one that actually reads files and runs commands is the Rust-side engine. That is why the permission gate can sit in the engine — the model never even gets a chance to “secretly execute.” - Every circuit can be interrupted or kept alive. If the user presses Esc at step ④, the top of circuit 2 returns Interrupted; if the user types “顺便加个测试” at step ⑯, that steer is appended into history at the start of the next circuit. The loop is not a black box; it is an office people walk in and out of at any time.
Chapter 7 · Tool system and permissions: the model’s “hands” and the “cuffs” on those hands
7.1 Tool definition: the ToolSpec trait
Every tool implements the same trait (crates/tui/src/tools/spec.rs:1167):
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
...
}
Note the ingenuity of this default implementation (lines 1181-1190): approval level is not a made-up config; it is derived from capability tags. As long as a tool honestly declares ExecutesCode (it will run code), it automatically falls into the strictest approval grade — this is the type-level embodiment of what the README calls “safe by construction.”
7.2 Full built-in tool inventory
Tool registration uses a builder pattern: a chain of with_* methods on ToolRegistryBuilder, each registering a family (crates/tui/src/tools/registry.rs). Agent mode’s complete tool surface is assembled by with_agent_runtime_surface() (registry.rs:1226), which internally calls with_agent_tools_policy() (:1194). Plan mode walks the read-only surface (tool_setup.rs:60-79). The table below lists model-visible canonical names + hidden compatibility aliases by family (aliases are for old-session replay; see the comment at :682-683):
| Group | Tool name (canonical / alias) | Function | Key parameters | Example |
|---|---|---|---|---|
| Files | File (read_file/write_file/edit_file/list_dir) |
read/write/edit/list files, dispatched by action | action, path, content |
read_file src/main.rs |
apply_patch |
apply a unified-diff patch (feature flag) | patch |
change many files at once | |
fim_edit |
small “fill-in-the-middle” edits with a completion model | path, prompt |
change one function body | |
| Shell | Bash (exec_shell/exec_shell_wait/exec_shell_interact/exec_shell_cancel) |
run shell commands, supporting background and interactive | command, timeout |
cargo test |
terminal_run/terminal_send/terminal_wait/terminal_cancel/terminal_reset |
stateful PTY terminal sessions | command, session |
start a dev server then interact | |
| Search | grep_files, file_search |
content search / search by filename | pattern, path |
find all TODO |
| Git | Git (git_status/git_diff/git_log/git_show/git_blame) |
repo status, diff, history, blame | path |
see uncommitted changes |
| Diagnostics / project | diagnostics |
LSP diagnostic summary (compile errors) | — | self-check after editing |
project_map |
project-structure map | — | first look at a strange repo | |
| Test / verify | Run (run_tests/run_verifiers) |
run tests and acceptance scripts | filter |
run unit tests and summarize failures |
validate_data |
structured-data validation | data, schema |
validate JSON | |
| Web | Web (web_search/fetch_url/wait_for_dev_server), web.run |
search, fetch pages, wait for a local service | query, url |
look up docs, fetch an issue |
| Tasks / collaboration | tasks (task_create/task_list/…) |
durable tasks, gates, PR-attempt records | action |
create a recoverable task |
github (github_issue_context/github_pr_context/github_comment/github_close_*) |
read issue/PR, comment, close | action, number |
see PR context | |
automation (automation_create/…) |
timed / trigger automation | action |
run a check every night | |
| Progress | work_update (todo_*/checklist_* aliases) |
maintain a todo list (the only progress surface, issue #4132) | items |
break a task down and check it off |
update_plan |
write/edit a plan in Plan mode | plan |
emit an implementation plan | |
create_goal/get_goal/update_goal |
long-term goals with a token budget | objective |
time-boxed refactor | |
| Sub-agent | agent |
start/query/wait/cancel a sub-agent | action, prompt, worktree… |
Chapter 9 |
agents/list/agents/message/agents/followup/agents/interrupt/agents/wait/agents/coordinate |
multi-agent coordination | agent_id |
send a message to a child | |
| Review / memory | review |
let another model review a diff | — | self-review |
remember |
append a line to the user memory file (registered only if the user has memory enabled) | text |
remember a user preference | |
slop_ledger_append/query/update/export |
record a “tech-debt residue” ledger | entry |
mark leftover issues | |
| Large-output governance | retrieve_tool_result |
retrieve a historical tool output that was spilled to disk | id |
look back at a long log |
handle_read |
read content referenced by a var_handle symbol |
handle |
large objects that save tokens | |
| Misc | note |
in-session scratch notes | text |
intermediate conclusions |
request_user_input |
the model asks the user back (popup) | questions |
when a parameter is missing | |
revert_turn |
undo the previous turn’s workspace changes (snapshot) | — | “undo the last edit” | |
load_skill |
load a SKILL.md full text on demand | name |
Chapter 10 | |
finance |
financial market data | symbol |
look up a stock price | |
pandoc_convert |
document format conversion (registered only if pandoc is detected) | input, format |
md → docx | |
image_ocr |
image OCR (registered only if a backend is detected) | path |
read screenshot text | |
image_analyze |
vision-model image analysis (registered only if [vision_model] is configured) |
path |
look at a UI screenshot | |
speech/tts |
speech synthesis | text |
read the result aloud | |
notify |
desktop/terminal notification (respects user notification config) | message |
long-task completion reminder | |
rlm (rlm_open/rlm_eval/…) |
persistent RLM sessions | action |
extra-long-context research | |
workflow |
declarative/JS workflow orchestration | script |
Chapters 9, 10 | |
start_mcp_server |
dynamically bring up an MCP server at runtime | name |
Chapter 10 |
Two details worth noticing:
- Probe-style registration: tools that depend on an external binary, such as
pandoc_convertandimage_ocr, first probe whether the host has it; if not they are not registered at all (comment atregistry.rs:754-767). The model never sees a tool it cannot use — hallucination-style calls are killed at the source. - Mode decides the tool surface: Plan mode registers a read-only subset (
tool_setup.rs:60-79); write-tool names are simply not in the catalog. “Read-only” is not begging the model via the prompt not to write; the model has no write tool at all. This is the mechanistic implementation of “Plan mode is read-only.”
Look at one more design the table cannot show: why are file read/write one File tool rather than four independent tools? From the registration code (registry.rs:660-665) you can see that File is the canonical name with an action parameter; read_file/write_file/edit_file/list_dir are only compatibility aliases hanging on the same implementation. This has two practical payoffs: first, the tool catalog gets shorter, so the schema the model has to read each turn is smaller (saves tokens, and reduces the chance of picking the wrong tool); second, old names in old session transcripts can still replay (:682-683 comments that aliases are hidden). tasks, github, automation, rlm all use the same “one canonical name + action parameter + hidden aliases” pattern — this is the warehouse-wide tool-surface convergence of the v0.9 era (issue #4625).
A complete approval instance: suppose in the default Ask posture the model decides to execute Bash{command: "rm -rf target/"}. ① Bash declared the ExecutesCode capability → approval_requirement() returns Required (spec.rs:1183-1184); ② resolve_tool_permission looks up the truth table: Ask posture + Required → Prompt; ③ the engine emits Event::ApprovalRequired, attaching the command text, an intent summary, and two fingerprints; ④ the user sees a popup, chooses “allow and remember this class of command” → the engine records approval_grouping_key into the session allow cache; ⑤ later same-shape commands such as rm -rf build/ are allowed directly, but a precise “deny” binds only to precise parameters (approval_key) and is not generalized. If this command hits execpolicy’s deny prefix (e.g. rm -rf /), it is blocked by the policy layer before step ① and never reaches approval.
7.3 Permission approval: one truth table + one handshake
Approval decisions all converge on one function: resolve_tool_permission() (crates/tui/src/core/authority.rs:289). It takes three inputs: this turn’s authority TurnAuthority (mode, auto_approve, approval posture), the tool’s ApprovalRequirement, and whether the tool is on the “non-bypassable list”; it outputs one of three: Allow / Prompt / Deny.
flowchart TD
A[工具调用] --> B{requirement == Auto?
(纯只读工具)}
B -- 是 --> AL[Allow:直接执行
Never 姿态下也只读不死]
B -- 否 --> C{姿态 == Never 且非全权?}
C -- 是 --> D[Deny:不弹窗直接拒]
C -- 否 --> E{在不可绕过名单?
is_non_bypassable}
E -- 是 --> P[Prompt:必须弹窗
Full Access 也照弹]
E -- 否 --> F{auto_approve / Bypass / Yolo?}
F -- 是 --> AL
F -- 否 --> P
(Corresponding to authority.rs:294-316) This truth table has unit tests verifying cell by cell (authority.rs:380-468), e.g. “Never posture stays read-only rather than paralyzed,” “under Full Access ordinary tools auto-approve, non-bypassable tools still popup.”
When Prompt is needed, the handshake between engine and UI is as follows (crates/tui/src/core/engine/approval.rs:71):
sequenceDiagram
participant E as 引擎(await_tool_approval)
participant EV as Event 通道
participant U as TUI 审批弹窗
participant RX as rx_approval 通道
E->>EV: Event::ApprovalRequired{工具名, 参数,
approval_key, 意图摘要}
(events.rs:290)
EV->>U: 渲染审批框(含 diff 预览)
U->>RX: ApprovalDecision::Approved/Denied/RetryWithPolicy
RX->>E: 返回审批结果
Note over E,RX: 全程挂在一个 select! 上:
取消令牌触发 → ToolError::cancelled(approval.rs:77-82)
Three design details are worth tasting:
- Approval key and grouping key (
approval_key/approval_grouping_keyatevents.rs:290): if the user has clicked “allow for this session” oncargo test, the engine records denials with a precise-parameter fingerprint (#1617) and allowances with a lossy / arity-aware fingerprint (v0.8.37) — allowance can loosely cover same-class commands; denial must be precise to this one set of parameters, preventing “one deny ofrm -rf /being generalized into denying all rm.” - RetryWithPolicy: the user can say not only “yes/no,” but also “retry with a higher sandbox policy” (
approval.rs:27-31). - Input-source downgrade (
UserInputProvenanceatauthority.rs:161-180+ops.rs:53): if a “user message” actually came from a sub-agent handoff, memory recall, or an imported transcript, it cannot inherit the long-term authorization of “auto-approve for this session” — only a real human typing (ExternalUser) can. This closes the privilege-escalation channel of “a malicious sub-agent forges a user instruction to ride full access.”
7.4 Shell execution’s safety design: four layers of defense
flowchart TD
CMD[模型请求跑 shell] --> L1{第 1 层:execpolicy 前缀策略
crates/execpolicy
trusted/denied 前缀 × 三层优先级}
L1 --> L2{第 2 层:审批真值表
authority.rs:289}
L2 --> L3{第 3 层:仓库宪法写保护
repo_law.rs}
L3 --> L4{第 4 层:OS 沙箱包裹
sandbox/}
L4 --> RUN[真正执行]
- execpolicy (
crates/execpolicy/src/lib.rs): a rule set of allowed/denied command prefixes, in three priority layersBuiltinDefault < Agent < User(:13); on conflict, “higher layer + longest prefix” wins; it also hasbash_arity.rsfor argument-count awareness. - Repo constitution (
crates/tui/src/repo_law.rs): entries withpathsglobs in the repo’s.codewhale/constitution.jsonare compiled into write-protection. The rules are very hard-nosed: the law can only “add locks,” never “grant power” (the schema has no allow shape at all);askforce-popups under approval posture and fails closed under Full Access (because full access does not popup);blockdenies directly under any posture; any parse failure degrades to “fewer rules” rather than “poisoning the gate” (comment atrepo_law.rs:9-20). The protected write-tool list is explicit (WRITE_TOOLS = ["write_file", "edit_file", "apply_patch", "fim_edit"]at:30) — the comment admitsfim_editonce slipped through. - OS sandbox (
crates/tui/src/sandbox/): macOS uses Seatbelt (sandbox-exec) and declares it only after a successful probe; Linux uses bubblewrap only when the user explicitly enables it and/usr/bin/bwrapexists; Windows currently honestly reports “none.” Mode mapping is atauthority.rs:235: Plan →ReadOnly, Agent →WorkspaceWrite{writable root=workspace, network allowed}, Yolo →DangerFullAccess. The same honesty principle as the README’s “unknown price displays as unknown, not as $0” is here “do not claim a sandbox unless it actually wrapped.”
Chapter 8 · Context compaction and memory: after the session grows long
8.1 Trigger: a token-only signal
Compaction config and trigger logic live in crates/tui/src/compaction.rs. CompactionConfig defaults (:38-63): on, threshold token_threshold: 800_000 (the comment explains: this is 80% of a V4 model’s 1M window, and deliberately later than the model-side 60% “please /compact manually” guidance, so automatic compaction is only a “keep-alive last resort”).
should_compact() (:690-736) has been a pure token signal since v0.8.11:
// 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
There used to be a “message-count” trigger branch; it was deleted. The comment is very clear: message count was a 128K-window-era heuristic; for “long but each-message-short” sessions it would false-trigger compaction — and compaction rewrites the request prefix, which is exactly most expensive in that scene. Token estimation itself is coarse (about 4 characters ≈ 1 token, estimate_tokens, :644-652); images are estimated at a fixed 1000 tokens.
8.2 Flow: mechanical slimming first, then pay for a summary
flowchart TD
A[主循环每圈开头调 should_compact
turn_loop.rs:410] --> B{超过阈值?}
B -- 否 --> Z[继续正常推理]
B -- 是 --> C["plan_compaction:分区
保留最近 4 条 KEEP_RECENT_MESSAGES
pin 住工作集路径相关消息"]
C --> D["① 机械修剪 prune_tool_results
重复的旧工具输出 → 一行摘要
不花一分钱 token"]
D --> E{修剪后低于阈值?}
E -- 是 --> F[完事:零成本]
E -- 否 --> G["② LLM 摘要 compact_messages_safe
头 14000 + 尾 6000 字符喂给模型
生成结构化摘要"]
G --> H{结果为空?}
H -- 是 --> I["放弃:宁可不压缩也不破坏状态
turn_loop.rs:480-485"]
H -- 否 --> J["replace_messages + 注入摘要
最近 4 条原文原样保留"]
Several key numbers (compaction.rs:65-84): the most recent 4 messages are always kept verbatim (KEEP_RECENT_MESSAGES); summary input is truncated to head 14000 + tail 6000 characters; retained tool results have a per-item cap of 64 KB, thinking a cap of 16 KB; for large context (windows of 500 000 tokens and above) a larger summary-quota table is used.
An accounting example: suppose a session has 60 messages, the to-summarize zone estimates 850 000 tokens, the pin zone (working-set-path-related messages) estimates 50 000 tokens, threshold is the default 800 000. Effective threshold = 800 000 − 50 000 = 750 000 (saturating_sub at compaction.rs:722); 850 000 > 750 000 → trigger compaction. Step one mechanical prune compresses 20 duplicate old tool outputs (e.g. the same long log catted three times) into one-line summaries, saving 300 000; re-estimate leaves only 550 000 < 750 000, so the LLM summary is never called, completed at zero cost. Only when mechanical prune still exceeds does it feed head 14000 + tail 6000 characters of context to the model to write a summary. This two-stage “free first, pay later” design turns compaction from “expensive every time” into “most of the time it costs nothing.”
Compaction’s execution point is in the main loop (turn_loop.rs:410-487); the failure-path comment well embodies this project’s risk philosophy: “Only update if we got valid messages (never corrupt state)” (:442) — if compaction fails, keep using the original history; better to blow the window than to wreck state.
8.3 Memory system: four timelines
CodeWhale’s “memory” is actually four kinds, each with a different time span:
flowchart LR
subgraph 会话内
M1[working_set
最近摸过的文件路径]
M2[note 工具随手记]
end
subgraph 跨会话["跨会话(本仓库)"]
M3[".codewhale/handoff.md
压缩/退出时的交接信"]
M4["AGENTS.md / CLAUDE.md
项目指令"]
M5[".codewhale/constitution.json
仓库宪法"]
end
subgraph 跨项目["跨项目(用户级)"]
M6["~/.codewhale/memory.md
用户记忆(opt-in,正被 Moraine MCP 取代)"]
M7["~/.codewhale/AGENTS.md
全局指令"]
end
- handoff: on
/compactor exit, the model writes.codewhale/handoff.mdinCOMPACT_TEMPLATEformat; next start it is injected as a “token-budget / continuity” fragment (load_handoff_blockatprompts.rs:1293). - User memory:
memory.rs’s legacy mechanism (~/.codewhale/memory.md+remembertool +#shortcut) has been marked DEPRECATED, migrating to Moraine MCP recall (deprecation note atmemory.rs:1-20) — the docs honestly record this: this subsystem is in a transitional state. - Working set: the engine continuously observes which files have been read/written; at compaction it uses the top 24 paths (
top_paths(24)atturn_loop.rs:408) as a pin signal of “do not compact these away.”
Design trade-off: compared with Claude Code’s /compact (a one-shot LLM summary), CodeWhale has two extra layers of thought: first do zero-cost mechanical dedup, then pay for a summary; the pin mechanism keeps “files currently being edited” from being compacted away. This is still an extension of prefix-cache economics — compaction itself is the operation that hurts cache most, so do it as little as possible, as late as possible, and free-slim first.
Chapter 9 · Sub-agents and multi-agent: agent begets agent
9.1 The model-visible agent tool
CodeWhale’s sub-agent is not an independent slash command; it is an ordinary tool in the model’s hands, agent (crates/tui/src/tools/subagent/mod.rs:6249). Action enum (:6229-6238):
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),
...
}
The start parameter table (JSON Schema at :6264-6360) is very rich; pick a few that most embody the design:
prompt: the focused task for the child;type/profile: role (explore/plan/review/verifier…) or a Fleet roster member, carrying role posture, model routing, and instruction overlay;model_strength: same|faster/model: the child can switch to a faster (cheaper) model for low-risk work;thinking: reasoning effort, independent of the parent;worktree: true: open an independent git worktree + new branch for the child, so parallel code edits do not step on each other;fork_context: for a read-only child on the same model route, fork the parent’s cache prefix — another design that saves cache money;max_depth/max_steps: a child can still spawn grandchildren, but depth has a hard ceiling.
9.2 Depth, concurrency, and anti-runaway
flowchart TD
P[父 agent] -->|agent start| C1[子 agent A
explore · faster 模型]
P -->|agent start worktree=true| C2[子 agent B
builder · 独立 worktree]
C1 -->|agent start| G[孙 agent
max_depth 递减]
subgraph 护栏
L1["深度:DEFAULT_MAX_SPAWN_DEPTH
subagent/mod.rs:1885
+ 绝对天花板 MAX_SPAWN_DEPTH_CEILING"]
L2["数量:DEFAULT_MAX_SUBAGENTS = 64
config/subagent_limits.rs:13"]
L3["权限:子 agent 不继承父的
'会话级自动批准'(authority.rs:161)"]
end
Depth computation has a subtle bug history, left in the comment of clamp_child_max_spawn_depth() (:1896): if you only do a spawn_depth > max_depth test, the model re-reports a max_depth >= 1 on every generation and the depth ring never locks — so now the parent’s remaining depth and the model-requested value are added then clamped to the global ceiling.
9.3 Parent–child communication: sentinel messages and the no-barrier principle
After a child finishes, how does the result return to the parent’s conversation? The answer is a sentinel message <codewhale:subagent.done>. Every circuit the main loop calls drain_subagent_completion_events() (turn_loop.rs:238-284) to harvest completion events and append them as runtime messages into the parent session; when the model stream ends with no tool calls, it also first checks whether there are pending child-completion events (turn_loop.rs:1423) before deciding whether to end the turn.
The most important design decision is written in the comment at 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.
The parent never waits just because a child is still running — the turn ends normally, the user can keep talking, and after the child finishes it reports in via the sentinel on a later turn. (The old version select!ed and waited here; the user experience was “TUI frozen.”) At the same time the engine injects a “waiting hint,” to prevent the model from polling empty with peek/status/sleep (issue #4097). Coordination tools (agents/list, agents/message, agents/followup, agents/interrupt, agents/wait, agents/coordinate, registered at coord.rs:682) let the parent explicitly leave messages, nudge, and interrupt children, rather than polling.
9.4 Fleet: from “spawn one” to “lead a team”
Above sub-agents there is another layer, Fleet (crates/tui/src/fleet/): a local-first multi-worker control plane, with a roster (defining roles such as reviewer/scout/builder), a scheduler, alerts, and an append-only JSONL ledger (fleet/ledger.rs: each step appends one record; after a manager crash, state is rebuilt by replaying records; large artifacts store only disk references, not into the ledger). fleet resume can continue from the interrupt point, corresponding to the README’s “Work that survives.”
A typical multi-agent scene: the user says “把这个仓库的测试覆盖率补到 80%.” A reasonable play for the main agent is — ① start a read-only explore child (model_strength: "faster", cheap model) to figure out which modules lack tests; ② simultaneously start two builder children, each with worktree: true on independent branches writing tests in parallel, not stepping on each other; ③ keep doing other work itself, turn ending normally; ④ the two builders finish one after another, sentinel messages flow in on later turns, the main agent runs the review tool over the diffs, then merges. Throughout, the user can talk to the main agent at any time — that is the experience bought by “do not barrier the parent loop.” If the task is larger, e.g. “按这份清单迁移 40 个包,” it should go to Fleet: 40 tasks queue in the JSONL ledger, roster worker roles pick them up one by one; after a machine restart fleet resume continues from the ledger breakpoint, and every step has an auditable receipt.
Further up is Workflow (crates/workflow + crates/workflow-js): the model can write a stretch of JavaScript, executed in a QuickJS sandbox (rquickjs), using await task(opts) to dispatch children, parallel() to fan out, pipeline() to serialize a pipeline, plus a global token-budget variable (doc comments in crates/workflow-js/src/lib.rs). The declarative IR stays on the Rust side; imperative execution walks the JS sandbox; the two communicate through a WorkflowDriver seam and can be tested with a fake driver.
flowchart TD
U[用户] --> W{任务规模}
W -->|单点| A[主 agent 自己干]
W -->|并行几件事| B[agent 工具
生几个子 agent]
W -->|带角色的团队| C[Fleet
花名册 + JSONL 台账 + resume]
W -->|复杂编排逻辑| D[Workflow
QuickJS 脚本:task/parallel/pipeline]
B --> C
C --> D
Chapter 10 · Ecosystem: commands, extensions, MCP, and plugins
10.1 The slash-command universe
Slash commands are organized by group under crates/tui/src/commands/groups/ (core / session / config / memory / plugins / project / skills / utility / debug). Summing CommandInfo across the files, there are more than 85 built-in commands, classified by use (representative commands in parentheses):
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
A few commands with a story: /debt (aliases /cleanup, /slop, /canzha) manages the tech-debt ledger — “canzha” is the pinyin of “残渣,” exposing traces of Chinese-community participation; /relay (alias “接力”) handles cross-session handoff; /hf is a HuggingFace shortcut. Many commands have Chinese aliases (jihua=plan, zidong=auto, commands/mod.rs:157-167). Users can also drop Markdown files in .codewhale/commands/ as custom commands (frontmatter can restrict the tool set); this is the extension point with the most community contributions.
What does a custom command look like? A .codewhale/commands/review-pr.md file; the body is a prompt template ($ARGUMENTS placeholders are replaced by command arguments); frontmatter can write allowed_tools restricting which tools this session may call. Then /review-pr 123 equals “open a turn with a read-only tool surface + a prefabricated prompt” — freezing commonly used complex prompts into team-shared commands that live in the repo and travel with the code. This is the same play as Claude Code’s custom slash commands, further confirming conceptual interoperability of the two ecosystems.
10.2 Skills: an open strategy of compatible foreign directories
Skill discovery scans a long list of directories (crates/tui/src/skills/roots.rs:153-251): .codewhale/skills, .agents/skills, .claude/skills, .cursor/skills, .opencode/skills, .codex/skills, plus a flat workspace/skills. That is, a skill you wrote for Claude Code / Cursor / Codex, CodeWhale can use directly — a deliberate compatibility strategy (comment at prompts.rs:1218-1224). The system prompt only holds the skill catalog; the model uses the load_skill tool to load full text on demand, saving tokens.
10.3 MCP: full-transport support + runtime hot-add
MCP lives in crates/tui/src/mcp.rs, supporting three transports: stdio (local child process), sse, streamable_http, with a connection pool, OAuth, and per-server timeouts. Tools from an external MCP server are wrapped as ordinary ToolSpec via McpToolAdapter (registry.rs:1150-1157); to the model they are indistinguishable from built-in tools. More special is the start_mcp_server tool (runtime_mcp.rs): the model itself can bring up a new MCP server mid-conversation from context — the comment at tool_setup.rs:49-56 specifically records the fix that aligned it to all executable modes.
10.4 Hooks and plugins
Hooks (crates/tui/src/hooks.rs:51) execute user-configured shell commands at 11 lifecycle points: SessionStart/End, MessageSubmit (can rewrite or intercept a user message, ui.rs:8678-8702), ToolCallBefore/After, ModeChange, OnError, TurnEnd, SubagentSpawn/Complete, ShellEnv (inject temporary environment variables into shell tools, e.g. short-lived credentials). The plugin system (crates/tui/src/plugins/) does read-only discovery early in boot (the comment at main.rs:1356-1372 emphasizes: discovery ≠ enable ≠ trust ≠ execute); script tools are registered via configure_plugin_tools (engine.rs:3531-3533); on name collision, explicit config overrides auto-discovery.
Design trade-off: CodeWhale’s extension-ecosystem strategy can be summarized as “if it can be compatible, do not invent a second standard”: skill directories compatible with six houses, MCP full transport, commands as Markdown. This lowers user migration cost, and is a community project’s realistic way of fighting official products.
Chapter 11 · Feature set: auth, models, and those “exclusive tricks”
11.1 Auth: a unified facade for 36 providers
Provider identity concentrates in the ProviderKind enum at crates/config/src/provider_kind.rs:13 — a full 36 variants, from DeepSeek, Anthropic, OpenAI to Moonshot (月之暗面), Zai (智谱), MiniMax, Stepfun (阶跃), Xiaomi MiMo, then self-hosted Vllm/Sglang/Ollama (no key). Each variant carries a string of serde aliases (e.g. zhipu, bigmodel both map to Zai); however the user writes it, it matches.
Three auth paths: codewhale auth set --provider X stores the key in the OS keychain (crates/secrets; Linux needs libdbus-1-dev, see AGENTS.md:131-134); environment variables passed through (ANTHROPIC_API_KEY etc.); and OAuth device-code flow (oauth.rs, xai_oauth.rs). All model calls walk a unified LlmClient trait (llm_client/mod.rs:54): create_message + create_message_stream (returns an SSE event stream). The HTTP layer also has a safety gate: the base URL must be HTTPS, or a host loopback address, or the ALLOW_INSECURE_HTTP environment variable must be set explicitly; otherwise boot is refused (client.rs:624-662).
Three typical connection scenes, corresponding to three kinds of user:
# 场景 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
Scene C’s error copy (client.rs:647-656) is itself a mini tutorial: it tells you loopback is auto-allowed, which environment variable a LAN address needs, and even gives a command example. This “error message is documentation” style is consistent across the warehouse — Chapter 3’s error when codewhale-tui is missing (crates/cli/src/lib.rs:4397-4405) likewise walks you through diagnosis by the hand.
11.2 Model selection and reasoning effort
/model switches provider and model at once; --model auto / /model auto turns on automatic routing (model_routing.rs): pick a concrete model and reasoning effort from task content and recent context; the TUI can show a routing receipt. Reasoning effort splits off/low/medium/high/max; Ctrl+T cycles (docs/MODES.md); the auto grade is resolved to a concrete grade from message content by resolve_auto_effort() (turn_loop.rs:552-558).
11.3 Feature highlights
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 模式 / 主题 / 状态栏
Expand three:
codewhale web: starts a browser client on host127.0.0.1:7878. Only loopback is allowed, there is no--hostparameter, the browser-launch URL carries a one-shot short-lived bootstrap credential and never a long-term token (docs/WEB.md) — the safety boundary of a “local service” is treated as first-class design.- LSP closed loop: after
Filewrite/edit/patch actions, LSP diagnostics fire and are injected as a synthetic user message before the next reasoning step (turn_loop.rs:527,core/engine/lsp_hooks.rs) — the model “sees” compile errors immediately after editing, forming a self-repair loop. - Snapshots and undo: a workspace snapshot is taken before and after each turn (stored in a side git repo,
snapshot/);/restore Nand therevert_turntool can both roll back (core/turn.rs:6-14) — the premise of “let the agent edit freely” is that you can one-key regret at any time.
Not found / not supported (honest list): Windows has no OS-level sandbox (honestly reports none); landlock/seccomp helper code exists but is not wired into the child-process execution path (sandbox/mod.rs:14-16); user-memory legacy is in a deprecation transition; crate splitting is unfinished — crates/core, crates/agent etc. are currently only peripheral abstractions, not the runtime source of truth.
Chapter 12 · Summary: design philosophy and trade-offs
12.1 Three design philosophies that run through everything
flowchart TD
P1["① 模型是零件,不是产品
36 provider 一个运行时
路由/价格/上下文按真实路由来"]
P2["② 安全是机制,不是承诺
只读=没有写工具
沙箱=真包裹才宣称
宪法=编译成写保护"]
P3["③ 省钱是工程,不是运气
提示词恒定/易变分层
前缀冻结校验
压缩前先免费瘦身"]
P1 --- P2 --- P3
12.2 Five most distinctive design points
- Prefix cache is a first-class architectural constraint. From prompt-assembly order (volatile-content-last),
PrefixStabilityManagerruntime telemetry, thefrozen_prefixthree-zone contract, to “compaction would rather be late and rare,” the whole project is designed around “make the KV-cache hit longer” — among open-source agents of this class almost no second house takes this to this degree. - “Safe by construction” is at the type and mechanism level. Approval level is derived from tool capability tags (
spec.rs:1181); Plan mode does not register write tools;constitution.jsonis compiled into write-protection even Full Access cannot bypass; input sources do not inherit long-term authorization — safety does not beg the model to behave via the prompt; the model simply has no tool with which to overreach. - Input-source (provenance) downgrade.
UserInputProvenance(ops.rs:53) distinguishes “typed by a real human,” “handed off by a sub-agent,” “recalled from memory,” “imported from a transcript”; non-human sources uniformly cannot inherit session-level auto-approve — source-level defense against the new agent-era threat of “indirect prompt-injection privilege escalation.” - Sub-agents do not barrier the parent loop + a sentinel completion protocol. The parent never freezes because a child is running; completion results naturally flow in on later turns as the
<codewhale:subagent.done>sentinel (issue #3216) — replace blocking wait with a message protocol; the TUI is always responsive. - The ecosystem-compatibility “do not invent a second standard” strategy. Skill scan is compatible with six houses’ directories
.claude/.cursor/.codexetc.; bothAGENTS.md/CLAUDE.mdare recognized; custom commands are just Markdown — a community project trades compatibility for users.
12.3 Advantages and costs Rust brings
Advantages (all attributable in source): single-binary distribution; tokio holding up the concurrency disk of “streaming + parallel tools + sub-agents + steer”; Drop semantics for hard cancel (dropping FuturesUnordered cancels every parallel tool); the type system converges permission decisions into an exhaustively testable truth table (tests in authority.rs verify cell by cell); crashes are controllable (panic hook restores the terminal + crash dump to disk).
Costs (equally attributable in source): 650 000 lines of volume; single files of several thousand lines at a time (ui.rs 16815 lines, subagent/mod.rs 11833 lines); astonishing density of #issue comments left by every historical bug; crates/tui became a giant crate of “nominally UI, actually the entire runtime,” and official docs themselves admit the split “is in progress”; compile speed is a barrier for new contributors (AGENTS.md specifically teaches how to run targeted tests).
12.4 A reading path for beginners
If you want to read the source along this analysis, this order is recommended:
README.md→docs/ARCHITECTURE.md(build the map);crates/cli/src/main.rs(18 lines, the smallest starting point) →crates/tui/src/main.rs:1292(six boot steps);crates/tui/src/tui/ui.rs:6139(Enter dispatch) →ui.rs:8651(message dispatch);crates/tui/src/prompts.rs:1137(prompt assembly);- The heart:
crates/tui/src/core/engine/turn_loop.rs:286(main loop); crates/tui/src/tools/spec.rs:1167(tool trait) →registry.rs:1226(tool surface) →authority.rs:289(permission truth table);- Go deeper by interest:
compaction.rs(compaction),tools/subagent/mod.rs(sub-agents),fleet/ledger.rs(ledger),repo_law.rs(repo constitution).
CodeWhale’s greatest value for a beginner is not that it is “yet another AI coding tool,” but that it writes into code and comments “every dirty problem a production agent has to handle” — stream breaks, sleep, injection, overreach, cache, crash, rollback. Reading it is equivalent to reading an error-notebook with issue numbers.
End of lecture. Cited line numbers are based on commit 88a158e; the project iterates very fast (CHANGELOG shows near-daily updates). When reading the latest code, line numbers may drift, but module boundaries and function names stay stable.