Compare commits

..
Author SHA1 Message Date
CodeWhale BotandClaude Sonnet 5.5 287bc2004d fix(telemetry): count sub-agent spawns in the engine so exec and serve report them (CW-8190)
subagent_spawn was bumped only in the TUI handler for the spawned event, so
exec and serve always sent 0. The bump now sits at the end of
spawn_subagent_from_input, after the child is registered, which every start
path (agent tool, workflow task, fleet member start) goes through. A refused
spawn is not counted. The TUI count is unchanged because the TUI no longer
bumps on its own.

docs/TELEMETRY.md anchor rewritten; generated agent docs refreshed.
Engine test: spawn_counts_one_subagent_spawn_for_telemetry.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 17:46:26 -08:00
CodeWhale BotandClaude Opus 5.5 97c1ad923d feat(web): first-time guide for people who have never used a terminal (CW-8181)
New docs page /docs/first-time ("Use Codewhale for the first time", en + zh)
on the existing DocArticle renderer and docs-map registry. It covers opening a
terminal on macOS, Windows and Linux; installing and checking the version;
choosing the folder Codewhale works in and when that choice matters; opening a
terminal in a folder from Finder (Services > New Terminal at Folder),
File Explorer (Open in Terminal / Open PowerShell window here), `cd` + drag,
or `codewhale -C <dir>`; first run and provider choice; a first task; and
Esc, /undo, /diff, /receipts, /exit and `codewhale --continue`.

Every claim was checked against the v0.10.2 release, not main:
- workspace = current directory or -C/--workspace (resolve_workspace)
- file tools stay in the workspace unless /trust add, /trust on or Full Access
- Seatbelt on macOS, bubblewrap on Linux only when installed, no Windows sandbox
- snapshots and /undo are off in /, home, and ~/Desktop, Documents,
  Downloads, Library, Movies, Music and Pictures themselves
  (unsafe_workspace_snapshot_reason); snapshots do not need git
- no-git folders ask before edits ("No Git repository here, so edits ask
  first." in the v0.10.2 locale); the page avoids Accept Edits and the
  0.10.3 permission dial, and is now in the docs-release guard that keeps
  them out of every docs body while v0.10.2 is the published release
- Finder service names read from Terminal.app's Info.plist and its zh_CN
  ServicesMenu.strings on macOS 26.1

The install page gets a short "New to the terminal?" block for macOS and
Windows that links to the guide; llms.txt and the install markdown carry the
same steps (llms-txt contract test). The page also adds the topic to the
docs search index, the fallback topic list, and the GT catalog registry.

Regenerated web/data/agent-docs.* with npm run prebuild. That also picks up
docs/RUNTIME_API.md drift from 2c9174012e, which had left check:facts
failing with "generated agent docs are stale".

The new copy assumes the main web/public/install.sh (CW-8036), which adds the
PATH block itself; the live site serves the older installer until the next
web.yml dispatch, which ships this page and that installer together.

Evidence (web/, this worktree):
- npm test: vitest 92 files, 1306 passed, 0 failed
- npm run check:facts OK; check:docs PASS; check:locales PASS; gt-site check OK
- eslint (touched files) clean; tsc --noEmit exit 0
- next build: compiled, 979/979 static pages, including /{en,zh,ja}/docs/first-time
  and /docs-markdown/first-time.md
- next dev, built-in browser: /en/install and /en/docs/first-time at 583px,
  /zh/install and /zh/docs/first-time at 375px; no console errors, no
  horizontal page overflow, sidebar link and all internal links resolve
- main's installer in a throwaway HOME (zsh, default macOS PATH): installed
  v0.10.2, appended the marked block to ~/.zshrc, new `zsh -i` found
  codewhale 0.10.2

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 17:16:44 -08:00
CodeWhale BotandClaude Sonnet 5.5 00cf9f002d fix(tui): satisfy clippy 1.100 needless_range_loop and unreachable_code
Iterate with enumerate in the context menu rows and two transcript tests, and
match the uninhabited backend error in the probe test instead of formatting it.
Behaviour unchanged.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 17:07:36 -08:00
CodeWhale BotandClaude Sonnet 5.5 2cd5969e31 chore(web): regenerate agent docs for the CONTRIBUTORS entry and RUNTIME_API
npm run prebuild; the generated snapshot was stale for RUNTIME_API.md on main
and now also carries the CONTRIBUTORS.md change.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 17:07:28 -08:00
CodeWhale BotandClaude Sonnet 5.5 5d7ca06a5a chore(credit): credit MannXo for the Qwen Code registry change and cite the issue number
Map the commit author's name and email to the MannXo account, list them in
CONTRIBUTORS and the website release credits, and write the existing Qwen Code
line as (#7293) so the feature release-note receipt check finds it. check-
contributor-credit.py and check-versions.sh --range-audit-advisory pass.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 17:07:16 -08:00
CodeWhale BotandClaude Sonnet 5.5 31178aec57 fix(engine): collapse four needless_bool tails flagged by clippy 1.100
Return the condition directly in auto_review tag-like check, fleet start_leased
result, DSH overlay_granted (cfg split) and ledger contract overlap. Behaviour
unchanged.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 17:07:08 -08:00
CodeWhale BotandClaude Opus 5.5 5500fee639 docs(changelog): MCP and LSP clients answer server requests (PR 7319)
Merged as its author after two independent reviews (Opus 5.5, high;
correctness and attacker lenses, both "merge" at 8e8cd373ad) with every
check green on the PR.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 15:17:22 -08:00
落尘 30952497ca fix(mcp,lsp): answer server requests instead of taking them as replies (#7319)
* fix(mcp,lsp): answer server requests instead of taking them as replies

A JSON-RPC message with a `method` is a request or a notification, never
a reply, but both clients matched any message carrying an `id` against
their own pending requests. Servers number their requests independently,
so a server `ping` (MCP) or `workspace/configuration` (LSP) that shared an
id with an in-flight request resolved that request with no result, the
real reply was then dropped, and the server's request was never answered.

Both clients now skip messages with a `method` when matching replies and
answer server requests: MCP replies to `ping` with an empty result and to
anything else with -32601; LSP replies -32601 through the outbound queue
without blocking the dispatcher.

Tests: cargo test -p codewhale-lsp (65 passed); cargo test -p
codewhale-engine --lib mcp:: (371 passed). The two new tests fail on main.

Fixes #7306

* fix(mcp): keep the in-flight reply when answering a server request fails

The reply to a server request (`ping`, or -32601 for anything else) was
sent with `self.send(reply).await?`. A failed send failed the
`tools/call` it interrupted, even though the server's real answer was
still on its way, and marked the connection disconnected. Over HTTP it
was worse: a 404/405/406/415/501 on that POST made the transport fall
back to legacy SSE, dropping the Streamable HTTP messages still queued
for the request in flight.

The reply now goes through a new `McpTransport::send_reply`, which
defaults to `send`; HttpTransport overrides it to report a refusal
without changing mode. A failure is logged at debug level and not
propagated, and the connection state is left alone.

Tests: cargo test -p codewhale-engine --lib mcp:: (373 passed); clippy
--all-targets -D warnings clean. The two new tests fail without this
change.
2026-11-13 07:16:25 +08:00
69 changed files with 1554 additions and 1964 deletions

No files matched your search

+2
View File
@@ -319,3 +319,5 @@ namezzb = sailorZ <[email protected]>
alphapku-ux = Alpha Wu <[email protected]>
BX166 = BX166 <[email protected]>
c020627 = c020627 <[email protected]>
[email protected] = Parman Mohammadalizadeh <[email protected]>
Parman Mohammadalizadeh = Parman Mohammadalizadeh <[email protected]>
+3 -5
View File
@@ -12,13 +12,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Session names after a crash, and keep-forever snapshot retention:** a session name saved right after a crash that cut off the previous session-index line is no longer lost, because the new entry now starts on its own line; and a very large `[snapshots] max_age_days` (such as `9223372036854775807` for "keep forever") no longer wraps around and deletes every undo snapshot at session start. Thanks [@luochen211](https://github.com/luochen211) (PR 7316).
- **Blank environment variables:** exporting a variable empty (for example `CODEWHALE_MODEL=` from a compose `${VAR:-}` template) no longer hides its legacy alias. `DEEPSEEK_MODEL`, `DEEPSEEK_BASE_URL`, `KIMI_BASE_URL`, `DEEPSEEK_FORCE_HTTP1`, `DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS` and the other provider alias variables now apply as they do when the primary is unset. Thanks [@luochen211](https://github.com/luochen211) (PR 7315).
- **Qwen Code as an external agent:** Codewhale now finds an installed Qwen Code CLI (`qwen`, 0.6.1 or newer) and can run it as a session over ACP with `qwen --acp`. `GET /v1/agents/external` lists it, an older install is reported as needing `npm i -g @qwen-code/qwen-code@latest`, and sign-in stays inside Qwen Code (`/auth`). Thanks [@MannXo](https://github.com/MannXo) (PR 7293).
- **Telemetry setting default:** the settings schema API reports telemetry's default as on, matching what Codewhale does when you have not chosen.
- **Auto routing abstention:** when the decision model marks a tier, effort or shadow answer `abstained: true`, Auto no longer acts on it, even with `min_confidence = 0`: an abstained tier takes the local fallback, an abstained effort keeps the tier and drops only the effort, and the routing receipt lists the abstained questions.
- **Plugin index (Runtime API):** a new `GET /v1/apps/plugin-index` route lists plugins, skills, connectors, hooks and hand-written commands with kind, source and state, and `/v1/commands` rows say which plugin (`plugin_id`) added a command and whether a command came from the project (`workspace_sourced`) (CW-8092).
- **Model commands and routing:** `codewhale model list` now lists every custom endpoint you have configured, under its own name. `codewhale model resolve` no longer refuses a model on a custom endpoint, router or local server whose model list has not been fetched yet; once a list is fetched, a model missing from it is still refused. Automatic routing marks a context window as verified only when the route itself supplies it, and no longer reports a guessed output limit.
- **ACP:** when a plugin tool asks the core to run a tool that needs approval, the editor now gets a `session/request_permission` on the plugin tool's call that names the nested tool and its input; before, the prompt ended with an internal error (CW-8050).
- **MCP and language servers that send requests mid-call:** a tool call no longer fails with "neither a result nor an error" when an MCP server sends a `ping` or other request that reuses the call's id while the call is running, and a language server's `workspace/configuration` or similar request no longer ends a pending LSP request. Codewhale now answers `ping`, answers other server requests with "Method not found" (it never advertised them), keeps waiting for the real reply, and a refused answer over Streamable HTTP no longer drops the connection to legacy SSE. Thanks [@luochen211](https://github.com/luochen211) (PR 7319).
- **Qwen Code as an external agent:** Codewhale now finds an installed Qwen Code CLI (`qwen`, 0.6.1 or newer) and can run it as a session over ACP with `qwen --acp`. `GET /v1/agents/external` lists it, an older install is reported as needing `npm i -g @qwen-code/qwen-code@latest`, and sign-in stays inside Qwen Code (`/auth`). Thanks [@MannXo](https://github.com/MannXo) (#7293).
- **README and contributor credit:** the repository front page now opens the way the website does: the line "The model can be anyone's. The agent should be yours.", one sentence on what Codewhale is, the install command and one picture, with reference detail moved into the linked guides; the 18 translated READMEs follow the same structure and say they are machine-drafted. The contributor wall and the website now count the same 433 people (407 GitHub accounts and the commit authors whose email is not linked to an account), where the website had counted accounts only. `DEPENDENCY_SECURITY.md` moved into `docs/dependency-maintenance.md`, and `.github/SUPPORT.md` says where to ask for help.
- **Website Plugins page:** The website's Plugins page now describes what the released version does, and a labelled section says what 0.10.3 adds: one Plugins place, Standard and Creator mode, and the Permissions control.
- **First-time guide on the website:** a new docs page, "Use Codewhale for the first time" (`/docs/first-time`, English and Simplified Chinese), walks someone who has never used a terminal through opening one, installing, choosing the folder Codewhale works in and when that choice matters, opening a terminal in a folder from Finder or File Explorer, connecting a model, a first task, and stopping, undoing and quitting. The install page gains a short "New to the terminal?" block for macOS and Windows that links to it, and `llms.txt` carries the same steps.
- **Plugin docs and plugin-creator:** Docs now say everything you add is a plugin. The bundled plugin-creator skill no longer tells an agent never to trust or enable a plugin (in Full Access it may, for one it wrote and installed from its project), points code and screen plugins at cordis-plugin-development, and an untouched copy from 0.10.2 now upgrades. The Chinese extension and Runtime API docs now say editor ACP sessions get plugin tools and configured MCP servers.
- **Model thinking:** New installs show model thinking by default: it streams live in the transcript and collapses under a "Thought for …" header when the answer starts. Existing installs keep their saved setting; turn it on under Model reasoning in chat in /config.
- **Footer clock:** The footer clock and phase now say "thinking", matching the transcript.
+15 -50
View File
@@ -47,18 +47,6 @@ impl Drop for TurnClaim {
}
}
}
fn admitted_call<'a>(
calls: &'a HashMap<String, PendingToolCall>,
execution: &str,
) -> Option<(&'a str, &'a PendingToolCall)> {
let mut id = execution;
loop {
if let Some((key, call)) = calls.get_key_value(id) {
return Some((key.as_str(), call));
}
id = id.rsplit_once('.')?.0;
}
}
fn update(session: &str, update: acp::SessionUpdate) -> Outgoing {
Outgoing::Update(acp::SessionNotification::new(session.to_string(), update))
}
@@ -431,50 +419,27 @@ impl PromptRun {
&& pending.turn_id == turn
&& pending.tool_call_id.as_deref() == Some(execution)
}) {
let (shown, call) = admitted_call(calls, execution)
let call = calls
.get(execution)
.ok_or_else(|| anyhow!("Approval projection lacks admitted tool start"))?;
let id = uuid::Uuid::new_v4().to_string();
let nested = shown != execution;
let nested_call;
let shown_call = if nested {
nested_call = PendingToolCall {
execution_id: execution.to_string(),
name: p
.get("tool_name")
.and_then(Value::as_str)
.unwrap_or(&call.name)
.to_string(),
input: p.get("input").cloned().unwrap_or(Value::Null),
};
&nested_call
} else {
call
};
let title = tool_call_title(shown_call);
let description = p
let title = tool_call_title(call);
let text = p
.get("description")
.and_then(Value::as_str)
.filter(|text| !text.trim().is_empty());
let summary = p
.get("summary")
.and_then(Value::as_str)
.filter(|text| nested && !text.trim().is_empty());
let text = match (summary, description) {
(Some(summary), Some(description)) => format!("{summary}\n{description}"),
(Some(text), None) | (None, Some(text)) => text.to_string(),
(None, None) => title.clone(),
};
let mut fields = acp::ToolCallUpdateFields::new()
.title(title)
.kind(tool_call_kind(shown_call))
.raw_input((!shown_call.input.is_null()).then(|| shown_call.input.clone()))
.content(vec![acp::ToolCallContent::from(text)]);
if !nested {
fields = fields.status(acp::ToolCallStatus::Pending);
}
.filter(|text| !text.trim().is_empty())
.map_or_else(|| title.clone(), str::to_string);
let request = acp::RequestPermissionRequest::new(
session.to_string(),
acp::ToolCallUpdate::new(shown.to_string(), fields),
acp::ToolCallUpdate::new(
execution.to_string(),
acp::ToolCallUpdateFields::new()
.title(title)
.kind(tool_call_kind(call))
.status(acp::ToolCallStatus::Pending)
.raw_input((!call.input.is_null()).then(|| call.input.clone()))
.content(vec![acp::ToolCallContent::from(text)]),
),
vec![
acp::PermissionOption::new(
"allow-once",
-12
View File
@@ -129,7 +129,6 @@ mod members;
mod memory_lens;
mod mobile;
mod plans;
mod plugin_index;
mod plugins;
pub(crate) mod review;
mod secrets;
@@ -3373,7 +3372,6 @@ pub fn build_router(state: RuntimeApiState) -> Router {
.route("/v1/skills/{name}/audit", get(audit_skill_api))
.route("/v1/apps/mcp/tools", get(list_mcp_tools))
.route("/v1/apps/plugins", get(plugins::list_plugins))
.route("/v1/apps/plugin-index", get(plugin_index::get_plugin_index))
.route("/v1/apps/avatars", get(avatars::list_avatars))
.route(
"/v1/apps/avatars/{handle}/atlases/{page}",
@@ -5877,10 +5875,6 @@ struct CommandCatalogEntry {
#[serde(skip_serializing_if = "Option::is_none")]
origin: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
plugin_id: Option<String>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
workspace_sourced: bool,
#[serde(skip_serializing_if = "Option::is_none")]
registration_id: Option<String>,
}
@@ -5939,8 +5933,6 @@ fn command_catalog(
shadowed_by,
shadowed_aliases,
origin: None,
plugin_id: None,
workspace_sourced: false,
registration_id: None,
});
}
@@ -5964,8 +5956,6 @@ fn command_catalog(
shadowed_by: None,
shadowed_aliases: Vec::new(),
origin: None,
plugin_id: command.plugin_id.clone(),
workspace_sourced: command.workspace_sourced,
registration_id: None,
});
}
@@ -6048,8 +6038,6 @@ async fn list_commands(
shadowed_by: None,
shadowed_aliases: Vec::new(),
origin: Some(reference.origin.clone()),
plugin_id: Some(reference.plugin_id.clone()),
workspace_sourced: false,
registration_id: query
.thread_id
.as_deref()
@@ -1,489 +0,0 @@
use std::collections::{BTreeSet, HashMap};
use std::path::{Path as FsPath, PathBuf};
use axum::Json;
use axum::extract::{Query, State};
use serde::Serialize;
use serde_json::Value;
use super::plugins::{self, PluginSummaryEntry};
use super::{ApiError, CommandsQuery, HooksQuery, RuntimeApiState};
const MAX_ADDED_NAMES: usize = 512;
#[derive(Debug, Serialize)]
pub(super) struct PluginIndexResponse {
workspace: String,
items: Vec<IndexItem>,
unavailable: Vec<IndexNote>,
problems: Vec<IndexNote>,
}
#[derive(Debug, Serialize)]
struct IndexNote {
kind: &'static str,
message: String,
}
#[derive(Debug, Serialize)]
struct IndexItem {
id: String,
kind: &'static str,
name: String,
description: Option<String>,
source: &'static str,
state: &'static str,
labels: Vec<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
plugin: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
adds: Option<PluginAdds>,
#[serde(skip_serializing_if = "Option::is_none")]
tier: Option<&'static str>,
owner: &'static str,
row: Value,
}
#[derive(Debug, Default, Serialize)]
struct PluginAdds {
skills: Vec<String>,
connectors: Vec<String>,
hooks: Vec<String>,
commands: Vec<String>,
profiles: Vec<String>,
}
impl PluginAdds {
fn labels(&self, entry: &PluginSummaryEntry) -> Vec<&'static str> {
[
("skill", !self.skills.is_empty()),
("connector", !self.connectors.is_empty()),
("hook", !self.hooks.is_empty()),
("command", !self.commands.is_empty()),
(
"tool",
entry.inventory.native > 0 || entry.inventory.lsp > 0,
),
("profile", !self.profiles.is_empty()),
]
.into_iter()
.filter_map(|(label, present)| present.then_some(label))
.collect()
}
}
fn row_value<T: Serialize>(row: &T) -> Value {
serde_json::to_value(row).unwrap_or(Value::Null)
}
fn note_failure(unavailable: &mut Vec<IndexNote>, kind: &'static str, error: &ApiError) {
unavailable.push(IndexNote {
kind,
message: error.message.clone(),
});
}
fn plugin_source(origin: &str) -> &'static str {
match origin {
"codewhale-builtin" => "built_in",
"workspace-codewhale" => "project",
_ => "user",
}
}
fn plugin_state(entry: &PluginSummaryEntry) -> &'static str {
if matches!(
entry.trust_status,
"content-changed" | "capabilities-changed"
) || entry.state == "enabled-untrusted"
{
return "needs_review";
}
match entry.state {
"active" => "enabled",
"disabled" => "disabled",
_ => "unavailable",
}
}
fn add_stem(names: &mut BTreeSet<String>, path: &FsPath, extension: Option<&str>, lowercase: bool) {
if let Some(wanted) = extension
&& path.extension().and_then(|value| value.to_str()) != Some(wanted)
{
return;
}
if let Some(stem) = path.file_stem().and_then(|value| value.to_str()) {
names.insert(if lowercase {
stem.to_lowercase()
} else {
stem.to_string()
});
}
}
async fn component_names(
paths: &[PathBuf],
extension: Option<&str>,
lowercase: bool,
omitted: &mut bool,
) -> Vec<String> {
let mut names = BTreeSet::new();
for path in paths {
let Ok(meta) = tokio::fs::symlink_metadata(path).await else {
continue;
};
if meta.is_file() {
add_stem(&mut names, path, extension, lowercase);
} else if meta.is_dir()
&& let Ok(mut entries) = tokio::fs::read_dir(path).await
{
let mut seen = 0;
while let Ok(Some(entry)) = entries.next_entry().await {
seen += 1;
if seen > MAX_ADDED_NAMES {
*omitted = true;
break;
}
if entry.file_type().await.is_ok_and(|kind| kind.is_file()) {
add_stem(&mut names, &entry.path(), extension, lowercase);
}
}
}
if names.len() >= MAX_ADDED_NAMES {
*omitted = true;
break;
}
}
if names.iter().any(|name| name.chars().any(char::is_control)) {
*omitted = true;
names.retain(|name| !name.chars().any(char::is_control));
}
names.into_iter().take(MAX_ADDED_NAMES).collect()
}
async fn plugin_adds(
plugin: &codewhale_plugins::types::LoadedPlugin,
omitted: &mut bool,
) -> PluginAdds {
let mut skills: Vec<String> = plugin
.skill_snapshots
.iter()
.map(|skill| skill.name.clone())
.collect();
skills.sort();
let mut connectors: Vec<String> = plugin
.manifest
.mcp_servers
.as_ref()
.map(|servers| servers.keys().cloned().collect())
.unwrap_or_default();
connectors.sort();
PluginAdds {
skills,
connectors,
hooks: component_names(&plugin.components.hooks, None, false, omitted).await,
commands: component_names(&plugin.components.commands, Some("md"), true, omitted).await,
profiles: component_names(&plugin.components.agents, Some("toml"), false, omitted).await,
}
}
async fn plugin_adds_by_id(
state: &RuntimeApiState,
omitted: &mut BTreeSet<String>,
) -> HashMap<String, PluginAdds> {
let registry = state
.plugin_discovery
.registry_for_workspace(&state.workspace);
let mut adds = HashMap::new();
for plugin in registry.list() {
let mut cut = false;
adds.insert(
plugin.id.as_str().to_string(),
plugin_adds(plugin, &mut cut).await,
);
if cut {
omitted.insert(plugin.name().to_string());
}
}
adds
}
fn skill_source(
path: Option<&PathBuf>,
is_bundled: bool,
skills_dir: &FsPath,
workspace: &FsPath,
) -> &'static str {
if is_bundled {
return "built_in";
}
match path {
Some(path) if path.starts_with(skills_dir) => "user",
Some(path) if path.starts_with(workspace) => "project",
Some(_) => "other_app",
None => "plugin",
}
}
pub(super) async fn get_plugin_index(
State(state): State<RuntimeApiState>,
) -> Result<Json<PluginIndexResponse>, ApiError> {
let mut items = Vec::new();
let mut unavailable = Vec::new();
let mut problems = Vec::new();
let commands = super::list_commands(
State(state.clone()),
Query(CommandsQuery { thread_id: None }),
)
.await;
let mut plugin_commands: HashMap<String, BTreeSet<String>> = HashMap::new();
if let Ok(Json(response)) = &commands {
for command in &response.commands {
if let (Some(plugin_id), "user") = (command.plugin_id.as_ref(), command.kind) {
plugin_commands
.entry(plugin_id.clone())
.or_default()
.insert(command.name.clone());
}
}
}
match plugins::list_plugins(State(state.clone())).await {
Ok(Json(response)) => {
let mut omitted = BTreeSet::new();
let mut adds_by_id = plugin_adds_by_id(&state, &mut omitted).await;
for name in &omitted {
problems.push(IndexNote {
kind: "plugin",
message: format!(
"plugin '{name}' adds more files than are listed, or a file name with control characters; some names are left out of `adds`"
),
});
}
for diagnostic in &response.registry_diagnostics {
problems.push(IndexNote {
kind: "plugin",
message: diagnostic.message.clone(),
});
}
for entry in &response.plugins {
let mut adds = adds_by_id.remove(&entry.id);
if entry.state == "active"
&& commands.is_ok()
&& let Some(adds) = adds.as_mut()
{
adds.commands = plugin_commands
.remove(&entry.id)
.map(|names| names.into_iter().collect())
.unwrap_or_default();
}
items.push(IndexItem {
id: format!("plugin:{}", entry.id),
kind: "plugin",
name: entry
.display_name
.clone()
.unwrap_or_else(|| entry.name.clone()),
description: entry.description.clone(),
source: plugin_source(entry.origin),
state: plugin_state(entry),
labels: adds
.as_ref()
.map(|adds| adds.labels(entry))
.unwrap_or_default(),
plugin: None,
adds,
tier: None,
owner: "/v1/apps/plugins",
row: row_value(entry),
});
}
}
Err(error) => note_failure(&mut unavailable, "plugin", &error),
}
match plugins::list_plugin_catalog(State(state.clone())).await {
Ok(Json(catalog)) => {
for (tier, rows) in [
("builtin", &catalog.builtin),
("experimental", &catalog.experimental),
("more", &catalog.more),
] {
for row in rows {
items.push(IndexItem {
id: format!("plugin:catalog:{}", row.id),
kind: "plugin",
name: row.title.clone(),
description: row.description.clone(),
source: "built_in",
state: if !row.available {
"unavailable"
} else if row.enabled {
"enabled"
} else {
"disabled"
},
labels: Vec::new(),
plugin: None,
adds: None,
tier: Some(tier),
owner: "/v1/apps/plugins/catalog",
row: row_value(row),
});
}
}
}
Err(error) => note_failure(&mut unavailable, "plugin", &error),
}
match super::list_skills(State(state.clone())).await {
Ok(Json(response)) => {
for warning in &response.warnings {
problems.push(IndexNote {
kind: "skill",
message: warning.clone(),
});
}
for skill in &response.skills {
if skill.plugin_id.is_some() {
continue;
}
items.push(IndexItem {
id: format!("skill:{}", skill.name),
kind: "skill",
name: skill.name.clone(),
description: Some(skill.description.clone()),
source: skill_source(
skill.path.as_ref(),
skill.is_bundled,
&response.directory,
&state.workspace,
),
state: if skill.enabled { "enabled" } else { "disabled" },
labels: vec!["skill"],
plugin: None,
adds: None,
tier: None,
owner: "/v1/skills",
row: row_value(skill),
});
}
}
Err(error) => note_failure(&mut unavailable, "skill", &error),
}
match super::list_mcp_servers(State(state.clone())).await {
Ok(Json(response)) => {
for server in &response.servers {
let source = match server.origin {
"plugin" => continue,
"project" => "project",
"runtime" => "runtime",
_ => "user",
};
items.push(IndexItem {
id: format!("connector:{}", server.name),
kind: "connector",
name: server.name.clone(),
description: None,
source,
state: if !server.enabled {
"disabled"
} else if server.auth_required {
"needs_sign_in"
} else {
"enabled"
},
labels: vec!["connector"],
plugin: None,
adds: None,
tier: None,
owner: "/v1/apps/mcp/servers",
row: row_value(server),
});
}
}
Err(error) => note_failure(&mut unavailable, "connector", &error),
}
match super::list_hooks(State(state.clone()), Query(HooksQuery { thread_id: None })).await {
Ok(Json(response)) => {
for problem in &response.problems {
problems.push(IndexNote {
kind: "hook",
message: problem.clone(),
});
}
let mut ordinals: HashMap<&'static str, usize> = HashMap::new();
for hook in &response.hooks {
let ordinal = ordinals.entry(hook.event).or_default();
*ordinal += 1;
let source = match hook.source {
"plugin" => continue,
"project" => "project",
_ => "user",
};
let name = hook
.name
.clone()
.unwrap_or_else(|| format!("{} {}", hook.event, ordinal));
items.push(IndexItem {
id: format!("hook:{source}:{}:{name}", hook.event),
kind: "hook",
name,
description: None,
source,
state: if response.enabled {
"enabled"
} else {
"disabled"
},
labels: vec!["hook"],
plugin: None,
adds: None,
tier: None,
owner: "/v1/hooks",
row: row_value(hook),
});
}
}
Err(error) => note_failure(&mut unavailable, "hook", &error),
}
match commands {
Ok(Json(response)) => {
for command in &response.commands {
let (source, plugin) = match (command.kind, command.plugin_id.as_ref()) {
("builtin", _) => continue,
("user", Some(_)) => continue,
("extension", plugin_id) => ("plugin", plugin_id.cloned()),
_ if command.workspace_sourced => ("project", None),
_ => ("user", None),
};
items.push(IndexItem {
id: format!("command:{}", command.name),
kind: "command",
name: command.name.clone(),
description: command.summary.clone(),
source,
state: "enabled",
labels: vec!["command"],
plugin,
adds: None,
tier: None,
owner: "/v1/commands",
row: row_value(command),
});
}
}
Err(error) => note_failure(&mut unavailable, "command", &error),
}
Ok(Json(PluginIndexResponse {
workspace: state.workspace.display().to_string(),
items,
unavailable,
problems,
}))
}
@@ -15,7 +15,6 @@ use tokio::sync::{Mutex, mpsc, oneshot};
use tokio::time::sleep;
use uuid::Uuid;
mod catalog_parity;
mod command_catalog;
#[cfg(unix)]
mod control_socket_selection;
@@ -29,7 +28,6 @@ mod headless_catalog;
mod member_operations;
#[cfg(unix)]
mod owner_bootstrap;
mod plugin_index;
mod plugin_ui;
mod profile_constitution;
#[cfg(any(unix, windows))]
@@ -1,600 +0,0 @@
use super::*;
use codewhale_config::catalog::{
CatalogOffering, CatalogSource, CatalogStatus, ProviderCatalogDelta, base_url_fingerprint,
now_unix,
};
use codewhale_engine::cost_status::EffectiveRouteEnvelope;
use codewhale_engine::model_inventory::ModelInventory;
use codewhale_runtime::config::ProviderIdentity;
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
const SINGULAR: &str = "model list / model resolve";
const PLURAL: &str = "models";
const RUNTIME: &str = "runtime api";
const ROUTING: &str = "routing inventory";
const COST: &str = "cost";
const UNKNOWN: &str = "unknown";
const ROSTER: &[&str] = &["claude-opus-5", "claude-haiku-4-5", "claude-parity-probe-1"];
type Readings = BTreeMap<(String, String, &'static str), BTreeMap<&'static str, String>>;
struct Route {
label: &'static str,
api_path: &'static str,
identity: ProviderIdentity,
base_url: String,
models: &'static [&'static str],
}
fn cold_process() {
codewhale_engine::provider_catalog_live::reset_cache_for_test();
codewhale_engine::provider_lake::clear_live_snapshot();
}
fn known<T: ToString>(value: Option<T>) -> String {
value.map_or_else(|| UNKNOWN.to_string(), |value| value.to_string())
}
fn rate(value: &Value) -> String {
let number = match value {
Value::Number(number) => number.as_f64(),
Value::String(text) => text.parse::<f64>().ok(),
_ => None,
};
known(number)
}
fn roster(mut models: Vec<String>) -> String {
models.sort();
models.dedup();
models.join(",")
}
fn read(
readings: &mut Readings,
route: &Route,
model: &str,
field: &'static str,
surface: &'static str,
value: String,
) {
readings
.entry((route.label.to_string(), model.to_string(), field))
.or_default()
.insert(surface, value);
}
fn persist_id_only_roster(identity: &ProviderIdentity, base_url: &str) {
let fingerprint = base_url_fingerprint(base_url);
let fetched_at = now_unix();
let ticket = codewhale_engine::provider_catalog_live::begin_refresh_for_identity(
identity.provider,
identity.key.as_str(),
base_url,
);
assert_eq!(
codewhale_engine::provider_catalog_live::record_success_if_current(
&ticket,
ProviderCatalogDelta {
provider: identity.key.to_string(),
base_url_fingerprint: fingerprint.clone(),
fetched_at,
offerings: ROSTER
.iter()
.map(|id| CatalogOffering {
provider: identity.key.to_string(),
wire_model_id: (*id).to_string(),
endpoint_key: "chat".into(),
source: CatalogSource::Live {
base_url_fingerprint: fingerprint.clone(),
fetched_at,
},
..Default::default()
})
.collect(),
},
),
Some(CatalogStatus::Fresh)
);
}
fn read_singular(readings: &mut Readings, config_path: &Path, route: &Route) -> Result<()> {
cold_process();
let store = codewhale_config::ConfigStore::load(Some(config_path.to_path_buf()))?;
let listed =
codewhale_engine::route_preferences::list_models(&store, Some(route.identity.provider))?
.into_iter()
.find(|(listed, _)| listed == route.identity.key.as_str())
.map(|(_, models)| roster(models));
read(
readings,
route,
"*",
"roster",
SINGULAR,
listed.unwrap_or_else(|| "route not listed".into()),
);
for model in route.models {
let limits = codewhale_engine::route_preferences::resolve_model(
config_path,
route.identity.key.as_str(),
&route.base_url,
Some(model),
)
.map(|candidate| candidate.limits());
let (context, output) = match limits {
Ok(limits) => (known(limits.context_tokens), known(limits.output_tokens)),
Err(error) => (format!("refused: {error}"), format!("refused: {error}")),
};
read(readings, route, model, "limit.context", SINGULAR, context);
read(readings, route, model, "limit.output", SINGULAR, output);
}
Ok(())
}
fn read_plural(readings: &mut Readings, config: &Config, route: &Route) -> Result<()> {
cold_process();
codewhale_engine::models_dev_live::maybe_load_persisted_cache();
let mut route_config = config.clone();
route_config
.scope_to_provider_identity(&route.identity)
.map_err(anyhow::Error::msg)?;
read(
readings,
route,
"*",
"roster",
PLURAL,
roster(codewhale_engine::provider_lake::listed_models_for_route(
&route_config,
&route.identity,
)),
);
Ok(())
}
async fn read_runtime(
readings: &mut Readings,
client: &reqwest::Client,
addr: &SocketAddr,
route: &Route,
) -> Result<()> {
let body: Value = client
.get(format!("http://{addr}/v1/providers/{}", route.api_path))
.send()
.await?
.error_for_status()?
.json()
.await?;
let models = body["models"].as_array().context("models array")?;
read(
readings,
route,
"*",
"roster",
RUNTIME,
roster(
models
.iter()
.filter_map(|model| model["id"].as_str().map(str::to_string))
.collect(),
),
);
for model in route.models {
let facts = models
.iter()
.find(|entry| entry["id"] == *model)
.map_or(&Value::Null, |entry| &entry["facts"]);
read(
readings,
route,
model,
"limit.context",
RUNTIME,
known(facts["effective_limits"]["context_tokens"].as_u64()),
);
read(
readings,
route,
model,
"limit.output",
RUNTIME,
known(facts["effective_limits"]["output_tokens"].as_u64()),
);
read(
readings,
route,
model,
"window",
RUNTIME,
known(facts["effective_context"]["tokens"].as_u64()),
);
read(
readings,
route,
model,
"window.source",
RUNTIME,
known(facts["effective_context"]["source"].as_str()),
);
read(
readings,
route,
model,
"price.input",
RUNTIME,
rate(&facts["pricing"]["input_per_million"]),
);
read(
readings,
route,
model,
"price.output",
RUNTIME,
rate(&facts["pricing"]["output_per_million"]),
);
read(
readings,
route,
model,
"price.basis",
RUNTIME,
format!(
"provenance={} status={} source={}",
facts["pricing"]["provenance"], facts["pricing_status"], facts["source"]
),
);
}
Ok(())
}
fn read_routing(readings: &mut Readings, config: &Config, route: &Route) -> Result<()> {
let inventory = ModelInventory::from_config(config).map_err(anyhow::Error::msg)?;
let candidates: Vec<_> = inventory
.candidates
.iter()
.filter(|candidate| candidate.identity == route.identity)
.collect();
read(
readings,
route,
"*",
"roster",
ROUTING,
roster(
candidates
.iter()
.map(|candidate| candidate.model.clone())
.collect(),
),
);
for model in route.models {
let candidate = candidates
.iter()
.find(|candidate| candidate.model == *model);
read(
readings,
route,
model,
"window",
ROUTING,
known(candidate.map(|candidate| candidate.context_window)),
);
read(
readings,
route,
model,
"window.source",
ROUTING,
known(candidate.map(|candidate| {
if candidate.context_window_unverified {
"unverified"
} else {
"verified"
}
})),
);
read(
readings,
route,
model,
"limit.output",
ROUTING,
known(candidate.and_then(|candidate| candidate.max_output)),
);
}
Ok(())
}
fn read_cost(readings: &mut Readings, config: &Config, route: &Route) {
let usage = Usage {
input_tokens: 100_000,
output_tokens: 10_000,
..Default::default()
};
for model in route.models {
let envelope = EffectiveRouteEnvelope::from_admitted(
Some(config),
&route.identity,
*model,
Some(&route.base_url),
chrono::Utc::now(),
);
let quote = envelope.provider_live_pricing.as_ref();
let audit = envelope.audit(&usage);
read(
readings,
route,
model,
"price.input",
COST,
quote
.and_then(|quote| quote.input_per_million.as_deref())
.map_or_else(|| UNKNOWN.to_string(), |text| rate(&json!(text))),
);
read(
readings,
route,
model,
"price.output",
COST,
quote
.and_then(|quote| quote.output_per_million.as_deref())
.map_or_else(|| UNKNOWN.to_string(), |text| rate(&json!(text))),
);
read(
readings,
route,
model,
"price.basis",
COST,
format!(
"provenance={:?} billing={:?} charge_usd={} unpriced={:?}",
quote.map(|quote| quote.provenance.clone()),
envelope.billing_mode,
known(audit.exact_usd.as_deref()),
audit.unpriced_reason,
),
);
}
}
#[tokio::test]
async fn one_captured_catalog_reads_the_same_on_every_model_surface() -> Result<()> {
let _env = lock_test_env();
let _live = codewhale_engine::provider_lake::lock_live_snapshot();
let root = tempfile::tempdir()?;
let captured = codewhale_config::catalog::BUNDLED_MODELS_DEV_JSON;
let catalog_path = root.path().join("captured-models-dev.json");
fs::write(&catalog_path, captured)?;
let _catalog = EnvVarGuard::set("CODEWHALE_MODELS_DEV_PATH", &catalog_path);
let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path());
let _config_path = EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
let _legacy_config_path = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
let _cli = EnvVarGuard::remove(codewhale_config::CLI_API_KEY_ENV);
cold_process();
let config_path = root.path().join("config.toml");
fs::write(
&config_path,
r#"provider = "anthropic"
[providers.anthropic]
api_key = "parity-fixture-key"
model = "claude-opus-5"
[providers.zai]
api_key = "parity-fixture-key"
model = "GLM-5.3"
[providers.lab]
kind = "openai-compatible"
base_url = "http://127.0.0.1:9/v1"
api_key = "parity-fixture-key"
model = "claude-haiku-4-5"
"#,
)?;
let config = Config::load(Some(config_path.clone()), None)?;
let route = |label: &'static str,
api_path: &'static str,
key: &str,
models: &'static [&'static str]|
-> Result<Route> {
let identity = config
.resolve_provider_pin_identity(key)
.map_err(anyhow::Error::msg)?;
Ok(Route {
label,
api_path,
base_url: config.base_url_for_route(&identity),
identity,
models,
})
};
let routes = [
route(
"anthropic (official, id-only roster)",
"anthropic/models",
"anthropic",
ROSTER,
)?,
route(
"lab (custom endpoint, same spelling)",
"custom/models?model_provider_id=lab",
"lab",
&["claude-haiku-4-5"],
)?,
route(
"zai (plan endpoint)",
"zai/models",
"zai",
&["GLM-5.3", "GLM-5.2"],
)?,
];
assert!(
codewhale_engine::models_dev_live::refresh(false)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?
> 0
);
persist_id_only_roster(&routes[0].identity, &routes[0].base_url);
let mut readings = Readings::new();
for route in &routes {
read_singular(&mut readings, &config_path, route)?;
read_plural(&mut readings, &config, route)?;
}
cold_process();
let (addr, _, handle) = spawn_test_server_with_config_path(config_path.clone())
.await?
.expect("loopback server required");
let client = codewhale_release::tls::reqwest_client();
for route in &routes {
read_runtime(&mut readings, &client, &addr, route).await?;
read_routing(&mut readings, &config, route)?;
read_cost(&mut readings, &config, route);
}
handle.abort();
cold_process();
println!(
"PARITY captured_catalog sha256={} bytes={}",
Sha256::digest(captured.as_bytes())
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>(),
captured.len()
);
let mut differing = Vec::new();
let mut known_differences: Vec<String> = Vec::new();
for ((route, model, field), surfaces) in &readings {
let mut values: Vec<String> = surfaces
.values()
.map(|value| {
if *field == "roster" {
roster(
value
.to_ascii_lowercase()
.split(',')
.map(str::to_string)
.collect(),
)
} else {
value.clone()
}
})
.collect();
values.sort();
values.dedup();
let compared = !matches!(*field, "price.basis" | "window.source");
let borrowed_window = *field == "window"
&& surfaces.get(RUNTIME).map(String::as_str) == Some(UNKNOWN)
&& readings
.get(&(route.clone(), model.clone(), "window.source"))
.and_then(|sources| sources.get(ROUTING))
.map(String::as_str)
== Some("unverified");
let verdict = if !compared {
"info"
} else if borrowed_window {
known_differences.push(format!("{route} | {model} | {surfaces:?}"));
"KNOWN-DIFFERENCE"
} else if values.len() == 1 {
"agree"
} else {
differing.push(format!("{route} | {model} | {field} | {surfaces:?}"));
"DIFFER"
};
println!("PARITY {verdict} | {route} | {model} | {field} | {surfaces:?}");
}
let value = |route: &Route, model: &str, field: &'static str, surface: &'static str| {
readings
.get(&(route.label.to_string(), model.to_string(), field))
.and_then(|surfaces| surfaces.get(surface))
.cloned()
.unwrap_or_default()
};
assert_eq!(
value(&routes[0], "*", "roster", SINGULAR),
"claude-haiku-4-5,claude-opus-5,claude-parity-probe-1",
"the singular command lists the route's own roster, not every bundled row"
);
assert_eq!(
value(&routes[0], "claude-opus-5", "limit.context", RUNTIME),
"1000000"
);
assert_eq!(
value(&routes[0], "claude-haiku-4-5", "limit.context", RUNTIME),
"200000"
);
assert_eq!(value(&routes[0], "claude-opus-5", "price.input", COST), "5");
assert_eq!(
value(&routes[0], "claude-haiku-4-5", "price.output", COST),
"5"
);
for field in ["limit.context", "limit.output", "price.input"] {
assert_eq!(
value(&routes[1], "claude-haiku-4-5", field, RUNTIME),
UNKNOWN,
"a custom endpoint borrows no fact from a same-named model: {field}"
);
}
assert_eq!(
value(&routes[1], "claude-haiku-4-5", "window.source", ROUTING),
"unverified",
"routing never presents a same-named model's window as this endpoint's"
);
assert_eq!(
value(&routes[1], "claude-haiku-4-5", "limit.output", ROUTING),
UNKNOWN
);
for model in ["GLM-5.3", "GLM-5.2"] {
let basis = value(&routes[2], model, "price.basis", COST);
assert!(
basis.contains("NotMoneyMetered") && basis.contains("charge_usd=unknown"),
"a plan route is never billed per token: {model}: {basis}"
);
}
for (model, rate) in [("GLM-5.3", UNKNOWN), ("GLM-5.2", "1.4")] {
for surface in [COST, RUNTIME] {
assert_eq!(
value(&routes[2], model, "price.input", surface),
rate,
"the list rate is shown the same everywhere and never turned into a charge: {model} {surface}"
);
}
}
let basis = value(&routes[1], "claude-haiku-4-5", "price.basis", COST);
assert!(
basis.contains("UnknownBillingBasis") && basis.contains("charge_usd=unknown"),
"a custom endpoint is never given a charge: {basis}"
);
for surface in [COST, RUNTIME] {
assert_eq!(
value(&routes[0], "claude-parity-probe-1", "price.input", surface),
UNKNOWN,
"a metered first-party model with no price row reads unknown: {surface}"
);
}
let basis = value(&routes[0], "claude-parity-probe-1", "price.basis", COST);
assert!(
basis.contains("charge_usd=unknown") && !basis.contains("unpriced=None"),
"an unpriced model produces no charge and says why: {basis}"
);
assert_eq!(
known_differences,
vec![
"lab (custom endpoint, same spelling) | claude-haiku-4-5 | {\"routing inventory\": \"200000\", \"runtime api\": \"unknown\"}"
],
"the only named difference is the custom endpoint's borrowed routing window"
);
assert!(
differing.is_empty(),
"surfaces disagree on the same captured catalog:\n{}",
differing.join("\n")
);
Ok(())
}
@@ -28,8 +28,6 @@ fn user(name: &str) -> UserCommand {
takes_arguments: false,
hidden: false,
extension: false,
plugin_id: None,
workspace_sourced: false,
builtin_bindings: vec![name.to_string()],
}
}
@@ -1,139 +0,0 @@
use super::*;
use codewhale_engine::command_catalog::{BuiltinCommand, CommandCatalog, UserCommand};
struct Catalog(Arc<std::sync::Mutex<Option<String>>>);
impl CommandCatalog for Catalog {
fn answers_to(&self, _name: &str) -> bool {
false
}
fn builtins(&self) -> Vec<BuiltinCommand> {
Vec::new()
}
fn user_commands(&self, _workspace: &Path) -> Vec<UserCommand> {
let plugin_id = self.0.lock().expect("plugin id").clone();
let command = |name: &str, plugin: Option<String>| UserCommand {
name: name.to_string(),
aliases: Vec::new(),
description: Some("A command.".to_string()),
usage: None,
takes_arguments: false,
hidden: false,
extension: false,
plugin_id: plugin,
workspace_sourced: false,
builtin_bindings: Vec::new(),
};
vec![command("mine", None), command("ship", plugin_id)]
}
}
#[tokio::test]
async fn the_index_lists_every_kind_once_and_a_plugin_carries_what_it_adds() -> Result<()> {
let _env = lock_test_env();
let temp = tempfile::tempdir()?;
let root = temp.path().join("index-route");
let workspace = root.join("workspace");
fs::create_dir_all(&workspace)?;
let plugin_id = Arc::new(std::sync::Mutex::new(None));
let _catalog = codewhale_engine::command_catalog::CatalogGuard::install(Arc::new(Catalog(
plugin_id.clone(),
)));
let sources = root.join("sources");
fs::create_dir_all(&sources)?;
let bundle = write_plugin_source_bundle(&sources, "indexed")?;
fs::create_dir_all(bundle.join("commands"))?;
fs::write(bundle.join("commands/ship.md"), "Ship it.\n")?;
fs::create_dir_all(bundle.join("agents"))?;
fs::write(bundle.join("agents/reviewer.toml"), "name = \"reviewer\"\n")?;
fs::write(
bundle.join("plugin.toml"),
"schema_version = 1\n[plugin]\nname = \"indexed\"\nversion = \"1.0.0\"\n\
description = \"test bundle\"\n\n[skills]\npath = \"skills\"\n\
[commands]\npath = \"commands\"\n[agents]\npath = \"agents\"\n",
)?;
let Some((addr, handle)) = spawn_plugin_api_server(root, workspace).await? else {
return Ok(());
};
let client = codewhale_release::tls::reqwest_client();
plugin_ui::install_active(
&client,
&format!("http://{addr}/v1/apps/plugins"),
bundle.display().to_string(),
)
.await?;
let listed: Value = client
.get(format!("http://{addr}/v1/apps/plugins"))
.send()
.await?
.error_for_status()?
.json()
.await?;
let id = listed["plugins"]
.as_array()
.and_then(|plugins| plugins.iter().find(|plugin| plugin["name"] == "indexed"))
.and_then(|plugin| plugin["id"].as_str())
.expect("installed plugin id")
.to_string();
*plugin_id.lock().expect("plugin id") = Some(id.clone());
let commands: Value = client
.get(format!("http://{addr}/v1/commands"))
.send()
.await?
.error_for_status()?
.json()
.await?;
let ship = commands["commands"]
.as_array()
.and_then(|rows| rows.iter().find(|row| row["name"] == "ship"))
.expect("ship row");
assert_eq!(ship["plugin_id"], id.as_str());
let index: Value = client
.get(format!("http://{addr}/v1/apps/plugin-index"))
.send()
.await?
.error_for_status()?
.json()
.await?;
let items = index["items"].as_array().expect("items");
let find = |id: &str| items.iter().find(|item| item["id"] == id);
let plugin = items
.iter()
.find(|item| item["kind"] == "plugin" && item["row"]["name"] == "indexed")
.unwrap_or_else(|| panic!("plugin row in {index}"));
assert_eq!(plugin["kind"], "plugin");
assert_eq!(plugin["state"], "enabled");
assert_eq!(plugin["adds"]["skills"], serde_json::json!(["greet"]));
assert_eq!(plugin["adds"]["commands"], serde_json::json!(["ship"]));
assert_eq!(plugin["adds"]["profiles"], serde_json::json!(["reviewer"]));
assert_eq!(
plugin["labels"],
serde_json::json!(["skill", "command", "profile"])
);
assert_eq!(plugin["row"]["name"], "indexed");
assert!(find("skill:greet").is_none());
assert!(find("command:ship").is_none());
let mine = find("command:mine").expect("a hand-written command is a row");
assert_eq!(mine["source"], "user");
assert_eq!(mine["labels"], serde_json::json!(["command"]));
assert!(
items
.iter()
.any(|item| item["kind"] == "plugin" && item["tier"] == "builtin"),
"built-in switches are rows"
);
assert_eq!(
index["unavailable"].as_array().map(Vec::len),
Some(0),
"{index}"
);
handle.abort();
Ok(())
}
@@ -79,7 +79,7 @@ fn write_native_ui_bundle(dir: &Path, name: &str) -> Result<PathBuf> {
Ok(bundle)
}
pub(super) async fn install_active(
async fn install_active(
client: &reqwest::Client,
base: &str,
source: String,
+2 -345
View File
@@ -3,13 +3,13 @@ mod acp_support;
use acp_support::{Client, fixture_config, text_of, updates};
use anyhow::{Result, anyhow};
use codewhale_app_server::acp_server::capture_frontend;
use codewhale_engine::llm_client::mock::{CannedTurn, MockLlmClient, canned};
use codewhale_engine::llm_client::mock::{MockLlmClient, canned};
use codewhale_engine::runtime_threads::RuntimeTurnStatus;
use codewhale_engine::runtime_threads::manager::{
RuntimeThreadManager, RuntimeThreadManagerConfig,
};
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncWriteExt, BufReader};
@@ -497,346 +497,3 @@ async fn extension_journey() -> Result<()> {
host.shutdown().await;
Ok(())
}
const GATES_MCP_FIXTURE: &str = r#"import readline from 'node:readline';
readline.createInterface({input:process.stdin}).on('line', line => {
const r = JSON.parse(line); if (r.id === undefined) return;
let result = {};
if (r.method === 'initialize') result = {protocolVersion:'2024-11-05',capabilities:{tools:{}},serverInfo:{name:'fixture',version:'1'}};
if (r.method === 'tools/list') result = {tools:['echo','hidden'].map(name => ({name,description:'Echo text',inputSchema:{type:'object',properties:{text:{type:'string'}},required:['text']}}))};
if (r.method === 'tools/call') result = {content:[{type:'text',text:'mcp-ran:' + r.params.name + ':' + r.params.arguments.text}]};
process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:r.id,result}) + '\n');
});
"#;
struct Outcome {
response: Value,
frames: Vec<Value>,
asked: Vec<Value>,
}
impl Outcome {
fn rendered(&self) -> String {
serde_json::to_string(&self.frames).unwrap_or_default()
}
fn completed(&self) -> usize {
updates(&self.frames, "tool_call_update")
.iter()
.filter(|frame| frame.pointer("/params/update/status") == Some(&json!("completed")))
.count()
}
fn titles(&self) -> Vec<String> {
self.asked
.iter()
.map(|frame| {
frame["params"]["toolCall"]["title"]
.as_str()
.unwrap_or_default()
.to_string()
})
.collect()
}
fn announced_ids(&self) -> Vec<String> {
updates(&self.frames, "tool_call")
.iter()
.filter_map(|frame| frame.pointer("/params/update/toolCallId")?.as_str())
.map(str::to_string)
.collect()
}
}
async fn run_prompt(
client: &mut Client,
session: &str,
allow: &dyn Fn(usize) -> bool,
) -> Result<Outcome> {
client.next_id += 1;
let prompt_id = client.next_id;
client
.send(
json!({"jsonrpc":"2.0","id":prompt_id,"method":"session/prompt","params":{
"sessionId":session,"prompt":[{"type":"text","text":"go"}]}}),
)
.await?;
let mut frames = Vec::new();
let mut asked = Vec::new();
let response = loop {
let frame = client.recv().await?;
if frame["method"] == "session/request_permission" {
let option = if allow(asked.len()) {
"allow-once"
} else {
"reject-once"
};
client
.send(json!({"jsonrpc":"2.0","id":frame["id"],
"result":{"outcome":{"outcome":"selected","optionId":option}}}))
.await?;
asked.push(frame);
} else if frame["id"] == json!(prompt_id) && frame.get("method").is_none() {
break frame;
} else {
frames.push(frame);
}
};
Ok(Outcome {
response,
frames,
asked,
})
}
async fn new_session(
client: &mut Client,
manager: &RuntimeThreadManager,
workspace: &Path,
turns: Vec<CannedTurn>,
plan: bool,
) -> Result<String> {
manager.set_test_model_client(Arc::new(MockLlmClient::new(turns)));
let (created, _) = client
.request("session/new", json!({"cwd":workspace}))
.await?;
let session = created["result"]["sessionId"]
.as_str()
.ok_or_else(|| anyhow!("session/new returned no sessionId: {created}"))?
.to_string();
if plan {
let (set, _) = client
.request(
"session/set_mode",
json!({"sessionId":session,"modeId":"plan"}),
)
.await?;
assert!(set.get("error").is_none(), "{set}");
}
Ok(session)
}
#[test]
fn extension_tools_keep_the_permission_boundary_in_an_acp_session() -> Result<()> {
std::thread::Builder::new()
.stack_size(64 << 20)
.spawn(|| {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?
.block_on(async {
tokio::time::timeout(Duration::from_secs(180), gates())
.await
.map_err(|_| anyhow!("extension gates exceeded 180s"))?
})
})?
.join()
.map_err(|_| anyhow!("extension gates thread panicked"))?
}
async fn gates() -> Result<()> {
use codewhale_engine::extension_host::test_support::{FixturePlugins, node_for_tests};
let Some(node) = node_for_tests("extension_tools_keep_the_permission_boundary") else {
return Ok(());
};
let _policy = codewhale_plugins::activation::TestPolicyGuard::extension_host(true);
let fixture = FixturePlugins::new(&["plugin-context", "core-call"]).await;
let host = fixture.manager(node.clone());
let _host = codewhale_engine::extension_host::TestManagerGuard::install(Arc::clone(&host));
let dir = tempfile::tempdir()?;
let _home = codewhale_engine::test_support::SealedHome::at(dir.path());
let workspace = fixture.workspace().to_path_buf();
std::fs::write(workspace.join("seed.txt"), "seed-content-xyz")?;
let server = dir.path().join("fixture-mcp.mjs");
std::fs::write(&server, GATES_MCP_FIXTURE)?;
let mcp_config = dir.path().join("mcp.json");
std::fs::write(
&mcp_config,
serde_json::to_vec(&json!({"servers":{
"fixture":{"command":node,"args":[server],"disabled_tools":["hidden"]},
"off":{"command":node,"args":[server],"disabled":true}
}}))?,
)?;
let runtime_dir = dir.path().join("runtime");
let mut config = fixture_config();
config.set_feature("mcp", true).unwrap();
config.set_feature("extension_host", true).unwrap();
config.mcp_config_path = Some(mcp_config.to_string_lossy().into_owned());
let manager = Arc::new(RuntimeThreadManager::open_host(
config.clone(),
workspace.clone(),
RuntimeThreadManagerConfig {
mcp_auth_context: Default::default(),
data_dir: runtime_dir.clone(),
task_data_dir: runtime_dir,
sessions_dir: None,
max_active_threads: 16,
},
fixture.registry(),
codewhale_engine::core::engine::EngineHostProfile::Acp,
None,
None,
)?);
let frontend = capture_frontend(
config,
"fixture-model".into(),
workspace.clone(),
Arc::clone(&manager),
codewhale_engine::session_manager::default_sessions_dir()?,
None,
None,
)?;
let (mut client, agent_read, agent_write) = Client::connect(false);
let connection = frontend.serve(Box::new(BufReader::new(agent_read)), Box::new(agent_write));
let manager_for_script = Arc::clone(&manager);
let script =
async {
let manager = manager_for_script;
let result = async {
client
.request(
"initialize",
json!({"protocolVersion":1,"clientCapabilities":{}}),
)
.await?;
let session = new_session(
&mut client,
&manager,
&workspace,
vec![
canned::tool_call_turn(
"n1",
"cc_call",
r#"{"name":"write","input":{"path":"nested.txt","content":"via-plugin"}}"#,
),
canned::simple_text_turn("nested allowed"),
],
false,
)
.await?;
let allowed = run_prompt(&mut client, &session, &|_| true).await?;
assert!(allowed.response.get("error").is_none(), "{}", allowed.response);
assert_eq!(allowed.response["result"]["stopReason"], "end_turn");
assert_eq!(allowed.asked.len(), 2, "{:?}", allowed.titles());
assert!(allowed.titles()[0].starts_with("cc_call"));
let announced = allowed.announced_ids();
for request in &allowed.asked {
let id = request["params"]["toolCall"]["toolCallId"]
.as_str()
.unwrap_or_default();
assert!(
announced.iter().any(|known| known == id),
"a nested approval is shown on a tool call the editor knows: {id} {announced:?}"
);
}
let nested = &allowed.asked[1]["params"]["toolCall"];
assert_eq!(nested["title"], "write: nested.txt", "{nested}");
assert_eq!(nested["kind"], "edit", "{nested}");
assert_eq!(nested["rawInput"]["path"], "nested.txt", "{nested}");
assert!(nested.get("status").is_none(), "{nested}");
assert_eq!(
std::fs::read_to_string(workspace.join("nested.txt"))?,
"via-plugin"
);
std::fs::remove_file(workspace.join("nested.txt"))?;
let session = new_session(
&mut client,
&manager,
&workspace,
vec![
canned::tool_call_turn(
"n2",
"cc_call",
r#"{"name":"write","input":{"path":"nested.txt","content":"via-plugin"}}"#,
),
canned::simple_text_turn("nested denied"),
],
false,
)
.await?;
let denied = run_prompt(&mut client, &session, &|index| index == 0).await?;
assert!(denied.response.get("error").is_none(), "{}", denied.response);
assert_eq!(denied.response["result"]["stopReason"], "end_turn");
assert_eq!(denied.asked.len(), 2, "{:?}", denied.titles());
assert!(
!workspace.join("nested.txt").exists(),
"a rejected nested write must not run"
);
let session = new_session(
&mut client,
&manager,
&workspace,
vec![
canned::tool_call_turn("r1", "ctx_probe", "{}"),
canned::tool_call_turn("r2", "mcp_fixture_echo", r#"{"text":"control"}"#),
canned::tool_call_turn("r3", "ctx_note", r#"{"text":"must-not-be-written"}"#),
canned::tool_call_turn("r4", "mcp_fixture_echo", r#"{"text":"rejected"}"#),
canned::simple_text_turn("rejections done"),
],
false,
)
.await?;
let rejected = run_prompt(&mut client, &session, &|index| index < 2).await?;
assert_eq!(rejected.asked.len(), 4, "{:?}", rejected.titles());
let rendered = rejected.rendered();
assert!(rendered.contains("mcp-ran:echo:control"), "{rendered}");
assert!(!rendered.contains("mcp-ran:echo:rejected"), "{rendered}");
assert!(!rendered.contains("note.txt"), "{rendered}");
let session = new_session(
&mut client,
&manager,
&workspace,
vec![
canned::tool_call_turn("p1", "ctx_probe", "{}"),
canned::tool_call_turn("p2", "mcp_fixture_echo", r#"{"text":"plan"}"#),
canned::simple_text_turn("plan done"),
],
true,
)
.await?;
let plan = run_prompt(&mut client, &session, &|_| true).await?;
assert!(plan.asked.is_empty(), "{:?}", plan.titles());
assert_eq!(plan.completed(), 0, "{}", plan.rendered());
assert!(!plan.rendered().contains("mcp-ran:"));
let session = new_session(
&mut client,
&manager,
&workspace,
vec![
canned::tool_call_turn("f1", "mcp_fixture_hidden", r#"{"text":"hid"}"#),
canned::tool_call_turn("f2", "mcp_off_echo", r#"{"text":"off"}"#),
canned::tool_call_turn("f3", "mcp_evil_echo", r#"{"text":"evil"}"#),
canned::tool_call_turn(
"f4",
"start_mcp_server",
r#"{"name":"evil","command":"node","args":["-e","0"]}"#,
),
canned::tool_call_turn("f5", "agent", r#"{"action":"start","prompt":"p"}"#),
canned::simple_text_turn("fabricated done"),
],
false,
)
.await?;
let fabricated = run_prompt(&mut client, &session, &|_| true).await?;
assert!(fabricated.asked.is_empty(), "{:?}", fabricated.titles());
assert_eq!(fabricated.completed(), 0, "{}", fabricated.rendered());
assert!(!fabricated.rendered().contains("mcp-ran:"));
client.request("shutdown", json!({})).await?;
Ok::<_, anyhow::Error>(())
}
.await;
if result.is_err() {
let _ = client.write.shutdown().await;
}
result
};
let (served, scripted) = tokio::join!(connection, script);
served?;
scripted?;
manager.shutdown_and_wait().await?;
host.shutdown().await;
Ok(())
}
+1 -1
View File
@@ -1313,7 +1313,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
def(
"telemetry",
SettingKind::Bool(TELEMETRY),
"true",
"false",
ui(
TAB_TRUST,
"network",
-12
View File
@@ -138,16 +138,6 @@ pub struct SystemOneResponse {
pub answers_validated: Option<bool>,
}
impl SystemOneResponse {
pub fn abstained_questions(&self, names: &[&str]) -> Vec<String> {
names
.iter()
.filter(|name| self.answers.get(**name).is_some_and(|a| a.abstained))
.map(|name| (*name).to_string())
.collect()
}
}
impl<'de> Deserialize<'de> for SystemOneResponse {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
@@ -211,8 +201,6 @@ pub struct SystemOneAnswer {
pub probabilities: BTreeMap<String, Option<f64>>,
#[serde(default)]
pub confidence: Option<f64>,
#[serde(default)]
pub abstained: bool,
}
/// Provider usage. OpenRouter adds `cost`; the token-count casing differs
-2
View File
@@ -29,8 +29,6 @@ pub struct UserCommand {
pub takes_arguments: bool,
pub hidden: bool,
pub extension: bool,
pub plugin_id: Option<String>,
pub workspace_sourced: bool,
pub builtin_bindings: Vec<String>,
}
@@ -2152,10 +2152,7 @@ fn git_push_refspec_is_protected(refspec: &str) -> bool {
// Tag-like names (`v1`, `v0.9.1`): git resolves branch-vs-tag on the
// server, so treat them as publishes.
let mut chars = lower.chars();
if chars.next() == Some('v') && chars.next().is_some_and(|ch| ch.is_ascii_digit()) {
return true;
}
false
chars.next() == Some('v') && chars.next().is_some_and(|ch| ch.is_ascii_digit())
}
fn git_tag_tokens_are_publish_like(tokens: &[&str]) -> bool {
+14 -32
View File
@@ -456,7 +456,7 @@ async fn captured_identity_cancel_and_task_bounds_refuse_before_any_provider() {
#[tokio::test]
async fn dropping_an_actual_dispatched_core_call_keeps_unknown_usage_without_retaining_authority() {
struct Pending(Arc<tokio::sync::Notify>);
struct Pending;
impl Replies for Pending {
fn effective_route_envelope(
&self,
@@ -478,42 +478,24 @@ async fn dropping_an_actual_dispatched_core_call_keeps_unknown_usage_without_ret
&self,
_: MessageRequest,
) -> Pin<Box<dyn Future<Output = anyhow::Result<MessageResponse>> + Send + '_>> {
self.0.notify_one();
Box::pin(std::future::pending())
}
}
let entered = Arc::new(tokio::sync::Notify::new());
let caller = Arc::new(caller_for(
Arc::new(Pending(entered.clone())),
"captured-model",
));
let caller = Arc::new(caller_for(Arc::new(Pending), "captured-model"));
let weak_caller = Arc::downgrade(&caller);
let usage = RlmUsageAccumulator::new();
let mut dispatch = caller.dispatch(RlmInvocation {
prompt: "pending".into(),
mode: RlmMode::Completion,
max_tokens: None,
task_instructions: None,
deadline: Some(tokio::time::Instant::now() + Duration::from_secs(600)),
gate: None,
events: None,
usage: usage.clone(),
});
tokio::time::timeout(Duration::from_secs(10), async {
tokio::select! {
biased;
() = entered.notified() => {}
result = &mut dispatch => panic!(
"dropped dispatch never reached the client: {:?}",
result.error
),
}
})
.await
.expect("the dispatched call must reach the client");
tokio::time::pause();
tokio::time::advance(Duration::from_secs(601)).await;
let result = dispatch.await;
let result = caller
.dispatch(RlmInvocation {
prompt: "pending".into(),
mode: RlmMode::Completion,
max_tokens: None,
task_instructions: None,
deadline: Some(tokio::time::Instant::now() + Duration::from_millis(100)),
gate: None,
events: None,
usage: usage.clone(),
})
.await;
assert!(
result
.error
-1
View File
@@ -2643,7 +2643,6 @@ pub fn decision_receipt_fixture(source_id: &str) -> RuntimeDecisionReceipt {
probabilities_bp: Default::default(),
confidence_bp: 0,
min_confidence_bp: 5_000,
abstained_questions: Vec::new(),
cost_saving_kept_fast: false,
thinking: None,
provider_reported_cost_usd: Some("0.000012054".to_string()),
@@ -203,10 +203,13 @@ impl Drop for TestOverlay {
fn overlay_granted() -> bool {
#[cfg(test)]
if TEST_OVERLAY.with(std::cell::Cell::get) {
return true;
{
TEST_OVERLAY.with(std::cell::Cell::get)
}
#[cfg(not(test))]
{
false
}
false
}
fn caller(call: &BrokerCall, tool_name: &'static str) -> Result<ToolContext, BrokerRefusal> {
+1 -5
View File
@@ -1748,11 +1748,7 @@ impl FleetManager {
return Err(start_error);
}
};
if !started {
return Ok(false);
}
Ok(true)
Ok(started)
}
fn start_leased_workers(
+44
View File
@@ -1669,6 +1669,10 @@ pub trait McpTransport: Send + Sync {
async fn recv(&mut self) -> Result<Vec<u8>>;
async fn send_reply(&mut self, msg: Vec<u8>) -> Result<()> {
self.send(msg).await
}
/// Record the protocol revision negotiated at `initialize`. Only the
/// Streamable HTTP transport uses it (the `MCP-Protocol-Version` header on
/// subsequent requests); stdio and legacy SSE have no header channel, so
@@ -3125,6 +3129,30 @@ impl McpConnection {
result
}
async fn answer_server_request(&mut self, reply: &serde_json::Value) {
let result = match serde_json::to_vec(reply) {
Ok(bytes) => {
let cancel_token = self.cancel_token.clone();
tokio::select! {
biased;
_ = cancel_token.cancelled() => {
Err(anyhow::anyhow!("MCP connection was cancelled"))
}
result = self.transport.send_reply(bytes) => result,
}
}
Err(err) => Err(err.into()),
};
if let Err(err) = result {
tracing::debug!(
target: "mcp",
server = %self.name,
error = %err,
"could not answer an MCP server request"
);
}
}
/// Handshake and discovery receive: each frame wait is bounded by the read
/// knob, and a server that stays silent past it is treated as dead.
async fn recv(&mut self, expected_id: String) -> Result<serde_json::Value> {
@@ -3202,6 +3230,22 @@ impl McpConnection {
{
self.catalog_stale = true;
}
if let Some(method) = value.get("method") {
if let Some(id) = value.get("id") {
let method = method.as_str().unwrap_or_default();
let reply = if method == "ping" {
serde_json::json!({"jsonrpc": "2.0", "id": id, "result": {}})
} else {
serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"error": {"code": -32601, "message": format!("Method not found: {method}")}
})
};
self.answer_server_request(&reply).await;
}
continue;
}
if response_id_matches(value.get("id"), &expected_id) {
// Marks the connection stale so it is rebuilt, but this is a
// reply to the request, so it never qualifies for a replay.
+15
View File
@@ -177,6 +177,21 @@ impl McpTransport for HttpTransport {
}
}
async fn send_reply(&mut self, msg: Vec<u8>) -> Result<()> {
match &mut self.mode {
HttpTransportMode::Streamable(transport) => {
transport.send(msg).await.map_err(|err| match err {
StreamableSendError::Incompatible(detail)
| StreamableSendError::StaleSession(detail) => anyhow::anyhow!(
"MCP Streamable HTTP refused a reply to a server request: {detail}"
),
StreamableSendError::Other(err) => err,
})
}
HttpTransportMode::Sse(transport) => transport.send(msg).await,
}
}
async fn recv(&mut self) -> Result<Vec<u8>> {
match &mut self.mode {
HttpTransportMode::Streamable(transport) => transport.recv().await,
+128
View File
@@ -3314,6 +3314,80 @@ async fn call_method_skips_notifications_and_unmatched_responses() {
assert_eq!(sent[0]["method"], "tools/call");
}
#[tokio::test]
async fn call_method_answers_server_requests_instead_of_taking_them_as_the_reply() {
let sent = Arc::new(Mutex::new(Vec::new()));
let transport = ScriptedValueTransport {
sent: Arc::clone(&sent),
responses: VecDeque::from([
json_frame(serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "ping"})),
json_frame(serde_json::json!({"jsonrpc": "2.0", "id": "1", "method": "roots/list"})),
json_frame(serde_json::json!({"jsonrpc": "2.0", "id": 1, "result": {"ok": true}})),
]),
};
let mut conn = test_connection(Box::new(transport));
let result = conn
.call_method("tools/call", serde_json::json!({"name": "echo"}), 1)
.await
.unwrap();
assert_eq!(result, serde_json::json!({"ok": true}));
let sent = sent.lock().unwrap();
assert_eq!(sent.len(), 3);
assert_eq!(
sent[1],
serde_json::json!({"jsonrpc": "2.0", "id": 1, "result": {}})
);
assert_eq!(sent[2]["id"], "1");
assert_eq!(sent[2]["error"]["code"], -32601);
}
struct ReplyRefusingTransport {
sent: Arc<Mutex<Vec<serde_json::Value>>>,
responses: VecDeque<Vec<u8>>,
}
#[async_trait::async_trait]
impl McpTransport for ReplyRefusingTransport {
async fn send(&mut self, msg: Vec<u8>) -> Result<()> {
let msg: serde_json::Value = serde_json::from_slice(&msg)?;
anyhow::ensure!(msg.get("method").is_some(), "status=405 Method Not Allowed");
self.sent.lock().unwrap().push(msg);
Ok(())
}
async fn recv(&mut self) -> Result<Vec<u8>> {
self.responses
.pop_front()
.context("scripted transport exhausted")
}
}
#[tokio::test]
async fn call_method_keeps_its_reply_when_answering_a_server_request_fails() {
let sent = Arc::new(Mutex::new(Vec::new()));
let transport = ReplyRefusingTransport {
sent: Arc::clone(&sent),
responses: VecDeque::from([
json_frame(serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "ping"})),
json_frame(serde_json::json!({"jsonrpc": "2.0", "id": 1, "result": {"ok": true}})),
]),
};
let mut conn = test_connection(Box::new(transport));
let result = conn
.call_method("tools/call", serde_json::json!({"name": "echo"}), 1)
.await
.unwrap();
assert_eq!(result, serde_json::json!({"ok": true}));
assert_eq!(conn.state, ConnectionState::Ready);
let sent = sent.lock().unwrap();
assert_eq!(sent.len(), 1);
assert_eq!(sent[0]["method"], "tools/call");
}
#[tokio::test]
async fn call_method_invalid_json_includes_server_output_preview() {
let sent = Arc::new(Mutex::new(Vec::new()));
@@ -6770,6 +6844,60 @@ async fn custom_headers_applied_to_get_preflight() {
);
}
#[tokio::test]
async fn refused_reply_to_a_server_request_does_not_fall_back_to_sse() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
let _env = crate::test_support::lock_test_env();
let _lock = lock_mcp_loopback_tests().await;
let _no_proxy = crate::test_support::EnvVarGuard::set("NO_PROXY", "*,127.0.0.1");
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let requests = Arc::new(Mutex::new(Vec::new()));
let requests_srv = Arc::clone(&requests);
let server = tokio::spawn(async move {
loop {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 4096];
let n = socket.read(&mut buf).await.unwrap();
let request_line = String::from_utf8_lossy(&buf[..n])
.lines()
.next()
.unwrap_or_default()
.to_string();
requests_srv.lock().unwrap().push(request_line);
socket
.write_all(
b"HTTP/1.1 405 Method Not Allowed\r\nConnection: close\r\nContent-Length: 0\r\n\r\n",
)
.await
.unwrap();
}
});
let url = format!("http://{addr}/mcp");
let mut transport = HttpTransport::new(
test_mcp_http_client(&url),
url,
tokio_util::sync::CancellationToken::new(),
Duration::from_secs(2),
);
let result = transport
.send_reply(json_frame(
serde_json::json!({"jsonrpc": "2.0", "id": 1, "result": {}}),
))
.await;
server.abort();
assert!(result.is_err(), "a refused reply reports the refusal");
let requests = requests.lock().unwrap();
assert_eq!(requests.len(), 1, "requests seen: {requests:?}");
assert!(requests[0].starts_with("POST "), "{requests:?}");
}
// === add_runtime_server_config conflict tests ===
#[test]
+23 -5
View File
@@ -282,20 +282,31 @@ impl ModelInventory {
// vendor-family heuristic) carries the number *and* the fact
// that nobody verified it — the auto-router must not read a
// guessed window as a route capability.
let mut context_window_unverified = true;
let mut context_window_unverified =
codewhale_config::catalog::reviewed::intrinsic_model(&model)
.and_then(|row| row.context_window)
.is_none();
if let Ok(route) = crate::route_runtime::resolve_runtime_route_for_identity(
config,
&identity,
Some(&model),
) {
capability.context_window = route.context_window.tokens;
context_window_unverified = !route.context_window.source.is_verified();
capability.max_output = route
if let Some(context_window) = route.candidate.limits().context_tokens {
capability.context_window = context_window.min(u64::from(u32::MAX)) as u32;
context_window_unverified = !route.context_window.source.is_verified();
}
// A concrete offering maximum is a stronger fact than the
// static compatibility matrix — and is the only way a
// membership route (no static cap) gets a known ceiling.
if let Some(max_output) = route
.candidate
.limits()
.output_tokens
.and_then(|tokens| u32::try_from(tokens).ok())
.filter(|tokens| *tokens > 0);
.filter(|tokens| *tokens > 0)
{
capability.max_output = Some(max_output);
}
user_declared = route
.candidate
.applied_limit_overrides()
@@ -305,6 +316,13 @@ impl ModelInventory {
== codewhale_config::route::OverrideSource::UserModelMetadata
});
if user_declared {
context_window_unverified = !route.context_window.source.is_verified();
capability.context_window = route.context_window.tokens;
capability.max_output = route
.candidate
.limits()
.output_tokens
.and_then(|value| u32::try_from(value).ok());
capability.thinking_supported = route.candidate.capabilities().reasoning
== codewhale_config::route::CapabilityState::Supported;
}
+2 -60
View File
@@ -1852,7 +1852,6 @@ pub struct ValidChoice {
pub choice: String,
pub probabilities_bp: BTreeMap<String, u16>,
pub confidence_bp: u16,
pub abstained: bool,
}
/// Validate one `choice` answer against the offered options. Any violation
@@ -1895,7 +1894,6 @@ pub fn validated_choice(answer: Option<&SystemOneAnswer>, options: &[&str]) -> O
choice: choice.to_string(),
probabilities_bp,
confidence_bp: probability_bp(confidence),
abstained: answer.abstained,
})
}
@@ -1984,7 +1982,6 @@ fn decision_attempt_from_response(
probabilities_bp: Default::default(),
confidence_bp: 0,
min_confidence_bp: inventory.router_min_confidence_bp,
abstained_questions: response.abstained_questions(&["tier", "thinking"]),
cost_saving_kept_fast: false,
thinking: None,
provider_reported_cost_usd: response
@@ -2008,18 +2005,17 @@ fn decision_attempt_from_response(
let min_confidence_bp = inventory.router_min_confidence_bp;
// An invalid or unsure `thinking` answer drops only the effort.
let thinking = validated_choice(response.answers.get("thinking"), &DECISION_THINKING_OPTIONS)
.filter(|thinking| !thinking.abstained && thinking.confidence_bp >= min_confidence_bp)
.filter(|thinking| thinking.confidence_bp >= min_confidence_bp)
.map(|thinking| thinking.choice);
let strong_bp = tier.probabilities_bp.get("strong").copied().unwrap_or(0);
let cost_saving_kept_fast =
cost_saving && tier.choice == "strong" && strong_bp < COST_SAVING_STRONG_MIN_BP;
let acted = !tier.abstained && tier.confidence_bp >= min_confidence_bp;
let acted = tier.confidence_bp >= min_confidence_bp;
attempt.decision = Some(AutoRouteDecisionEvidence {
choice: tier.choice.clone(),
probabilities_bp: tier.probabilities_bp,
confidence_bp: tier.confidence_bp,
min_confidence_bp,
abstained_questions: response.abstained_questions(&["tier", "thinking"]),
cost_saving_kept_fast: acted && cost_saving_kept_fast,
thinking: thinking.clone().filter(|_| acted),
provider_reported_cost_usd: response
@@ -4191,60 +4187,6 @@ mod decision_router_tests {
);
}
#[test]
fn explicit_abstention_survives_decode_and_never_authorizes_auto_at_floor_zero() {
let _env = hermetic_env();
let mut config = decision_config("https://openrouter.invalid/api/v1", false, 2);
config
.auto
.as_mut()
.unwrap()
.router
.as_mut()
.unwrap()
.min_confidence = Some(0.0);
let inventory = ModelInventory::from_config(&config).unwrap();
let attempt = |body: serde_json::Value| {
decision_attempt_from_response(false, &inventory, &pair(), None, &parsed(body), 120)
};
let mut body = answer_body(0.1, 0.9);
body["answers"]["thinking"]["abstained"] = true.into();
let effort_abstained = attempt(body.clone());
let recommendation = effort_abstained
.recommendation
.expect("valid tier survives effort abstention");
assert_eq!(recommendation.model, "deepseek-v4-flash");
assert_eq!(recommendation.reasoning_effort, None);
assert_eq!(
effort_abstained.decision.unwrap().abstained_questions,
vec!["thinking"]
);
body["answers"]["tier"]["abstained"] = true.into();
let tier_abstained = attempt(body);
assert!(tier_abstained.recommendation.is_none());
assert_eq!(
tier_abstained.fallback_reason,
Some(AutoRouteHeuristicReason::LowConfidence)
);
assert_eq!(tier_abstained.failure, None);
let decision = tier_abstained.decision.unwrap();
assert_eq!(decision.abstained_questions, vec!["tier", "thinking"]);
assert_eq!(decision.choice, "fast");
assert_eq!(
decision.provider_reported_cost_usd.as_deref(),
Some("0.000019992")
);
}
#[test]
fn non_boolean_abstention_is_refused_not_ignored() {
let mut body = answer_body(0.1, 0.9);
body["answers"]["tier"]["abstained"] = "yes".into();
let attempt = policy(false, &parsed(body));
assert!(attempt.recommendation.is_none());
assert_eq!(attempt.failure, Some(AutoRouterFailure::InvalidAnswer));
}
#[test]
fn invalid_answers_are_rejected_not_repaired() {
let mut outside = answer_body(0.8, 0.6);
+16 -19
View File
@@ -1455,24 +1455,6 @@ pub fn configured_catalog_models_for_route(
ids
}
pub fn listed_models_for_route(route_config: &Config, identity: &ProviderIdentity) -> Vec<String> {
let mut models = configured_catalog_models_for_route(
route_config,
identity.provider,
identity.key.as_str(),
&route_config.active_route_base_url(),
);
if identity.provider != ProviderKind::OpenaiCodex {
let default_model = route_config.default_model();
if !default_model.is_empty() && !default_model.eq_ignore_ascii_case("auto") {
push_unique_model(&mut models, &default_model);
}
models.sort();
models.dedup();
}
models
}
/// Look up the **bundled-snapshot** offering for `(provider, wire_model_id)`,
/// ignoring any live rows merged over it.
///
@@ -2237,8 +2219,23 @@ pub async fn run_models(
route_config
.scope_to_provider_identity(identity)
.map_err(anyhow::Error::msg)?;
let models = listed_models_for_route(&route_config, identity);
let mut models = configured_catalog_models_for_route(
&route_config,
identity.provider,
identity.key.as_str(),
&route_config.active_route_base_url(),
);
let default_model = route_config.default_model();
if identity.provider != ProviderKind::OpenaiCodex
&& !default_model.is_empty()
&& !default_model.eq_ignore_ascii_case("auto")
{
push_unique_model(&mut models, &default_model);
}
if identity.provider != ProviderKind::OpenaiCodex {
models.sort();
models.dedup();
}
if json {
// Preserve the existing array + AvailableModel field shape.
let chatgpt_roster = (identity.provider == ProviderKind::OpenaiCodex)
+35 -28
View File
@@ -325,7 +325,7 @@ pub fn selected_route(path: &Path) -> Result<(String, String, codewhale_config::
pub fn list_models(
store: &codewhale_config::ConfigStore,
filter: Option<ProviderKind>,
) -> Result<Vec<(String, Vec<String>)>> {
) -> Result<Vec<(ProviderKind, Vec<String>)>> {
crate::models_dev_live::maybe_load_persisted_cache();
let mut config = saved_config(store)?;
config.base_url_env_receipt = config
@@ -333,33 +333,42 @@ pub fn list_models(
.map_or(BaseUrlEnvReceipt::NoOwner, |identity| {
BaseUrlEnvReceipt::Route(identity.provider, identity.key.to_string())
});
let mut custom_routes: Vec<String> = config
.providers
.as_ref()
.map(|providers| providers.custom.keys().cloned().collect())
.unwrap_or_default();
custom_routes.sort();
let providers = filter
.as_ref()
.map_or(ProviderKind::all(), std::slice::from_ref);
let mut catalogs = Vec::with_capacity(providers.len());
for provider in providers {
let routes = if *provider == ProviderKind::Custom {
custom_routes.clone()
} else {
vec![provider.as_str().to_string()]
};
for route in routes {
let identity = config
.legacy_selection_identity(&route)
.map_err(anyhow::Error::msg)?;
let mut route_config = config.clone();
route_config
.scope_to_provider_identity(&identity)
.map_err(anyhow::Error::msg)?;
let models = crate::provider_lake::listed_models_for_route(&route_config, &identity);
catalogs.push((route, models));
if *provider == ProviderKind::Custom
&& config
.providers
.as_ref()
.and_then(|providers| providers.custom_provider_config(provider.as_str()))
.is_none()
{
continue;
}
let identity = config
.legacy_selection_identity(provider.as_str())
.map_err(anyhow::Error::msg)?;
let mut route_config = config.clone();
route_config
.scope_to_provider_identity(&identity)
.map_err(anyhow::Error::msg)?;
let mut models = crate::provider_lake::configured_catalog_models_for_route(
&route_config,
identity.provider,
identity.key.as_str(),
&route_config.active_route_base_url(),
);
if identity.provider != ProviderKind::OpenaiCodex {
let default_model = route_config.default_model();
if !default_model.is_empty() && !default_model.eq_ignore_ascii_case("auto") {
models.push(default_model);
}
models.sort();
models.dedup();
}
catalogs.push((*provider, models));
}
Ok(catalogs)
}
@@ -409,12 +418,10 @@ pub fn resolve_model(
.into(),
);
}
if !crate::provider_catalog_live::provider_owns_live_catalog(identity.provider, base_url) {
codewhale_agent::ModelRegistry::default().resolve(
model.filter(|model| *model != "auto"),
Some(identity.provider),
)?;
}
codewhale_agent::ModelRegistry::default().resolve(
model.filter(|model| *model != "auto"),
Some(identity.provider),
)?;
}
Ok(route?.candidate)
}
+2 -26
View File
@@ -451,11 +451,6 @@ async fn evaluate(
.map_or_else(Default::default, |v| v.probabilities_bp.clone()),
confidence_bp: intent.as_ref().map_or(0, |v| v.confidence_bp),
min_confidence_bp: 5_000,
abstained_questions: response.abstained_questions(&[
"intent",
"needs_tool",
"answerable_from_context",
]),
cost_saving_kept_fast: false,
thinking: None,
provider_reported_cost_usd: response
@@ -526,7 +521,7 @@ fn unit_interval(value: Option<f64>) -> Option<f64> {
/// fast route.
fn read_noul(response: &SystemOneResponse, key: &str) -> Option<f64> {
let answer = response.answers.get(key)?;
(answer.kind == "noul" && !answer.abstained)
(answer.kind == "noul")
.then_some(answer.noul)
.and_then(unit_interval)
}
@@ -553,9 +548,7 @@ fn derive_route(response: &SystemOneResponse) -> Route {
response.answers.get("intent"),
&["code_change", "code_question", "command", "chat", "other"],
)
.is_some_and(|intent| {
!intent.abstained && intent.choice == "chat" && intent.confidence_bp >= 5_000
});
.is_some_and(|intent| intent.choice == "chat" && intent.confidence_bp >= 5_000);
if intent_is_calibrated_chat && needs_tool.is_some_and(|nt| nt <= 0.2) {
return Route::PlainChat;
}
@@ -571,23 +564,6 @@ mod tests {
const TYPESAFE_TEST_KEY: &str = "sf-typesafe-test-key-0123456789";
#[test]
fn explicit_abstention_never_authorizes_a_shadow_route() {
let mut value = json!({"answers":{
"intent":{"type":"choice","choice":"chat","confidence":0.9,
"probabilities":{"chat":0.9,"code_change":0.1,"code_question":0.0,"command":0.0,"other":0.0}},
"needs_tool":{"type":"noul","noul":0.1},
"answerable_from_context":{"type":"noul","noul":0.1}}});
let decoded =
|value: Value| serde_json::from_value::<SystemOneResponse>(value).expect("response");
assert_eq!(derive_route(&decoded(value.clone())), Route::PlainChat);
value["answers"]["intent"]["abstained"] = true.into();
assert_eq!(derive_route(&decoded(value.clone())), Route::Unknown);
value["answers"]["needs_tool"]["noul"] = 0.95.into();
value["answers"]["needs_tool"]["abstained"] = true.into();
assert_eq!(derive_route(&decoded(value)), Route::Unknown);
}
/// Fields drop in declaration order: restore the environment before the
/// lock is released, or another test observes our overrides.
struct Env {
@@ -133,14 +133,9 @@ impl WriteScopeClaim {
return true;
}
}
if self
.contracts
self.contracts
.iter()
.any(|contract| other.contracts.iter().any(|other| other == contract))
{
return true;
}
false
}
#[must_use]
+1
View File
@@ -16367,6 +16367,7 @@ async fn spawn_subagent_from_input(
Err(error) => ToolError::execution_failed(format!("Failed to spawn sub-agent: {error}")),
})?;
codewhale_telemetry::session_counters().bump(codewhale_telemetry::Counter::SubagentSpawn);
Ok((result, spawn_metadata))
}
+37
View File
@@ -23585,6 +23585,43 @@ async fn runtime_surface_hardening_resume_from_requires_descendant_control() {
assert_eq!(manager.read().await.agents.len(), 2);
}
#[tokio::test]
async fn spawn_counts_one_subagent_spawn_for_telemetry() {
let tmp = tempdir().unwrap();
let mut runtime = stub_runtime();
runtime.context = ToolContext::new(tmp.path().to_path_buf());
let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 4);
let counters = codewhale_telemetry::session_counters();
let before = counters.counters().subagent_spawn;
let (result, _) = spawn_subagent_from_input(
json!({"prompt": "inspect the workspace"}),
manager.clone(),
runtime.clone(),
false,
None,
None,
None,
)
.await
.expect("spawn succeeds");
assert!(!result.agent_id.is_empty());
assert_eq!(counters.counters().subagent_spawn, before + 1);
let _ = spawn_subagent_from_input(
json!({"prompt": "x", "resume_from": "missing-agent"}),
manager,
runtime,
false,
None,
None,
None,
)
.await
.expect_err("a refused spawn is not counted");
assert_eq!(counters.counters().subagent_spawn, before + 1);
}
#[test]
fn resume_from_rejects_running_source() {
let tmp = tempdir().expect("tempdir");
+51 -6
View File
@@ -255,11 +255,12 @@ impl StdioLspTransport {
let child = Arc::new(AsyncMutex::new(Some(child)));
let dispatcher_child = child.clone();
let dispatcher_pending = pending.clone();
let dispatcher_outbound = tx_outbound.clone();
let dispatcher_task = spawn_supervised(
"lsp-dispatcher",
std::panic::Location::caller(),
async move {
dispatcher_task(rx_inbound, tx_diag, dispatcher_pending).await;
dispatcher_task(rx_inbound, tx_diag, dispatcher_outbound, dispatcher_pending).await;
// EOF, malformed frames, or an overflowing diagnostics queue
// terminate the producer too, so its pipes cannot stay stuck.
if let Some(mut child) = dispatcher_child.lock().await.take() {
@@ -536,15 +537,19 @@ impl LspTransport for StdioLspTransport {
/// Send a JSON value as one Content-Length-framed JSON-RPC message.
async fn send_message(tx: &mpsc::Sender<Vec<u8>>, value: &Value) -> Result<()> {
tx.send(frame_message(value)?)
.await
.map_err(|_| anyhow!("LSP outbound channel closed"))?;
Ok(())
}
fn frame_message(value: &Value) -> Result<Vec<u8>> {
let body = serde_json::to_vec(value).context("serialize LSP message")?;
let header = format!("Content-Length: {}\r\n\r\n", body.len());
let mut frame = Vec::with_capacity(header.len() + body.len());
frame.extend_from_slice(header.as_bytes());
frame.extend_from_slice(&body);
tx.send(frame)
.await
.map_err(|_| anyhow!("LSP outbound channel closed"))?;
Ok(())
Ok(frame)
}
/// Background task that drains the outbound queue and writes each frame to
@@ -638,6 +643,7 @@ fn parse_header(buf: &[u8]) -> Result<Option<(usize, usize)>> {
async fn dispatcher_task(
mut rx: mpsc::Receiver<Value>,
tx_diag: mpsc::Sender<DiagnosticMessage>,
tx_outbound: mpsc::Sender<Vec<u8>>,
pending: Arc<AsyncMutex<HashMap<i64, oneshot::Sender<Value>>>>,
) {
while let Some(value) = rx.recv().await {
@@ -654,6 +660,19 @@ async fn dispatcher_task(
}
continue;
}
if let Some(method) = method {
if let Some(id) = value.get("id") {
let reply = json!({
"jsonrpc": "2.0",
"id": id,
"error": {"code": -32601, "message": format!("unsupported method {method}")},
});
if let Ok(frame) = frame_message(&reply) {
let _ = tx_outbound.try_send(frame);
}
}
continue;
}
// Replies have an `id` and a `result` or `error`.
if let Some(id) = value.get("id").and_then(|v| v.as_i64()) {
let mut map = pending.lock().await;
@@ -780,6 +799,31 @@ pub(super) mod tests {
assert_eq!(diags[0].message, "missing semicolon");
}
#[tokio::test]
async fn server_request_sharing_a_pending_id_is_answered_not_taken_as_the_reply() {
let (tx_in, rx_in) = mpsc::channel(4);
let (tx_out, mut rx_out) = mpsc::channel::<Vec<u8>>(4);
let (tx_diag, _rx_diag) = mpsc::channel(4);
let pending = Arc::new(AsyncMutex::new(HashMap::new()));
let (reply_tx, reply_rx) = oneshot::channel();
pending.lock().await.insert(2, reply_tx);
let dispatcher = tokio::spawn(dispatcher_task(rx_in, tx_diag, tx_out, pending));
tx_in
.send(json!({"jsonrpc":"2.0","id":2,"method":"workspace/configuration","params":{"items":[]}}))
.await
.unwrap();
tx_in
.send(json!({"jsonrpc":"2.0","id":2,"result":"definition"}))
.await
.unwrap();
assert_eq!(reply_rx.await.unwrap()["result"], "definition");
let answer = next_document(&mut rx_out).await;
assert_eq!(answer["id"], 2);
assert_eq!(answer["error"]["code"], -32601);
drop(tx_in);
dispatcher.await.unwrap();
}
#[test]
fn round_trips_uri_path() {
let path = fixture_path("example/foo.rs");
@@ -1309,11 +1353,12 @@ while True:
let (mut producer, reader) = tokio::io::duplex(MAX_LSP_HEADER_BYTES * 2);
let (tx, rx) = mpsc::channel(8);
let (diag, _diagnostics) = mpsc::channel(8);
let (outbound, _outbound_rx) = mpsc::channel(8);
let pending = Arc::new(AsyncMutex::new(HashMap::new()));
let (reply, receiver) = oneshot::channel();
pending.lock().await.insert(1, reply);
let read_task = tokio::spawn(reader_task(reader, tx));
let dispatch = tokio::spawn(dispatcher_task(rx, diag, pending.clone()));
let dispatch = tokio::spawn(dispatcher_task(rx, diag, outbound, pending.clone()));
producer.write_all(&frame).await.unwrap();
assert!(
timeout(Duration::from_secs(1), receiver)
-2
View File
@@ -11,8 +11,6 @@ pub struct AutoRouteDecisionEvidence {
pub probabilities_bp: BTreeMap<String, u16>,
pub confidence_bp: u16,
pub min_confidence_bp: u16,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub abstained_questions: Vec<String>,
/// `[auto] cost_saving` kept the fast tier because the strong tier's
/// probability was below the cost-saving floor.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
+3 -5
View File
@@ -12,13 +12,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Session names after a crash, and keep-forever snapshot retention:** a session name saved right after a crash that cut off the previous session-index line is no longer lost, because the new entry now starts on its own line; and a very large `[snapshots] max_age_days` (such as `9223372036854775807` for "keep forever") no longer wraps around and deletes every undo snapshot at session start. Thanks [@luochen211](https://github.com/luochen211) (PR 7316).
- **Blank environment variables:** exporting a variable empty (for example `CODEWHALE_MODEL=` from a compose `${VAR:-}` template) no longer hides its legacy alias. `DEEPSEEK_MODEL`, `DEEPSEEK_BASE_URL`, `KIMI_BASE_URL`, `DEEPSEEK_FORCE_HTTP1`, `DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS` and the other provider alias variables now apply as they do when the primary is unset. Thanks [@luochen211](https://github.com/luochen211) (PR 7315).
- **Qwen Code as an external agent:** Codewhale now finds an installed Qwen Code CLI (`qwen`, 0.6.1 or newer) and can run it as a session over ACP with `qwen --acp`. `GET /v1/agents/external` lists it, an older install is reported as needing `npm i -g @qwen-code/qwen-code@latest`, and sign-in stays inside Qwen Code (`/auth`). Thanks [@MannXo](https://github.com/MannXo) (PR 7293).
- **Telemetry setting default:** the settings schema API reports telemetry's default as on, matching what Codewhale does when you have not chosen.
- **Auto routing abstention:** when the decision model marks a tier, effort or shadow answer `abstained: true`, Auto no longer acts on it, even with `min_confidence = 0`: an abstained tier takes the local fallback, an abstained effort keeps the tier and drops only the effort, and the routing receipt lists the abstained questions.
- **Plugin index (Runtime API):** a new `GET /v1/apps/plugin-index` route lists plugins, skills, connectors, hooks and hand-written commands with kind, source and state, and `/v1/commands` rows say which plugin (`plugin_id`) added a command and whether a command came from the project (`workspace_sourced`) (CW-8092).
- **Model commands and routing:** `codewhale model list` now lists every custom endpoint you have configured, under its own name. `codewhale model resolve` no longer refuses a model on a custom endpoint, router or local server whose model list has not been fetched yet; once a list is fetched, a model missing from it is still refused. Automatic routing marks a context window as verified only when the route itself supplies it, and no longer reports a guessed output limit.
- **ACP:** when a plugin tool asks the core to run a tool that needs approval, the editor now gets a `session/request_permission` on the plugin tool's call that names the nested tool and its input; before, the prompt ended with an internal error (CW-8050).
- **MCP and language servers that send requests mid-call:** a tool call no longer fails with "neither a result nor an error" when an MCP server sends a `ping` or other request that reuses the call's id while the call is running, and a language server's `workspace/configuration` or similar request no longer ends a pending LSP request. Codewhale now answers `ping`, answers other server requests with "Method not found" (it never advertised them), keeps waiting for the real reply, and a refused answer over Streamable HTTP no longer drops the connection to legacy SSE. Thanks [@luochen211](https://github.com/luochen211) (PR 7319).
- **Qwen Code as an external agent:** Codewhale now finds an installed Qwen Code CLI (`qwen`, 0.6.1 or newer) and can run it as a session over ACP with `qwen --acp`. `GET /v1/agents/external` lists it, an older install is reported as needing `npm i -g @qwen-code/qwen-code@latest`, and sign-in stays inside Qwen Code (`/auth`). Thanks [@MannXo](https://github.com/MannXo) (#7293).
- **README and contributor credit:** the repository front page now opens the way the website does: the line "The model can be anyone's. The agent should be yours.", one sentence on what Codewhale is, the install command and one picture, with reference detail moved into the linked guides; the 18 translated READMEs follow the same structure and say they are machine-drafted. The contributor wall and the website now count the same 433 people (407 GitHub accounts and the commit authors whose email is not linked to an account), where the website had counted accounts only. `DEPENDENCY_SECURITY.md` moved into `docs/dependency-maintenance.md`, and `.github/SUPPORT.md` says where to ask for help.
- **Website Plugins page:** The website's Plugins page now describes what the released version does, and a labelled section says what 0.10.3 adds: one Plugins place, Standard and Creator mode, and the Permissions control.
- **First-time guide on the website:** a new docs page, "Use Codewhale for the first time" (`/docs/first-time`, English and Simplified Chinese), walks someone who has never used a terminal through opening one, installing, choosing the folder Codewhale works in and when that choice matters, opening a terminal in a folder from Finder or File Explorer, connecting a model, a first task, and stopping, undoing and quitting. The install page gains a short "New to the terminal?" block for macOS and Windows that links to it, and `llms.txt` carries the same steps.
- **Plugin docs and plugin-creator:** Docs now say everything you add is a plugin. The bundled plugin-creator skill no longer tells an agent never to trust or enable a plugin (in Full Access it may, for one it wrote and installed from its project), points code and screen plugins at cordis-plugin-development, and an untouched copy from 0.10.2 now upgrades. The Chinese extension and Runtime API docs now say editor ACP sessions get plugin tools and configured MCP servers.
- **Model thinking:** New installs show model thinking by default: it streams live in the transcript and collapses under a "Thought for …" header when the answer starts. Existing installs keep their saved setting; turn it on under Model reasoning in chat in /config.
- **Footer clock:** The footer clock and phase now say "thinking", matching the transcript.
-1
View File
@@ -85,7 +85,6 @@ mod tests {
aliases: aliases.iter().map(|s| s.to_string()).collect(),
hidden,
plugin_authority: None,
workspace_sourced: false,
extension: None,
}
}
-5
View File
@@ -296,11 +296,6 @@ impl codewhale_engine::command_catalog::CommandCatalog for CommandTable {
takes_arguments: metadata.takes_arguments(),
hidden: metadata.hidden,
extension: metadata.extension.is_some(),
plugin_id: metadata
.plugin_authority
.as_ref()
.map(|authority| authority.plugin_id.as_str().to_string()),
workspace_sourced: metadata.workspace_sourced,
builtin_bindings: bindings.remove(&metadata.name).unwrap_or_default(),
})
.collect()
-4
View File
@@ -80,7 +80,6 @@ pub struct UserCommandMetadata {
pub aliases: Vec<String>,
pub hidden: bool,
pub plugin_authority: Option<codewhale_config::PluginAuthority>,
pub workspace_sourced: bool,
/// Set for a command contributed by an extension-host plugin: it runs in
/// the host (`command/run`) instead of expanding `body`.
pub extension: Option<codewhale_engine::extension_host::command::ExtensionCommandRef>,
@@ -247,7 +246,6 @@ impl UserCommandRegistry {
let (mut metadata, errors) =
parse_metadata(entry.name, &entry.content, &entry.path);
metadata.plugin_authority = entry.plugin_authority;
metadata.workspace_sourced = entry.workspace_sourced;
(metadata, errors, entry.path, entry.workspace_sourced)
})
.collect::<Vec<_>>();
@@ -397,7 +395,6 @@ impl UserCommandRegistry {
aliases: Vec::new(),
hidden: false,
plugin_authority: Some(entry.authority),
workspace_sourced: false,
extension: Some(reference),
},
);
@@ -485,7 +482,6 @@ fn parse_metadata(
aliases: Vec::new(),
hidden: false,
plugin_authority: None,
workspace_sourced: false,
extension: None,
};
let mut configured_name = None;
+2 -2
View File
@@ -284,8 +284,8 @@ impl ContextMenuView {
if start > 0 {
rows.push(MenuRow::MoreAbove(start));
}
for idx in start..end {
if idx > start && self.entries[idx].section_start {
for (idx, entry) in self.entries.iter().enumerate().take(end).skip(start) {
if idx > start && entry.section_start {
rows.push(MenuRow::Divider);
}
rows.push(MenuRow::Entry(idx));
+4 -4
View File
@@ -1441,8 +1441,8 @@ fn prose_cells_fill_full_width_on_ultrawide_by_default() {
let mut cache = TranscriptViewCache::new();
cache.ensure_filtered(&refs, &revisions, 220, options, &HashMap::new(), None, None);
for idx in 0..3 {
let width = max_line_width(&cache.per_cell[idx].lines);
for (idx, cell) in cache.per_cell.iter().enumerate().take(3) {
let width = max_line_width(&cell.lines);
assert!(
width > RETIRED_RAIL_MEASURE,
"prose cell {idx} wrapped to {width} columns — still on the retired \
@@ -1495,8 +1495,8 @@ fn transcript_prose_measure_caps_prose_but_not_tools() {
let mut cache = TranscriptViewCache::new();
cache.ensure_filtered(&refs, &revisions, 220, options, &HashMap::new(), None, None);
for idx in 0..3 {
let width = max_line_width(&cache.per_cell[idx].lines);
for (idx, cell) in cache.per_cell.iter().enumerate().take(3) {
let width = max_line_width(&cell.lines);
assert!(
width > 105,
"prose cell {idx} wrapped to {width} columns — still on the retired \
-1
View File
@@ -1771,7 +1771,6 @@ mod tests {
.collect(),
confidence_bp: 6400,
min_confidence_bp: 5000,
abstained_questions: Vec::new(),
cost_saving_kept_fast: false,
thinking: Some("max".to_string()),
provider_reported_cost_usd: Some("0.000019992".to_string()),
-1
View File
@@ -35,7 +35,6 @@ pub(crate) fn apply_agent_spawned_status_and_observer(
prompt_summary: &str,
) {
let label = app.ensure_agent_label(agent_id);
codewhale_telemetry::session_counters().bump(codewhale_telemetry::Counter::SubagentSpawn);
app.push_status_toast_record(
StatusToast::new(
format!(
+1 -1
View File
@@ -1110,7 +1110,7 @@ mod screen_mode_tests {
viewport: ratatui::Viewport::Inline(3),
},
)
.map_err(|err| io::Error::other(err.to_string()))
.map_err(|err| match err {})
},
)
.expect("a successful probe must switch");
+1
View File
@@ -44,6 +44,7 @@ changelog or release notes, and this contributor record.
**Contributions**
- **[MannXo](https://github.com/MannXo)** — added Qwen Code to the external agent registry so an installed Qwen Code CLI runs as a session over ACP ([#7293](https://github.com/codewhale-hq/Codewhale/pull/7293)).
- **[SamhandsomeLee](https://github.com/SamhandsomeLee)** — drove Responses request, URL and stream behavior from one checked dialect profile table ([#7261](https://github.com/codewhale-hq/Codewhale/pull/7261)).
- **[gaord](https://github.com/gaord)** — ran the read-only `/system`, `/tokens` and `/cost` builtins over the Runtime API through one gated terminal-host catalog ([#7277](https://github.com/codewhale-hq/Codewhale/pull/7277)).
+1 -1
View File
@@ -545,7 +545,7 @@ Return `{kind:'deny', reason}`, `{kind:'ask', reason?}`, `{kind:'revise', input}
Native hooks run first, then mod listeners in core registration order. Denial always wins; the last accepted input revision wins. Rust re-prepares revised arguments and reruns the existing authority, policy, and approval checks. Input revisions use the existing 32 KiB hook limit; context and reasons use the existing sanitizers. Unloading a fiber removes its listener, and disabling or revoking an owner retires its admitted handles before host teardown. Other workspaces do not receive the call.
This bridge runs on Engine sessions, including model tools, gated code-mode calls, and `exec.core` calls. Sessions an editor drives over ACP (`codewhale serve --acp`) get the same extension host, under the same `[features]` flags (`extension_host`, `mcp`): reviewed plugin tools (TypeScript and DSH) and the MCP servers configured in Codewhale appear as ACP tool calls and ask through the session's permission setting; Plan stays read-only. When a plugin tool asks the core to run a tool that needs approval (`exec.core`), the editor's permission request is shown on the plugin tool's call and names the nested tool and its input. The ACP server's capabilities say so (`agentCapabilities._meta.codewhale`). Servers an ACP client passes in `mcpServers` and configured shell hooks are not attached. Prepend/global listener ordering, DSH runtime/agent handles, around-execution wrappers, and post-result rewriting are not provided. Native `tool_call_after` remains an observer contract.
This bridge runs on Engine sessions, including model tools, gated code-mode calls, and `exec.core` calls. Sessions an editor drives over ACP (`codewhale serve --acp`) get the same extension host, under the same `[features]` flags (`extension_host`, `mcp`): reviewed plugin tools (TypeScript and DSH) and the MCP servers configured in Codewhale appear as ACP tool calls and ask through the session's permission setting; Plan stays read-only. The ACP server's capabilities say so (`agentCapabilities._meta.codewhale`). Servers an ACP client passes in `mcpServers` and configured shell hooks are not attached. Prepend/global listener ordering, DSH runtime/agent handles, around-execution wrappers, and post-result rewriting are not provided. Native `tool_call_after` remains an observer contract.
## Asking the core to run a tool
+1 -41
View File
@@ -886,13 +886,6 @@ Plugin tools and configured MCP servers on this transport are on main after
0.10.2; 0.10.2 itself does not have them.
Client-supplied MCP servers, dynamic tools, task/PTY/background shell,
interpreter, subagent and RLM lifecycles are unavailable on this transport.
ACP has no tool search, so every connected MCP tool and its schema is sent with
every request, and a server marked `lazy = true` never starts. A large MCP set
therefore costs tokens on each turn and can exceed a provider's tool limit.
MCP servers a model starts at run time stay out of an ACP-profile host. When
`serve --acp` instead attaches to a running Runtime owner (see below), its turns
use that owner's MCP pool, which includes servers started at run time in other
sessions of the same workspace.
Built-in overrides remove the
whole compatibility alias family. Final dispatch rechecks the profile, so a
fabricated alias, hook rewrite or omitted catalog entry cannot bypass it. Plan
@@ -913,11 +906,7 @@ produced.
Tool updates begin `pending`. `in_progress` means Core reached final dispatch;
`completed`/`failed` and typed image blocks come from the actual Core result.
`session/request_permission` correlates a private JSON-RPC ID to the same
Runtime-minted pending approval and Core execution ID. When a plugin tool asks
the core to run a tool that needs approval (`exec.core`), the request is shown
on the plugin tool's call, names the nested tool and carries its input, and the
answer releases the nested call; the nested call is decided under the session's
permission setting. Only an exact live
Runtime-minted pending approval and Core execution ID. Only an exact live
`allow-once` response can release that waiter; wrong IDs are ignored, invalid
options deny, and cancellation withdraws it. ACP grants neither remembered
permissions nor Native capabilities. Core typed rules, strict hooks, repo law,
@@ -2683,35 +2672,6 @@ thread deletion.
pure over the snapshot — so a client must not look for one. Anything that
mutates the session stays a terminal-side command.
**Plugin index**
- `GET /v1/apps/plugin-index` — `{workspace, items: [...], unavailable: [...],
problems: [...]}`: one list across every plugin, skill, connector, hook and
hand-written command. It is a projection: each item's `row` is the row its
owner's own route returns (`/v1/apps/plugins`, `/v1/apps/plugins/catalog`,
`/v1/skills`, `/v1/apps/mcp/servers`, `/v1/hooks`, `/v1/commands`), and
`owner` names that route. Per item: `id`, `kind` (`plugin`, `skill`,
`connector`, `hook`, `command`), `name`, `description`, `source`
(`built_in`, `user`, `project`, `other_app`, `runtime`, `plugin`), `state`
(`enabled`, `disabled`, `needs_review`, `needs_sign_in`, `unavailable`) and
`labels` (what the item adds: `skill`, `connector`, `hook`, `command`,
`tool`, `profile`). A plugin item also has `adds` (`skills`, `connectors`,
`hooks`, `commands`, `profiles`: names, not rows), and what a plugin adds is
not listed a second time as an item of its own. A built-in switch is a
`plugin` item with a `tier` and no labels. `/v1/commands` rows carry
`plugin_id` (the same id as `/v1/apps/plugins`, and the `plugin:<id>` item)
for a command a plugin adds, and `workspace_sourced` for one read from the
project; a command item's `plugin` is that id. An owner that fails is named in
`unavailable` and the other kinds still list; `problems` carries the owners'
load warnings. Names under `adds` are file names, so a profile's `id` inside its
file can differ; a command another plugin registers through the extension host
is an item with `source` `plugin`. For an active plugin `adds.commands` comes
from the `/v1/commands` rows; for any other plugin, and for `hooks` and
`profiles`, it is the file names found in the bundle, at most 512 per kind,
with names holding control characters left out; a cut list is reported
under `problems`. A hook's row names no plugin, so `adds.hooks` stays file
names. `tool` means the plugin declares native or language-server components;
a screen a plugin draws is not labelled yet.
**Hooks**
- `GET /v1/hooks[?thread_id=...]` — `{workspace, enabled, hooks: [...],
problems: [...]}`: the hook set Runtime API threads run for that workspace
+1 -2
View File
@@ -54,7 +54,6 @@ registration index.
| POST | `/v1/apps/mcp/servers/{name}/enable` |
| POST | `/v1/apps/mcp/servers/{name}/reconnect` |
| GET | `/v1/apps/mcp/tools` |
| GET | `/v1/apps/plugin-index` |
| GET | `/v1/apps/plugins` |
| DELETE / GET | `/v1/apps/plugins/{selector}` |
| POST | `/v1/apps/plugins/{selector}/disable` |
@@ -264,4 +263,4 @@ registration index.
| GET / POST | `/v1/worktrees` |
| POST | `/v1/worktrees/remove` |
The source contains 244 distinct literal paths in this table.
The source contains 243 distinct literal paths in this table.
+1 -1
View File
@@ -331,7 +331,7 @@ The workhorse. Everything a session accumulated ships here, once.
| `tool_calls` | `execute_tool_with_lock` in `crates/engine/src/core/engine/tool_execution.rs` — surface-agnostic, fires for exec and CLI too |
| `fleet_dispatch` | `create_queued_run_with_descriptor` in `crates/engine/src/fleet/manager.rs` — the single funnel that `create_run` and `create_queued_run` both land in; counting at either caller would double-count a plain `fleet run`. |
| `workflow_run` | bumped in `WorkflowTool::execute` (`crates/engine/src/tools/workflow/mod.rs`) only after `parse_workflow_action` returns an `Ok(WorkflowAction)`, never from `input["action"]`. The JSON Schema `enum` in `WorkflowTool::input_schema` is what is published *to the model* — a declaration, not a guard; the real parse also accepts `spawn\|wait\|list\|inspect\|stop\|abort`, and its reject arm (`Invalid workflow action '…'`) embeds the model string verbatim, so a rejected action is never counted. |
| `subagent_spawn` | `apply_agent_spawned_status_and_observer` in `crates/tui/src/tui/ui/apply.rs` |
| `subagent_spawn` | `spawn_subagent_from_input` in `crates/engine/src/tools/subagent/mod.rs`, after the sub-agent has been registered; surface-agnostic, so `exec` and `serve` count it too. A refused spawn is not counted. |
| `mcp_server_connected` | bumped when a server snapshot's `.connected` is true, in `snapshot_from_config` (`crates/engine/src/mcp.rs`); never `name`, `command_or_url`, or `error` — server names are user-chosen and routinely internal infra |
| `memory_search` | `tool_name == "memory_search"` (the tool registered in `crates/engine/src/tools/native_memory.rs`), counted at the same `execute_tool_with_lock` choke point |
| `approval_modal_shown` | the `Event::ApprovalRequired` arm of `run_event_loop` (`crates/tui/src/tui/ui/event_loop.rs`; the event is defined in `crates/engine/src/core/events.rs`) |
-2
View File
@@ -252,8 +252,6 @@ DSH 的 `allow` 被视为不干预,每个所有者提示一次;不能预先
调用。编辑器通过 ACP(`codewhale serve --acp`)驱动的会话获得同一个扩展宿主,并受相同的
`[features]` 开关(`extension_host`、`mcp`)约束:已审查的插件工具(TypeScript 与 DSH)和
Codewhale 中配置的 MCP 服务器会作为 ACP 工具调用出现,并通过会话的权限设置询问;Plan 保持只读。
插件工具通过 `exec.core` 请求核心执行需要审批的工具时,审批请求显示在该插件
工具的调用上,并写明嵌套工具及其输入。
ACP 服务器的能力声明会说明这一点(`agentCapabilities._meta.codewhale`)。ACP 客户端在
`mcpServers` 中传入的服务器和已配置的 shell hooks 不会挂载。`prepend`/`global` 顺序
覆盖、包裹实际执行的中间件、DSH 运行时句柄和执行后结果改写均不支持。Native
+2 -21
View File
@@ -660,11 +660,7 @@ shell 工具,外加已审查的插件工具(`extension_host` 功能)和 Co
MCP 服务器的工具(`mcp` 功能),均受同一权限设置约束;这一点见于 0.10.2 之后的 main,0.10.2 本身不含。Shell 同时要求编辑器声明
terminal 支持、操作者允许 `allow_shell`;指定的外部沙箱不可用时不提供 shell。此接口
不提供客户端传入的 MCP 服务器、动态工具、任务、PTY、后台 shell、解释器、子智能体
或 RLM 生命周期。ACP 没有工具搜索,因此每个已连接的 MCP 工具及其 schema 都会随每个请求
发送,标记为 `lazy = true` 的服务器永不启动;MCP 集合很大时每回合都会消耗令牌,并可能超出
提供商的工具数量上限。模型在运行时启动的 MCP 服务器不会进入 ACP profile 宿主;但当
`serve --acp` 附着到正在运行的 Runtime owner 时(见下文),其回合使用该 owner 的 MCP 池,
其中包含同一工作区其他会话在运行时启动的服务器。内置工具覆盖会移除整个兼容别名族。
或 RLM 生命周期。内置工具覆盖会移除整个兼容别名族。
最终派发再次校验 profile;伪造别名、hook 改写或目录中缺失的工具不能绕过限制。
Full Access 下 Plan 仍只读。Full Access 与普通审批姿态是服务端拥有的只读选项,
编辑器不能放宽。`approval_policy = "never"` 不是 ACP 审批姿态,服务端会拒绝
@@ -673,9 +669,7 @@ Full Access 下 Plan 仍只读。Full Access 与普通审批姿态是服务端
工具首先显示 `pending`;只有 Core 到达最终派发才显示 `in_progress`。
`completed`/`failed` 和类型化图片块来自实际 Core 结果。审批请求的私有 JSON-RPC ID
绑定同一个 Runtime 铸造的待决审批与 Core 执行 ID。插件工具通过 `exec.core` 请求核心运行
需要审批的工具时,请求显示在该插件工具的调用上,写明嵌套工具及其输入,答复释放嵌套调用,嵌套调用在会话的
权限设置下决定。只有精确匹配、仍有效的
绑定同一个 Runtime 铸造的待决审批与 Core 执行 ID。只有精确匹配、仍有效的
`allow-once` 响应能释放等待器;错误 ID 被忽略,无效选项拒绝,取消会撤销等待器。
ACP 不授予记忆权限或 Native 能力。
@@ -1993,19 +1987,6 @@ POST 前保留预期指纹,并在响应及按键 GET 恢复时核对,之后
保留命令回执——每个提供的处理器对快照都是纯函数——因此客户端不应寻找回执。
任何会修改会话的操作仍是终端侧命令。
**插件索引**
- `GET /v1/apps/plugin-index` — `{workspace, items: [...], unavailable: [...],
problems: [...]}`:跨所有插件、技能、连接器、钩子和手写命令的统一列表。它只是投影:每个条目的
`row` 就是其所属路由(`/v1/apps/plugins`、`/v1/apps/plugins/catalog`、`/v1/skills`、
`/v1/apps/mcp/servers`、`/v1/hooks`、`/v1/commands`)返回的那一行,`owner` 给出该路由。
每个条目有 `id`、`kind`(`plugin`、`skill`、`connector`、`hook`、`command`)、`name`、
`description`、`source`(`built_in`、`user`、`project`、`other_app`、`runtime`、`plugin`)、
`state`(`enabled`、`disabled`、`needs_review`、`needs_sign_in`、`unavailable`)和
`labels`(它添加的内容:`skill`、`connector`、`hook`、`command`、`tool`、`profile`)。插件条目另有 `adds`(`skills`、`connectors`、`hooks`、`commands`、
`profiles`:只含名称),插件所添加的内容不会再作为独立条目重复列出。内置开关是带 `tier`、无 `labels` 的
`plugin` 条目。`/v1/commands` 的行对插件添加的命令带有 `plugin_id`(与 `/v1/apps/plugins` 的 id 相同,即 `plugin:<id>` 条目),对从项目读取的命令带有 `workspace_sourced`;命令条目的 `plugin` 即该 id。某个所属路由失败时记入 `unavailable`,其余种类照常列出;`problems`
汇总各所属路由的加载警告。`adds` 中的名称是文件名,因此档案文件内部的 `id` 可能不同;通过扩展宿主注册的命令是 `source` 为 `plugin` 的条目。对已启用的插件,`adds.commands` 取自 `/v1/commands` 的行;其他插件以及 `hooks`、`profiles` 取自包内的文件名,每类最多 512 个,含控制字符的名称被略去,列表被截断时在 `problems` 中说明。钩子的行不带插件名,因此 `adds.hooks` 仍是文件名。`tool` 表示插件声明了原生或语言服务器组件;插件绘制的界面暂无标签。
**钩子**
- `GET /v1/hooks[?thread_id=...]` — `{workspace, enabled, hooks: [...],
problems: [...]}`:Runtime API 线程为该工作区运行的钩子集合
+19
View File
@@ -0,0 +1,19 @@
import { DocArticle } from "../_components/doc-article";
import { getDocsFirstTime } from "@/lib/i18n/dictionaries";
import { buildPageMetadata } from "@/lib/page-meta";
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
const t = getDocsFirstTime(locale);
return buildPageMetadata({
path: "/docs/first-time",
locale,
title: t.metaTitle,
description: t.metaDescription,
});
}
export default async function FirstTimePage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
return <DocArticle t={getDocsFirstTime(locale)} locale={locale} />;
}
+24
View File
@@ -97,6 +97,30 @@ export default async function InstallPage({ params }: { params: Promise<{ locale
<div lang={copyLocale}>
{locale === "zh" ? mirrors : null}
<Block id="install-new-to-terminal" title={t(INSTALL_COPY.newTitle)}>
{p(INSTALL_COPY.newLead)}
<Channel id="install-new-macos" title={t(INSTALL_COPY.newMacTitle)}>
<ol>
<li><Rich text={t(INSTALL_COPY.newMac1)} /></li>
<li><Rich text={t(INSTALL_COPY.newMac2)} />{code(INSTALL_COMMANDS.shell)}</li>
<li><Rich text={t(INSTALL_COPY.newMac3)} /></li>
</ol>
</Channel>
<Channel id="install-new-windows" title={t(INSTALL_COPY.newWinTitle)}>
<ol>
<li><Rich text={t(INSTALL_COPY.newWin1)} /></li>
<li><Rich text={t(INSTALL_COPY.newWin2)} />{code(INSTALL_COMMANDS.windows)}</li>
<li><Rich text={t(INSTALL_COPY.newWin3)} /></li>
</ol>
</Channel>
<p>
<Link href={`/${locale}/docs/first-time`} className="section-link">
{t(INSTALL_COPY.newNext)}
<Icon name="arrow-right" className="icon icon-flip" />
</Link>
</p>
</Block>
<Block id="install-path" title={t(INSTALL_COPY.pathTitle)}>
{p(INSTALL_COPY.pathPlaces)}
<ul>
+1
View File
@@ -23,6 +23,7 @@
.install-guide ul { list-style: disc; }
.install-guide ol { list-style: decimal; }
.install-guide li { margin-block: var(--space-1); }
.install-guide li > .code { margin-block: var(--space-3); }
.install-guide ::marker { color: var(--muted); }
.install-guide a:not(.btn) { color: var(--accent); text-decoration: underline; text-decoration-color: color-mix(in srgb, var(--accent) 40%, transparent); text-underline-offset: 0.2em; }
.install-guide a:not(.btn):hover { text-decoration-color: var(--accent); }
+43 -12
View File
@@ -1,4 +1,35 @@
[
{
"id": "first-time",
"slug": "first-time",
"sources": [
{
"path": "docs/GUIDE.md",
"sha256": "2fd312552cabc519c990071a16111fe251f902104ebd65f22513039d94e3412d",
"content": "# Codewhale User Guide\n\nThis guide is for your first hour with Codewhale. It explains the main\nworkflow, the important safety controls, and where to go next when you need a\ncomplete reference.\n\nCodewhale has deeper reference documents for installation, configuration,\nproviders, modes, keybindings, tools, and operations. Use this page as a guided\nwalkthrough, then follow the \"Next\" links when you need every option.\n\n## 1. Welcome to Codewhale\n\nCodewhale is a terminal coding agent. You run it from a workspace, give it a\ntask, and it can use structured tools to inspect files, run commands, edit\ncode, and report back with evidence.\n\nThe important difference from a normal chat model is that Codewhale is built\naround a harness:\n\n- It keeps the active workspace and session visible.\n- It routes each turn through explicit modes and approval rules.\n- It shows tool calls in the transcript instead of hiding the work.\n- It can preserve sessions, fork conversations, and continue later.\n- It can run sub-agents for focused background work.\n\nYou can use Codewhale for small questions:\n\n```text\nExplain the authentication flow in this repository.\n```\n\nYou can also use it for multi-step work:\n\n```text\nFind the failing validation path, propose a fix, and wait for my approval\nbefore editing files.\n```\n\nFor a new repository, start conservatively. Ask Codewhale to explore and plan\nbefore asking it to change files. That gives you a reviewable path and makes it\neasier to catch wrong assumptions early.\n\nNext: [ARCHITECTURE.md](ARCHITECTURE.md) explains the internal harness and\nruntime model.\n\n## 2. First Launch\n\nFor a new macOS or Linux installation, use the official GitHub release.\nThe installer verifies the release checksums and provides the same runtime\nunder the `codewhale` and `codew` command names:\n\n```bash\ncurl -fsSL https://codewhale.net/install.sh | sh\n```\n\nWindows users should choose the matching installer or archive from\n[GitHub Releases](https://github.com/codewhale-hq/CodeWhale/releases/latest).\nFor an existing direct install, use `codewhale update --check`, then\n`codewhale update`. npm and Cargo remain secondary packaging routes; Cargo\nalso supports source builds where a compatible prebuilt is unavailable.\nFor occupied directories, package-managed installs, and PATH setup, follow\n[the installation and migration guide](INSTALL.md#recommended-official-github-releases).\nAndroid/Termux uses its own [preview archive or source-build path](INSTALL.md#android--termux-arm64).\n\nDocker is also available when you want an isolated runtime. The published\nimage is currently 0.10.0; to test current `main`, [build it from source](DOCKER.md#building-locally).\n\n```bash\ndocker volume create codewhale-home\ndocker run --rm -it \\\n -e DEEPSEEK_API_KEY=\"$DEEPSEEK_API_KEY\" \\\n -v codewhale-home:/home/codewhale/.codewhale \\\n -v \"$PWD:/workspace\" \\\n -w /workspace \\\n ghcr.io/hmbown/codewhale:latest\n```\n\nOnce the install directory is on PATH, launch Codewhale from the repository or\ndirectory you want it to work in:\n\n```bash\ncodewhale\n```\n\nFor the default GitHub installer destination, you can use\n`\"$HOME/.local/bin/codewhale\"` until that directory is on PATH.\n\nOn first launch, Codewhale opens the composer, even with no key or route.\nWith no key or route, workspace trust is asked once a provider is connected;\notherwise only when the folder requires a decision. A provider is asked for\nwhen you send your first message: the message stays in the box while you sign\nin or use your own key, and a local model is offered only when a local Ollama\nserver answers. The provider step also has an explicit offline route.\n\nEverything optional stays available after that. Use `/setup` for the\nprogressive setup and repair guide, `/settings` for the full typed editor, and\n`/constitution` when you want your own constitution on top of the bundled one.\nThe localized telemetry choice appears only after the workspace is ready and\ndoes not block the composer.\n\nDeepSeek is the default provider. If you want to configure its key before or\nafter the first launch, the most direct setup path is:\n\n```bash\ncodewhale auth set --provider deepseek\n```\n\nYou can also provide a key through the environment:\n\n```bash\nexport DEEPSEEK_API_KEY=\"your-key\"\ncodewhale\n```\n\nNew Codewhale config is stored under `~/.codewhale/config.toml`. Legacy\n`~/.deepseek/config.toml` files are still supported for users migrating from\nthe old name.\n\nUse `/constitution` to review or change standing guidance. After setup, run a\ndoctor check:\n\n```bash\ncodewhale doctor\n```\n\nUse the JSON form when you need a machine-readable report for an issue:\n\n```bash\ncodewhale doctor --json\n```\n\nBoth forms are offline by default. They report structural configuration and\nliteral unknown/not-probed credential states without loading workspace `.env`\ncredentials, openiLine truncated
},
{
"path": "docs/INSTALL.md",
"sha256": "35bc2795284622177f1be7f85544174a4cbd9d2881e52fbdedb6f1262b636815",
"content": "# Installing Codewhale\n\n> 阅读简体中文版:[zh_hans/INSTALL.md](zh_hans/INSTALL.md)\n\nCodewhale is an open-source coding agent that runs in your terminal. You give\nit a task (\"fix the failing test\", \"add a CLI flag\"). It reads your repository,\nedits files and runs commands. In the default **Accept Edits** posture it\napplies file edits inside the repository immediately and shows you the diff,\nand asks before each shell command, so commit or stash anything you care about\nfirst. **Ask** (`Shift+Tab` round the dial, or `/config`) asks before every\nedit as well. It works with many model\nproviders. **DeepSeek** is the default.\n\nThe command is `codewhale`. `codew` is a shorter alias for the same program.\n\nThe installation paths were first checked with **v0.10.0** (released 2026-09-22) on a\nfresh **Ubuntu 24.04 x86_64** machine. The dated\n[install receipts](https://github.com/codewhale-hq/Codewhale/blob/37ecdfcc49bc68a9b0d058b97c3946e62c34bd31/docs/install-report/v0.10.0-2026-09-23/RECEIPTS.md) record the commands and output checked at that time. Steps that\ncould not be run on that machine are marked **(untested on this VM: reason)**.\nmacOS, Windows and Android are out of scope, apart from a few notes. A second\npass re-ran the installer, manual-download, archive and npm paths, the no-key\nchecks and zsh completion on **macOS 26.1 (Apple silicon)**; see\n[macOS notes](#macos-notes). Steps that need a model call were not re-run\nthere.\n\nInstall commands that use `latest` resolve to the latest **published** GitHub\nRelease or package. Between releases, `main` may already describe the next\nversion (for example a v0.10.3 source candidate before its tag). A\nprebuilt candidate is available through the official installer only after its\ntag, checksums and release assets exist. Contributors can build `main` now.\n\n## No account needed\n\nCodewhale needs no account. Keys you save with `/provider` or\n`codewhale auth set` stay on your machine; see\n[API keys and providers](#8-api-keys-and-providers). New Codewhale accounts\nare not open, so this guide does not cover account key storage.\n\n## Test and contribute to current source\n\n`latest` selects the latest published release. To test unreleased repairs,\nbuild canonical GitHub `main`, or CNB `main` after verifying its commit matches.\nThe source version number alone does not establish a published package.\n\n```bash\ngit clone --branch main https://github.com/codewhale-hq/CodeWhale.git\ncd CodeWhale\ncargo build --release --locked -p codewhale-cli\ntarget/release/codewhale --version\n```\n\nSee the Cargo section for build prerequisites; use the repository's stable Rust\nselection (declared minimum 1.89). Windows uses `target/release/codewhale.exe`\nand needs MSVC. A development build is separate from a signed release package.\nFor mainland-China downloads and source validation, see [CNB mirror](CNB_MIRROR.md).\nThe [Tencent Lighthouse bootstrap](../scripts/tencent-lighthouse/README.md) is\nbilingual. An npm registry mirror serves the wrapper; native binary downloads\nstill need an available release asset source.\n\n---\n\n## 60-second quickstart (Linux or macOS)\n\n```bash\n# 1. Install. Downloads two checksum-verified binaries into ~/.local/bin (no sudo).\ncurl -fsSL https://codewhale.net/install.sh | sh\n\n# 2. The installer adds ~/.local/bin to your shell profile (it prints what it added and\n# how to undo it; --no-modify-path skips that). Shells that are already open do not\n# reread the profile, so use it in this one now, then check it.\nexport PATH=\"$HOME/.local/bin:$PATH\"; hash -r\ncodewhale --version # -> codewhale 0.10.0 (1be1a703b975)\n\n# 3. Give it a DeepSeek API key (from https://platform.deepseek.com/api_keys).\ncodewhale auth set --provider deepseek # prompts for the key; nothing is echoed\ncodewhale auth status --provider deepseek # \"active source: secret store\"\n\n# 4. Run your first task inside a git repository.\ncd ~/your-project\ncodewhale\n```\n\nIn the TUI, type something concrete:\n\n```text\ncreate a Python file primes.py that prints the first 10 primes, run it, and show me the output\n```\n\nCodewhale writes the file, shows you a diff, then asks **APPROVAL: bash\npython3 primes.py – Do you want to proceed?** Press `y` to allow it once. It\nruns the command and reports the output. Press `Ctrl-D` (with an empty input\nbox) to quit. It prints the command to resume the session later.\n\n> **If nothing happens after you send your first message,** you have not\n> connected a model. v0.10.2 shows `no model connected · run /provider` on its\n> first screen and returns your first message to the composer. Type `/provider`,\n> choose a provider or paste a Custom endpoint, then send it again. If messages say\n> they were saved for later after you connect, quit with Ctrl+D and run `codewhale` again.\n\n---\n\n## Contents\n\n1. [Before you start](#1-before-you-start)\n2. [Install: rLine truncated
},
{
"path": "docs/MODES.md",
"sha256": "bfd3ed8cae274f9b72d1805232f41d4342cc29b45b53617a26f06c3776941202",
"content": "# Modes and Permission Postures\n\n> 阅读简体中文版:[zh_hans/MODES.md](zh_hans/MODES.md)\n\nCodewhale has three related concepts:\n\n- **TUI mode**: what kind of visible interaction you're in (Plan/Work).\n- **Permission posture**: how aggressively the UI asks before executing tools.\n- **Workflow**: named steps that coordinate sub-agents, with dependencies and\n results, available in any TUI mode.\n\nModel selection is separate. `--model auto` and `/model auto` route each turn to\na concrete model and thinking level; they are not TUI modes and are not on the\npermission dial.\n\nWorkflow runs named steps through the same sub-agent runtime; Fleet manages\ntheir saved roles and model assignments. A workflow adds ordering, result\nhandoffs, gates and a progress view. The active mode and permission posture\nstill control what each step may execute.\n\nBefore assigning steps, query `agent(action=\"roster\")` for the saved Fleet\nmodels and roles. A plan child can select a listed model with `model`, or use\nthe saved `role`/`profile` assignment. An Exact Fleet fixes each member's route.\n\n## TUI Modes\n\nOne dial says how much Codewhale may do without asking. Press `Shift+Tab` to\nstep it: **Ask → Accept Edits → Plan → Auto-Review → Full Access → Ask**. Plan\nis the Plan mode; every other stop is Work with that permission posture. The\nfooter shows the current stop and `Shift+Tab to change`. New installs start on\nAccept Edits when no permission posture is saved. An explicit saved choice,\nincluding Ask from an earlier version, stays in control.\n`Tab` only completes composer menus, commands, file mentions and suggestions;\nit never changes the mode, and never sends or queues composer text (use\n`Enter`).\nEvery change of effective permission writes a `Permissions changed to …` line\nin the transcript.\nPress `Ctrl+T` to cycle reasoning effort.\nRun `/mode` to open the mode picker, or switch directly with `/mode work` or\n`/mode plan`. `operate` and its spellings still parse, as Work, and say that\nOperate was retired, so an older setting or script never errors.\n\n- **Plan**: design-first prompting. The stable primitive names remain familiar, but the runtime centrally refuses file mutation and shell execution. Read-only inspection and policy-allowed research, including deferred Web search/fetch, remain available. When a Plan turn successfully finishes a nonempty response, including a prose-only plan, the TUI asks how to continue: Work (Ask), Work (Accept Edits), Work (Auto-Review), or keep planning. Work sends the exact approved response as a normal follow-up and preserves that turn's existing To-dos, or adds one pending plan item without replacing unrelated work.\n- **Work** (internally `agent`): ordinary multi-step execution. The first-turn toolbox includes `read`, `write`, `edit`, `file_search`, `grep_files`, `bash`, `agent`, `todo_write`, `present`, and `load_skill`, plus `create_goal`, `get_goal`, `update_goal`, and `task_shell_wait` so goal and background-shell controls are available without discovery. The agent calls `create_goal` when a request is a durable objective; the host never infers a goal from wording, and the contract must not tell the model otherwise. Approval, sandbox, repository law, and managed policy decide what may execute.\n- **Operate is retired.** It was Work with an orchestration contract, and that contract now lives in every session's constitution: make a goal of a durable request, keep the plan visible, fan independent work out to parallel sub-agents, and have an independent reviewer try to refute a non-trivial change. A saved `default_mode = \"operate\"` loads as Work and says so once; a Runtime API request with `mode: \"operate\"` runs as `agent` with the default posture (Accept Edits) and logs one notice per process, never an error. Agents forked from an older Operate conversation do not inherit its contract. Multi-agent work stays on `agent`, `workflow` and `/fleet` in every mode.\n\n`Act` and `/mode act` remain compatibility aliases for Work. Saved settings\nstill normalize to the internal value `agent`.\n\n### Tool availability by mode\n\n| Tool family | Plan | Work |\n|:---|:---:|:---:|\n| `read` and policy-allowed deferred research tools | yes | yes |\n| `write` and `edit` | visible names; execution denied | approval- and policy-gated |\n| `bash` | visible name; execution denied | approval- and policy-gated; delegation is preferred when parallelism or isolation helps |\n| `agent` | yes, subject to child-depth authority | yes, subject to child-depth authority |\n| Deferred native, MCP, and plugin tools | discoverable through `tool_search` when policy permits | same |\n| Paid or external-service tools | follows permission posture | follows permission posture |\n| Access outside the workspace root | explicit trusted paths only | only through trusted paths or trust mode; fleet profiles never widen it |\n\nMode changes scheduling emphasLine truncated
},
{
"path": "docs/SANDBOX.md",
"sha256": "1d765dc9b89ff76aedd512a49983058fae999e25fc5dd84868f38f268d00b0b1",
"content": "# Sandbox threat model\n\n> 阅读简体中文版:[zh_hans/SANDBOX.md](zh_hans/SANDBOX.md)。\n\nCodewhale can launch shell commands proposed by a model. Approval policy,\nworkspace-aware tools, and an operating-system command wrapper are separate\ncontrols: an approval is not a sandbox, and selecting `workspace-write` does\nnot prove that the current platform has an OS wrapper available.\n\nThis document describes only behavior wired into the command execution path.\nSee [Authorization order](AUTHORIZATION_ORDER.md) for the policy layers that\nrun before execution reaches this boundary.\n\n## Platform overview\n\n| Mechanism | Platform | Selection | What Codewhale reports |\n|---|---|---|---|\n| Seatbelt (`sandbox-exec`) | macOS | Automatic when the runtime probe succeeds | `macos-seatbelt` |\n| Bubblewrap (`/usr/bin/bwrap`) | Linux | Default when installed and a wrapped probe run succeeds; `prefer_bwrap = false` opts out | `linux-bwrap` |\n| No OS wrapper | Linux without usable bwrap, or opted out | `prefer_bwrap = false`, or bwrap absent/unusable | `none` |\n| No OS wrapper | Windows | Current implementation | `none` |\n| OpenSandbox-compatible service | Any supported host | `sandbox_backend = \"opensandbox\"` | External execution path |\n\nThe repository also contains a future Windows helper contract. It is not\nwired into child-command launch, so Codewhale does not advertise it as an\nactive sandbox. Source-only sandbox code is not evidence that a command was\nrestricted.\n\n## macOS: Seatbelt\n\nCodewhale probes `/usr/bin/sandbox-exec` by running a minimal profile. When the\nprobe succeeds and the selected `SandboxPolicy` requests a sandbox, the child\ncommand is wrapped with a generated Seatbelt profile.\n\nThe profile can provide:\n\n- broad filesystem reads;\n- writes limited by the selected policy, including the workspace and specific\n runtime/cache paths needed by supported tools;\n- network access only when the policy enables it. A `read-only` policy with\n network (the agent `read_only: true` shell mode) gets outbound IPv4 and the\n system DNS resolver only. IPv6 and the host's own addresses, loopback\n included, are denied, except the port of a local proxy named in\n `HTTP_PROXY`, `HTTPS_PROXY` or `ALL_PROXY`, and other local sockets stay\n denied.\n\nIf the probe fails or `sandbox-exec` is unavailable, Codewhale reports no OS\nsandbox and launches the command without a Seatbelt wrapper. It does not set a\nSeatbelt marker on that fallback.\n\n## Linux: default bubblewrap\n\nLinux command sandboxing is on by default whenever bubblewrap works. Set the\ntop-level configuration key to opt out:\n\n```toml\nprefer_bwrap = false\n```\n\nCodewhale selects bubblewrap only when `/usr/bin/bwrap` is a regular\nexecutable file AND a wrapped probe run proves it can create its namespaces\non this host — the exec bit alone lies where user namespaces are restricted\n(e.g. Ubuntu 24.04 with `kernel.apparmor_restrict_unprivileged_userns`),\nwhere every wrapped command would fail rather than run unsandboxed. The\nwrapper derives its mounts and network namespace from the resolved\n`SandboxPolicy`:\n\n```text\n/usr/bin/bwrap \\\n --unshare-all \\\n [--share-net] \\\n --ro-bind / / \\\n --dev /dev \\\n --proc /proc \\\n --tmpfs /tmp \\\n [--dev-bind <device-root> <device-root> ...] \\\n --bind <writable-root> <writable-root> ... \\\n --ro-bind <protected-descendant> <protected-descendant> ... \\\n [--ro-bind <extra-ro-root> <extra-ro-root> ...] \\\n --chdir <cwd> \\\n -- <program> <args>\n```\n\nThe sandbox always gets a private `/dev` (fresh device nodes, so `>/dev/null`\nworks), a private `/proc`, and a tmpfs `/tmp` (#5410). A `workspace-write`\npolicy then binds the host `/tmp` (and `TMPDIR`) over that tmpfs unless it\nexcludes them, so by default a sandboxed command shares the host's `/tmp`,\nwritable, with the usual sticky-bit rules. Two optional top-level\nconfig keys extend the mounts: `bwrap_ro_roots` (extra host paths bind-mounted\nread-only, applied last so they can narrow a policy-writable path) and\n`bwrap_dev_roots` (host character/block device nodes bind-mounted read-write;\ndirectories are never honored). Missing paths are skipped silently.\n\nThat gives the child a read-only root view. For `workspace-write`, every safe,\nexisting policy root is mounted read-write: the working directory, configured\nadditional roots, `/tmp` and `TMPDIR` unless excluded, and verified Git\nworktree metadata roots. Existing `.codewhale` and `.deepseek` descendants are\nremounted read-only after their writable parent. Missing paths, non-directory\npaths, and `/` are not promoted to writable mounts.\n\nFor `read-only`, there are no writable binds, so the working directory remains\ninside the read-only root view. `--unshare-all` isolates the network namespace\nby default. Codewhale adds `--share-net` only when the policy's\n`network_access` is true. The agent `read_only: true` sheLine truncated
},
{
"path": "docs/KEYBINDINGS.md",
"sha256": "5b3a71595db86ed30eb79675c2b934d60dce897f0202142794be849537a36da9",
"content": "# Keybindings\n\n> 阅读简体中文版:[zh_hans/KEYBINDINGS.md](zh_hans/KEYBINDINGS.md)\n\nThis is the source-of-truth catalog of every keyboard shortcut the TUI recognizes. Bindings are grouped by **context** — the focus or modal state they fire in. A binding listed under \"Composer\" only takes effect when the composer is focused; one under \"Transcript\" only when the transcript has focus; and so on.\n\nGlobal key chords are not yet user-configurable — tracked for a future release (#436, #437). Hotbar slot actions are configurable with `[[hotbar]]` and `/hotbar`; the Hotbar activation chord remains `Alt-1` through `Alt-8`.\n\n## Global (any context)\n\n| Chord | Action |\n|----------------------|---------------------------------------------------------------|\n| `/help`, `F1` or `Ctrl-/` | Toggle the help overlay. `/help` is what the chrome advertises: it is the one route every terminal delivers, where `F1` is eaten by tmux and some emulators and `Ctrl-/` encoding varies |\n| `F2` | Toggle the typed Settings editor |\n| `F3` | Open the provider/model picker (same as `/provider`) |\n| `Ctrl-K` | Open the command palette (slash-command finder) |\n| `Ctrl-C` | Cancel current turn / dismiss modal / clear a non-empty draft (recoverable — see `Ctrl-Z`) / arm-then-confirm quit |\n| `Ctrl-B` | Release the current shell wait so the turn can continue; foreground commands move into `/jobs`, and background tasks keep running. Use `/jobs` or `Bash` with `action: \"wait\"` to inspect output |\n| `Ctrl-D` | Quit (only when the composer is empty) |\n| `Tab` | Complete a slash command, `@` file mention or prompt suggestion. Tab never changes the mode |\n| `Shift+Tab` | Step the permission dial: Ask → Accept Edits → Plan → Auto-Review → Full Access → Ask. Plan is the Plan mode; every other stop is Work with that posture. Live regardless of composer contents or whether a turn is running (suppressed only while a modal other than Config is open). During a turn the change, including into or out of Plan, applies at the agent's next step; a call you already approved runs only if the new posture is equal or broader. Model, thinking level and provider stay locked until the turn ends |\n| `Ctrl-T` | Cycle reasoning effort for the active model. Walks the same ladder as `/model` and `/effort` (catalog or documented route dialect). Always-thinking models omit `off`; Grok 4.6 includes `xhigh`. |\n| `Ctrl-Shift-T` | Toggle live transcript overlay (sticky-tail auto-scroll) |\n| `Ctrl-R` | Open the resume-session picker |\n| `Ctrl-L` | Compact the conversation context (status line shows progress; no-op while a compaction is already running) |\n| `Ctrl-O` | Open the reasoning detail for the selected or current turn, regardless of composer contents |\n| `Ctrl-Alt-O` | Open the whole-turn Turn Inspector, regardless of composer contents |\n| `Alt-V` / `Option-V` (macOS) | Open the details pager for the selected tool/sub-agent card; with nothing selected, the newest visible error, else the first visible card with details, else the most recent one. Click a card first to keep Alt-V on it while newer cards arrive. Terminals that emit the legacy Option-V glyph are also handled |\n| `Ctrl-Shift-E` / `Cmd-Shift-E` | Toggle the file tree in the workbar |\n| `Ctrl-]` or `Ctrl-Tab` | Cycle the pet view while the pet main view is showing; otherwise cycle the work dock: TODO → Agents → Jobs → Background. `Ctrl-]` is portable; `Ctrl-Tab` requires the kitty keyboard protocol. Terminal.app uses it to switch terminal tabs |\n| `Ctrl-Shift-Tab` | Cycle the pet view or work dock backwards where the terminal sends the kitty keyboard chord. Terminal.app uses it to switch terminal tabs |\n| `Alt-G` / `Alt-Shift-G` | Scroll transcript to top / bottom when the composer is empty |\n| `Alt-1`-`Alt-8` | Dispatch Hotbar slots 1-8 when no modal or inline picker is open |\n| `Alt-!` / `Alt-@` / `Alt-#` / `Alt-$` | Select the workbar panel: Tasks / Agents / Context / Files |\n| `Ctrl-Alt-0` | Toggle the workbar off / back to the bottom placement |\n| `Alt-L` | Open the pager for the last message (composer empty) |\n| `Alt-P` / `Alt-A` / `Alt-Y` | Jump to Plan / Work, or request Full Access (`Alt-Y` is the legacy permission channel — Work + Full Access — not a separate mode; it honors a locked approval policy) |\n| `Ctrl-X` (Activity workbar) | Cancel all running background shell jobs |\n| `Esc` | Close topmost modal · cancel slash menu · dismiss toast. During Line truncated
}
]
},
{
"id": "install",
"slug": "install",
@@ -228,8 +259,8 @@
},
{
"path": "docs/TELEMETRY.md",
"sha256": "0ac65dbd0e1d9d196d1c4421116852c3ab6a855062c375c25b09db993084c563",
"content": "# Codewhale product telemetry\n\n> 阅读简体中文版:[zh_hans/TELEMETRY.md](zh_hans/TELEMETRY.md)\n\n**Usage analytics are on by default in the current source.** The first\ninteractive launch gives a localized, nonblocking disclosure naming **Codewhale\nand PostHog**, with a direct route to turn counting off. Notice version `6`\ndescribes this policy; displaying it is not recorded as human acceptance.\nMissing an older acceptance record does not turn a fresh installation off.\nExisting explicit opt-outs remain off across upgrades, and unreadable privacy\nstate fails closed. Environment and CLI kill switches still take precedence.\n\nRead the disclosure with `codewhale config telemetry`. Turn usage off in\n`/settings` or run `codewhale config set telemetry false`. Turning it back on\nin Settings or with `codewhale config set telemetry true` updates the existing\ndurable preference and privacy records for new sessions. The compatibility\ncommand `codewhale config telemetry --accept-notice 5` remains available, but\nis not required for a fresh installation. Collection never includes work content or credentials.\n\nThe PostHog sink is optional and inert until the ingest operator explicitly\nconfigures a project token and approved regional host, and records staging\nproof that the actual egress path does not forward the original client IP.\nSource readiness does not establish a deployment, an activated processor,\nan egress receipt, or a retention setting.\n\nCodewhale does not collect conversations, code, prompts, files, file/repo/branch\nnames, model content, or credentials. It sends no per-turn or per-tool timeline.\nIt does send the closed, aggregate schema below: version and platform classes,\nsession duration/outcome, feature/error counters, and a random install id that\nrotates every 90 days.\n\n**There is now a real endpoint.** An enabled session sends its batches to the\nfirst-party ingest service at `https://telemetry.codewhale.net/v1/telemetry`,\nwhich is the shipped default for `telemetry_endpoint`. What that service is,\nwhat it stores, and what it structurally cannot store is spelled out in \"What\nthe endpoint does\" below.\n\n**To send nothing anywhere, turn telemetry off** (see \"Turning it off\"). To stay\nenabled but contact nobody, set `telemetry_endpoint = \"\"`: batches are then\nappended to `$CODEWHALE_HOME/telemetry/dryrun.jsonl` on your own machine, byte\nfor byte what the server would have received, and no HTTP client is ever\nconstructed. That file is how you audit this document against reality.\n\nThis document is the schema. It is not a summary of the schema: a test in\n`crates/telemetry` parses the field names out of this file and asserts set\nequality against the structs the serializer actually uses, so a field that is\nhere and not in the code — or in the code and not here — fails the build.\n\nLocal saved sessions and `codewhale metrics` remain available with telemetry\ndisabled and require no account or hosted reporting service. Disabling telemetry\ndoes not disable local usage receipts.\n\n## Turning it off\n\nThere are two off switches and they do different things. Both stop collection\ncompletely; only one of them erases anything.\n\n```sh\ncodewhale config set telemetry false # opt out: stops collection and erases state\nCODEWHALE_TELEMETRY=0 codewhale # kill switch: stops collection, erases nothing\nDO_NOT_TRACK=1 codewhale # the same kill switch, by the common convention\ncodewhale --telemetry false # the same kill switch, for one command\n```\n\n**`telemetry = false` in the config file is the opt-out.** It is a floor:\n`--telemetry true` and `CODEWHALE_TELEMETRY=1` both lose to it, because a\nsetting you can undo by accident from a wrapper script is not a setting. It\ndeletes the random install id, truncates every buffered event and every dry-run\nrecord, and writes a tombstone. Appends, identity/state writes, and delivery all\nshare the wipe's ordering lock, so once opt-out returns no pre-opt-out write or\nPOST remains in flight. If any part of that wipe fails, the tombstone is still\nthere and the buffer is undrainable — a failed wipe fails closed. Every later\nrun re-asserts the same tombstone for as long as the setting stands, so it\nsurvives. Turning telemetry back on in `/settings` records that explicit preference\nin both existing privacy registers; a fresh launch can then clear the tombstone. Nothing buffered before that point is\never sent.\n\n**The environment variable and the flag are kill switches, not opt-outs.**\nTelemetry is off for the run, nothing is written, nothing is sent — and\nnothing on disk is touched or deleted. That is deliberate: a harness or agent\nthat sets `CODEWHALE_TELEMETRY=0` for one command must not silently discard the\ninstall id and the dry-run records of the person who owns the machine. If you\nwant the erasing kind, use the config file.\n\n`CODEWHALE_TELEMELine truncated
"sha256": "fbc3a64c83e0ba69df0b856a80bfd1e4aa67283bf680fa40e7b0b073fed2983f",
"content": "# Codewhale product telemetry\n\n> 阅读简体中文版:[zh_hans/TELEMETRY.md](zh_hans/TELEMETRY.md)\n\n**Usage analytics are on by default in the current source.** The first\ninteractive launch gives a localized, nonblocking disclosure naming **Codewhale\nand PostHog**, with a direct route to turn counting off. Notice version `6`\ndescribes this policy; displaying it is not recorded as human acceptance.\nMissing an older acceptance record does not turn a fresh installation off.\nExisting explicit opt-outs remain off across upgrades, and unreadable privacy\nstate fails closed. Environment and CLI kill switches still take precedence.\n\nRead the disclosure with `codewhale config telemetry`. Turn usage off in\n`/settings` or run `codewhale config set telemetry false`. Turning it back on\nin Settings or with `codewhale config set telemetry true` updates the existing\ndurable preference and privacy records for new sessions. The compatibility\ncommand `codewhale config telemetry --accept-notice 5` remains available, but\nis not required for a fresh installation. Collection never includes work content or credentials.\n\nThe PostHog sink is optional and inert until the ingest operator explicitly\nconfigures a project token and approved regional host, and records staging\nproof that the actual egress path does not forward the original client IP.\nSource readiness does not establish a deployment, an activated processor,\nan egress receipt, or a retention setting.\n\nCodewhale does not collect conversations, code, prompts, files, file/repo/branch\nnames, model content, or credentials. It sends no per-turn or per-tool timeline.\nIt does send the closed, aggregate schema below: version and platform classes,\nsession duration/outcome, feature/error counters, and a random install id that\nrotates every 90 days.\n\n**There is now a real endpoint.** An enabled session sends its batches to the\nfirst-party ingest service at `https://telemetry.codewhale.net/v1/telemetry`,\nwhich is the shipped default for `telemetry_endpoint`. What that service is,\nwhat it stores, and what it structurally cannot store is spelled out in \"What\nthe endpoint does\" below.\n\n**To send nothing anywhere, turn telemetry off** (see \"Turning it off\"). To stay\nenabled but contact nobody, set `telemetry_endpoint = \"\"`: batches are then\nappended to `$CODEWHALE_HOME/telemetry/dryrun.jsonl` on your own machine, byte\nfor byte what the server would have received, and no HTTP client is ever\nconstructed. That file is how you audit this document against reality.\n\nThis document is the schema. It is not a summary of the schema: a test in\n`crates/telemetry` parses the field names out of this file and asserts set\nequality against the structs the serializer actually uses, so a field that is\nhere and not in the code — or in the code and not here — fails the build.\n\nLocal saved sessions and `codewhale metrics` remain available with telemetry\ndisabled and require no account or hosted reporting service. Disabling telemetry\ndoes not disable local usage receipts.\n\n## Turning it off\n\nThere are two off switches and they do different things. Both stop collection\ncompletely; only one of them erases anything.\n\n```sh\ncodewhale config set telemetry false # opt out: stops collection and erases state\nCODEWHALE_TELEMETRY=0 codewhale # kill switch: stops collection, erases nothing\nDO_NOT_TRACK=1 codewhale # the same kill switch, by the common convention\ncodewhale --telemetry false # the same kill switch, for one command\n```\n\n**`telemetry = false` in the config file is the opt-out.** It is a floor:\n`--telemetry true` and `CODEWHALE_TELEMETRY=1` both lose to it, because a\nsetting you can undo by accident from a wrapper script is not a setting. It\ndeletes the random install id, truncates every buffered event and every dry-run\nrecord, and writes a tombstone. Appends, identity/state writes, and delivery all\nshare the wipe's ordering lock, so once opt-out returns no pre-opt-out write or\nPOST remains in flight. If any part of that wipe fails, the tombstone is still\nthere and the buffer is undrainable — a failed wipe fails closed. Every later\nrun re-asserts the same tombstone for as long as the setting stands, so it\nsurvives. Turning telemetry back on in `/settings` records that explicit preference\nin both existing privacy registers; a fresh launch can then clear the tombstone. Nothing buffered before that point is\never sent.\n\n**The environment variable and the flag are kill switches, not opt-outs.**\nTelemetry is off for the run, nothing is written, nothing is sent — and\nnothing on disk is touched or deleted. That is deliberate: a harness or agent\nthat sets `CODEWHALE_TELEMETRY=0` for one command must not silently discard the\ninstall id and the dry-run records of the person who owns the machine. If you\nwant the erasing kind, use the config file.\n\n`CODEWHALE_TELEMELine truncated
},
{
"path": "docs/public-surface-facts.json",
@@ -323,8 +354,8 @@
},
{
"path": "docs/EXTENSIONS.md",
"sha256": "8582de93c01083314119639678c1ea1ab4ba46317c5a400fb9dd139ffdcb232a",
"content": "# Writing an extension tool or command\n\nThe experimental TypeScript extension host runs reviewed plugin code in a\nshared Node process (or, as an opt-in, Bun). Rust still owns sessions, tool admission, approval and\nexecution. Extensions currently contribute tools and slash commands; they\ncannot provide an approval service, run a second agent loop or replace\nbuilt-in tools or commands.\n\n## Quick start\n\nStart with [hello-extension](examples/plugins/hello-extension/hello.mts) and its\n[plugin.json](examples/plugins/hello-extension/plugin.json). The example is\nexecuted unchanged by the host tests. It returns a greeting and does not read\nfiles or use the network.\n\n1. Explicitly enable `[features] extension_host = true` in your configuration,\n or start Codewhale with `codewhale --enable extension_host` (the flag goes\n before any subcommand).\n2. Install the example's directory using `/plugin install <local-directory>`.\n3. Run `/plugin validate hello-extension`, then `/plugin show hello-extension`.\n4. Run `/plugin enable hello-extension` to open its content/capability review.\n Read the review and personally run its exact `/plugin trust <token>` command,\n then enable the plugin. Trust alone does not execute the code.\n5. Ask Codewhale to use `hello_greet`. The tool follows the normal approval gate.\n6. Run `/hello-greet Codewhale`. The command runs when you type it, and shows\n its answer in the transcript with its origin, `extension:hello-extension`.\n\nAn authoring agent should stop after installation and validation and present\nthe review to the person, unless it runs in Full Access and the plugin is one\nit wrote and installed from a folder in its project: then it may trust and\nenable it for that project with `codewhale plugins trust` and `enable`. Those\nshell commands go through the session's approval like any other: Ask asks the\nperson, Auto-Review's reviewer judges, and Full Access runs them.\n\n## Locations, installation and trust\n\nUser bundles live under `~/.codewhale/plugins/`; workspace bundles live under\n`<workspace>/.codewhale/plugins/`. New bundles use Agent Plugins v1.0.0\n`plugin.json`. Declare `extensions[\"net.codewhale\"].native.path` as one regular\n`.mjs`, `.js` or `.mts` file inside the bundle. Directories and other extensions\nfail validation when the host is enabled. With the flag off, native entries\nremain inventory-only.\n\nA plugin may split its code across several entries with `native.paths` (up to\n64, alone or beside `path`). They are activated in the order declared, as the\nfibers of one plugin: they share one owner, so one disable, review change or\ncrash tears all of them down together. Each entry is a separate module with its\nown `apply`, and tool and command names must be unique across them. If an entry\nfails to activate (throws, requires a service the host does not provide, or has\na registration refused), only that entry is withdrawn: entries that already\nactivated stay registered, later entries still activate, and the plugin fails\nonly when no entry activates. Listing the same file twice is one entry.\n\n## Runtime\n\n`[extension_host] runtime` selects what runs the host. Node is the default.\nBun is an opt-in: it is qualified on macOS only, and becomes the default only\nafter an explicit, recorded cutover.\n\n```toml\n[extension_host]\nruntime = \"node\" # default: Node only\n# runtime = \"bun\" # Bun only; fails rather than falling back to Node\n# runtime = \"auto\" # Bun >= 1.4.0 if one is found and starts, otherwise Node\n# bun = \"~/.bun/bin/bun\" # the only Bun tried when set\n# node = \"/opt/homebrew/bin/node\" # the only Node tried when set\n# mcp_backend = \"rust\" # temporary opt-out; default is \"host\"\n```\n\nThe default `mcp_backend = \"host\"` uses the pinned MCP SDK independently of\n`[features] extension_host`, which controls optional Native extensions. Existing\nstdio and HTTP servers use modern discovery when supported; explicitly configured\nlegacy SSE servers use their legacy handshake. Rust retains process and network\nauthority, credential resolution, catalog admission, tool approval and cancellation.\nThe SDK receives opaque session selectors and exact operation grants. Modern\nrequests carry Rust-approved protocol metadata and headers without a session id.\n\nA typed discovery refusal can admit a legacy handshake. Only an explicit HTTP\ntransport refusal during that handshake can admit legacy SSE with a fresh exact\nRust grant. Legacy HTTP retains its GET preflight and bounded OAuth refresh;\nmodern HTTP does not inherit those session or resend behaviors. Incomplete result\nflows are reported without automatically repeating the operation. Credentials,\nconfigured URLs, OAuth browser/login operations and Computer Use decision keys\nstay in Rust.\n\nBoth backends share recorded semantic MCP goldens and retain separate transport\ngoldens, including discovery, authentication and sessioLine truncated
"sha256": "45dc87bc8293156cc221a0d1181ddcdfe6970caef3874d2127a5b86ba2b32d9d",
"content": "# Writing an extension tool or command\n\nThe experimental TypeScript extension host runs reviewed plugin code in a\nshared Node process (or, as an opt-in, Bun). Rust still owns sessions, tool admission, approval and\nexecution. Extensions currently contribute tools and slash commands; they\ncannot provide an approval service, run a second agent loop or replace\nbuilt-in tools or commands.\n\n## Quick start\n\nStart with [hello-extension](examples/plugins/hello-extension/hello.mts) and its\n[plugin.json](examples/plugins/hello-extension/plugin.json). The example is\nexecuted unchanged by the host tests. It returns a greeting and does not read\nfiles or use the network.\n\n1. Explicitly enable `[features] extension_host = true` in your configuration,\n or start Codewhale with `codewhale --enable extension_host` (the flag goes\n before any subcommand).\n2. Install the example's directory using `/plugin install <local-directory>`.\n3. Run `/plugin validate hello-extension`, then `/plugin show hello-extension`.\n4. Run `/plugin enable hello-extension` to open its content/capability review.\n Read the review and personally run its exact `/plugin trust <token>` command,\n then enable the plugin. Trust alone does not execute the code.\n5. Ask Codewhale to use `hello_greet`. The tool follows the normal approval gate.\n6. Run `/hello-greet Codewhale`. The command runs when you type it, and shows\n its answer in the transcript with its origin, `extension:hello-extension`.\n\nAn authoring agent should stop after installation and validation and present\nthe review to the person, unless it runs in Full Access and the plugin is one\nit wrote and installed from a folder in its project: then it may trust and\nenable it for that project with `codewhale plugins trust` and `enable`. Those\nshell commands go through the session's approval like any other: Ask asks the\nperson, Auto-Review's reviewer judges, and Full Access runs them.\n\n## Locations, installation and trust\n\nUser bundles live under `~/.codewhale/plugins/`; workspace bundles live under\n`<workspace>/.codewhale/plugins/`. New bundles use Agent Plugins v1.0.0\n`plugin.json`. Declare `extensions[\"net.codewhale\"].native.path` as one regular\n`.mjs`, `.js` or `.mts` file inside the bundle. Directories and other extensions\nfail validation when the host is enabled. With the flag off, native entries\nremain inventory-only.\n\nA plugin may split its code across several entries with `native.paths` (up to\n64, alone or beside `path`). They are activated in the order declared, as the\nfibers of one plugin: they share one owner, so one disable, review change or\ncrash tears all of them down together. Each entry is a separate module with its\nown `apply`, and tool and command names must be unique across them. If an entry\nfails to activate (throws, requires a service the host does not provide, or has\na registration refused), only that entry is withdrawn: entries that already\nactivated stay registered, later entries still activate, and the plugin fails\nonly when no entry activates. Listing the same file twice is one entry.\n\n## Runtime\n\n`[extension_host] runtime` selects what runs the host. Node is the default.\nBun is an opt-in: it is qualified on macOS only, and becomes the default only\nafter an explicit, recorded cutover.\n\n```toml\n[extension_host]\nruntime = \"node\" # default: Node only\n# runtime = \"bun\" # Bun only; fails rather than falling back to Node\n# runtime = \"auto\" # Bun >= 1.4.0 if one is found and starts, otherwise Node\n# bun = \"~/.bun/bin/bun\" # the only Bun tried when set\n# node = \"/opt/homebrew/bin/node\" # the only Node tried when set\n# mcp_backend = \"rust\" # temporary opt-out; default is \"host\"\n```\n\nThe default `mcp_backend = \"host\"` uses the pinned MCP SDK independently of\n`[features] extension_host`, which controls optional Native extensions. Existing\nstdio and HTTP servers use modern discovery when supported; explicitly configured\nlegacy SSE servers use their legacy handshake. Rust retains process and network\nauthority, credential resolution, catalog admission, tool approval and cancellation.\nThe SDK receives opaque session selectors and exact operation grants. Modern\nrequests carry Rust-approved protocol metadata and headers without a session id.\n\nA typed discovery refusal can admit a legacy handshake. Only an explicit HTTP\ntransport refusal during that handshake can admit legacy SSE with a fresh exact\nRust grant. Legacy HTTP retains its GET preflight and bounded OAuth refresh;\nmodern HTTP does not inherit those session or resend behaviors. Incomplete result\nflows are reported without automatically repeating the operation. Credentials,\nconfigured URLs, OAuth browser/login operations and Computer Use decision keys\nstay in Rust.\n\nBoth backends share recorded semantic MCP goldens and retain separate transport\ngoldens, including discovery, authentication and sessioLine truncated
}
]
},
@@ -334,8 +365,8 @@
"sources": [
{
"path": "docs/RUNTIME_API.md",
"sha256": "e3040635df0cf62717f5acbe9aacf4909b6ad60db8468b06e90a5e9be1d76d1e",
"content": "# Codewhale Engine Runtime API & Integration Contract\n\n> 阅读简体中文版:[zh_hans/RUNTIME_API.md](zh_hans/RUNTIME_API.md)。\n\n`codewhale app-server --http` exposes the [Codewhale Engine](ARCHITECTURE.md) through\nits canonical local Runtime API and control plane.\nLocal SDKs, mobile/remote-control clients, and editor integrations talk to it\ninstead of screen-scraping terminal output. It serves the full HTTP/SSE runtime\nAPI (`/v1/*`), a JSON-RPC control transport over stdio, and the phone-friendly\nmobile page. `codewhale doctor --json` provides machine-readable health, and\n`codewhale serve --acp` speaks the Agent Client Protocol over stdio for editors\nsuch as Zed.\n\n`codewhale serve --http` / `serve --mobile` remain as **compatibility aliases**\nfor `codewhale app-server --http` / `--mobile`; both launch the identical\nserver. Desktop, web, SDK and custom integrations should target\n`app-server --http`. `--stdio` and `--socket` expose compatibility controls.\nBare `app-server` selects initialized JSONL control over stdio. Local stdio,\nsocket and ACP owners do not bind a hidden TCP listener. Explicit `--http`\nretains HTTP/SSE and compatibility routes on the same Runtime owner.\n\n`codewhale exec` is the separate one-shot headless worker path (stream-json,\nfleet worker subprocess, CI primitive). It is not part of this API, but it\nshares the same runtime, provider/model resolution, permission profiles, and\nevent vocabulary.\n\nThis document is the stable integration contract for native workbench\napplications (and other local supervisors) that embed the Codewhale Engine.\n\n## Architecture\n\n```\nlocal supervisor / SDK / automation harness\n │\n ├─ codewhale app-server --http → HTTP/SSE runtime API (/v1/*) [canonical]\n ├─ codewhale app-server --mobile → runtime API + mobile control page\n ├─ codewhale app-server --stdio → JSON-RPC control transport over stdio\n ├─ codewhale app-server --socket → same JSON-RPC over a unix domain socket (desktop daemon)\n ├─ codewhale doctor --json → machine-readable health & capability\n ├─ codewhale serve --acp → ACP stdio agent for editors such as Zed\n ├─ codewhale serve --mcp → MCP stdio server\n ├─ codewhale serve --http/--mobile → legacy aliases for `app-server --http/--mobile`\n └─ codewhale exec [args] → one-shot headless worker (stream-json)\n```\n\nThe engine runs as a local-only process. All APIs bind to `localhost` by\ndefault. No hosted relay, no provider-token custody, no secret leakage.\n\nFor the read-only record of what a thread or turn did, see\n[`docs/RECEIPTS.md`](RECEIPTS.md): `codewhale receipts` on the CLI and the\n`/receipt` routes under **Threads** below.\n\n## Runtime API entrypoints\n\n| Entry | Transport | Use |\n|---|---|---|\n| `codewhale web [--port 7878]` | HTTP/SSE on `127.0.0.1:7878` + embedded client | First-class loopback-only browser client; opens the default browser |\n| `codewhale app-server --http` | HTTP/SSE on `127.0.0.1:7878` | Full `/v1/*` runtime API (canonical) |\n| `codewhale app-server --mobile` | HTTP/SSE on loopback + `/mobile` | Runtime API + local mobile control page |\n| `codewhale app-server --stdio` | Initialized JSONL over stdio | Compatibility controls and explicit tool-free turns through the held manager; no hidden TCP listener or full Codex interaction channel. |\n| `codewhale app-server --socket [--socket-path P]` | RPC text messages over WebSocket on a `0600` Unix domain socket (macOS/Linux) or an owner-only named pipe (Windows) | Desktop daemon: multi-client, kernel peer identity checked, `daemon/attach` claim handshake. Windows native-app qualification remains open. |\n| `codewhale app-server` | Initialized JSONL over stdio | Same selection as `--stdio`; HTTP requires explicit `--http`. |\n| `codewhale serve --http` / `--mobile` | same server as `app-server --http`/`--mobile` | Compatibility aliases |\n\n`app-server --http` and `--mobile` launch the same mature runtime API server\nhistorically reached through `serve --http` — no routes or behavior changed, so\nevery endpoint documented below is identical across both entrypoints. The\nruntime API token is read from `--auth-token`, then `CODEWHALE_RUNTIME_TOKEN`,\nthen `DEEPSEEK_RUNTIME_TOKEN`; use `--insecure-no-auth` only with a loopback\nbind. The `serve` compatibility aliases keep their `--insecure` flag.\nBare `app-server` does not open an HTTP port. Selected attached HTTP listeners\nretain their explicit bearer and non-loopback security checks.\n\nInitialized stdio/private-socket controls support `thread/loaded/list` with\n`{cursor?: string | null, limit?: number | null}`. The result is\n`{data: string[], nextCursor: string | null}` from the held manager's loaded\nengine snapshot, including idle and archived engines still in memory. It dLine truncated
"sha256": "6f3b8885978ef2e1b33d0fdf66ad6ccf5c25464c1951b59658aec00745390db2",
"content": "# Codewhale Engine Runtime API & Integration Contract\n\n> 阅读简体中文版:[zh_hans/RUNTIME_API.md](zh_hans/RUNTIME_API.md)。\n\n`codewhale app-server --http` exposes the [Codewhale Engine](ARCHITECTURE.md) through\nits canonical local Runtime API and control plane.\nLocal SDKs, mobile/remote-control clients, and editor integrations talk to it\ninstead of screen-scraping terminal output. It serves the full HTTP/SSE runtime\nAPI (`/v1/*`), a JSON-RPC control transport over stdio, and the phone-friendly\nmobile page. `codewhale doctor --json` provides machine-readable health, and\n`codewhale serve --acp` speaks the Agent Client Protocol over stdio for editors\nsuch as Zed.\n\n`codewhale serve --http` / `serve --mobile` remain as **compatibility aliases**\nfor `codewhale app-server --http` / `--mobile`; both launch the identical\nserver. Desktop, web, SDK and custom integrations should target\n`app-server --http`. `--stdio` and `--socket` expose compatibility controls.\nBare `app-server` selects initialized JSONL control over stdio. Local stdio,\nsocket and ACP owners do not bind a hidden TCP listener. Explicit `--http`\nretains HTTP/SSE and compatibility routes on the same Runtime owner.\n\n`codewhale exec` is the separate one-shot headless worker path (stream-json,\nfleet worker subprocess, CI primitive). It is not part of this API, but it\nshares the same runtime, provider/model resolution, permission profiles, and\nevent vocabulary.\n\nThis document is the stable integration contract for native workbench\napplications (and other local supervisors) that embed the Codewhale Engine.\n\n## Architecture\n\n```\nlocal supervisor / SDK / automation harness\n │\n ├─ codewhale app-server --http → HTTP/SSE runtime API (/v1/*) [canonical]\n ├─ codewhale app-server --mobile → runtime API + mobile control page\n ├─ codewhale app-server --stdio → JSON-RPC control transport over stdio\n ├─ codewhale app-server --socket → same JSON-RPC over a unix domain socket (desktop daemon)\n ├─ codewhale doctor --json → machine-readable health & capability\n ├─ codewhale serve --acp → ACP stdio agent for editors such as Zed\n ├─ codewhale serve --mcp → MCP stdio server\n ├─ codewhale serve --http/--mobile → legacy aliases for `app-server --http/--mobile`\n └─ codewhale exec [args] → one-shot headless worker (stream-json)\n```\n\nThe engine runs as a local-only process. All APIs bind to `localhost` by\ndefault. No hosted relay, no provider-token custody, no secret leakage.\n\nFor the read-only record of what a thread or turn did, see\n[`docs/RECEIPTS.md`](RECEIPTS.md): `codewhale receipts` on the CLI and the\n`/receipt` routes under **Threads** below.\n\n## Runtime API entrypoints\n\n| Entry | Transport | Use |\n|---|---|---|\n| `codewhale web [--port 7878]` | HTTP/SSE on `127.0.0.1:7878` + embedded client | First-class loopback-only browser client; opens the default browser |\n| `codewhale app-server --http` | HTTP/SSE on `127.0.0.1:7878` | Full `/v1/*` runtime API (canonical) |\n| `codewhale app-server --mobile` | HTTP/SSE on loopback + `/mobile` | Runtime API + local mobile control page |\n| `codewhale app-server --stdio` | Initialized JSONL over stdio | Compatibility controls and explicit tool-free turns through the held manager; no hidden TCP listener or full Codex interaction channel. |\n| `codewhale app-server --socket [--socket-path P]` | RPC text messages over WebSocket on a `0600` Unix domain socket (macOS/Linux) or an owner-only named pipe (Windows) | Desktop daemon: multi-client, kernel peer identity checked, `daemon/attach` claim handshake. Windows native-app qualification remains open. |\n| `codewhale app-server` | Initialized JSONL over stdio | Same selection as `--stdio`; HTTP requires explicit `--http`. |\n| `codewhale serve --http` / `--mobile` | same server as `app-server --http`/`--mobile` | Compatibility aliases |\n\n`app-server --http` and `--mobile` launch the same mature runtime API server\nhistorically reached through `serve --http` — no routes or behavior changed, so\nevery endpoint documented below is identical across both entrypoints. The\nruntime API token is read from `--auth-token`, then `CODEWHALE_RUNTIME_TOKEN`,\nthen `DEEPSEEK_RUNTIME_TOKEN`; use `--insecure-no-auth` only with a loopback\nbind. The `serve` compatibility aliases keep their `--insecure` flag.\nBare `app-server` does not open an HTTP port. Selected attached HTTP listeners\nretain their explicit bearer and non-loopback security checks.\n\nInitialized stdio/private-socket controls support `thread/loaded/list` with\n`{cursor?: string | null, limit?: number | null}`. The result is\n`{data: string[], nextCursor: string | null}` from the held manager's loaded\nengine snapshot, including idle and archived engines still in memory. It dLine truncated
}
]
},
@@ -387,8 +418,8 @@
},
{
"path": "docs/CONTRIBUTORS.md",
"sha256": "bedd1cc62969108856f75d823d13bde9f7217a936d4b0e7af3d2e6232cad5770",
"content": "# Contributors\n\nCodewhale is built in the open with a growing community of contributors. Every\nissue report and pull request is real project work — welcome at any experience\nlevel. This is the full per-PR contributor record, grouped into release/time\nbands with the newest band first so it stays scannable. Expand any band to see\neveryone.\n\nFor the live list, see the\n[GitHub contributors page](https://github.com/codewhale-hq/CodeWhale/graphs/contributors),\n[`AUTHOR_MAP`](https://github.com/codewhale-hq/CodeWhale/blob/main/.github/AUTHOR_MAP),\nand [CHANGELOG.md](../CHANGELOG.md).\n\n<!-- contributor-wall:start -->\n## Everyone\n\n[![433 people built Codewhale](assets/contributors.png)](#everyone)\n\n433 people have code, co-authored commits, carried-forward pull requests or credited reports in Codewhale. GitHub's contributor list shows only commit authors whose email is linked to their account, so it counts 212 of them. If your commits are missing there, add your commit email to your GitHub account and they will link.\n\n[0gl20shk0sbt36](https://github.com/0gl20shk0sbt36) · [1Git2Clone](https://github.com/1Git2Clone) · [20bytes](https://github.com/20bytes) · [47Cid](https://github.com/47Cid) · [7jrxt42BxFZo4iAnN4CX](https://github.com/7jrxt42BxFZo4iAnN4CX) · [8sero](https://github.com/8sero) · [A-Corner](https://github.com/A-Corner) · [aboimpinto](https://github.com/aboimpinto) · [Aboysky](https://github.com/Aboysky) · [adity982](https://github.com/adity982) · [AdityaVG13](https://github.com/AdityaVG13) · [AGSaturn](https://github.com/AGSaturn) · [ai-dengwsh](https://github.com/ai-dengwsh) · [aiapienthusiast](https://github.com/aiapienthusiast) · [Aitensa](https://github.com/Aitensa) · [AiurArtanis](https://github.com/AiurArtanis) · [alitvak69](https://github.com/alitvak69) · [all-lopezg](https://github.com/all-lopezg) · [alvin1](https://github.com/alvin1) · [amuthantamil](https://github.com/amuthantamil) · [AnaheimEX](https://github.com/AnaheimEX) · [Andrea-Bruno](https://github.com/Andrea-Bruno) · [Angel-Hair](https://github.com/Angel-Hair) · [angziii](https://github.com/angziii) · [AnonymousUser443](https://github.com/AnonymousUser443) · [antarikshraya](https://github.com/antarikshraya) · [Anyexyz](https://github.com/Anyexyz) · [Apeiron0w0](https://github.com/Apeiron0w0) · [aqilaziz](https://github.com/aqilaziz) · [AresNing](https://github.com/AresNing) · [ArronAI007](https://github.com/ArronAI007) · [Artenx](https://github.com/Artenx) · [asdfg314284230](https://github.com/asdfg314284230) · [Asteria1028](https://github.com/Asteria1028) · [asto18089](https://github.com/asto18089) · [atmosuwiryo](https://github.com/atmosuwiryo) · [axobase001](https://github.com/axobase001) · [aznikline](https://github.com/aznikline) · [baendlorel](https://github.com/baendlorel) · [banqii](https://github.com/banqii) · [Beltran12138](https://github.com/Beltran12138) · [bevis-wong](https://github.com/bevis-wong) · [BH8GCJ](https://github.com/BH8GCJ) · [BigBenLabs](https://github.com/BigBenLabs) · [Biilow](https://github.com/Biilow) · [bistack](https://github.com/bistack) · [BrathonBai](https://github.com/BrathonBai) · [bruce6135](https://github.com/bruce6135) · [BryonGo](https://github.com/BryonGo) · [buiducnhat](https://github.com/buiducnhat) · [buko](https://github.com/buko) · [BX166](https://github.com/BX166) · [c020627](https://github.com/c020627) · [cacdcaecawae](https://github.com/cacdcaecawae) · [caeserchen](https://github.com/caeserchen) · [caiyilian](https://github.com/caiyilian) · [CCChisato](https://github.com/CCChisato) · [ccomma](https://github.com/ccomma) · [cenab](https://github.com/cenab) · [ChaceLyee2101](https://github.com/ChaceLyee2101) · [chatman-media](https://github.com/chatman-media) · [chennest](https://github.com/chennest) · [chinaqy110](https://github.com/chinaqy110) · [chnjames](https://github.com/chnjames) · [chuntseevolving](https://github.com/chuntseevolving) · [ci4ic4](https://github.com/ci4ic4) · [cian-lien](https://github.com/cian-lien) · [cmdcorp6534](https://github.com/cmdcorp6534) · [cmyyy](https://github.com/cmyyy) · [codepgq](https://github.com/codepgq) · [Colorful-glassblock](https://github.com/Colorful-glassblock) · [ComeFromTheMars](https://github.com/ComeFromTheMars) · [CrepuscularIRIS](https://github.com/CrepuscularIRIS) · [ct-jaryn](https://github.com/ct-jaryn) · [ctxyao](https://github.com/ctxyao) · [cy2311](https://github.com/cy2311) · [cyq1017](https://github.com/cyq1017) · [czf0718](https://github.com/czf0718) · [dajiaohuang](https://github.com/dajiaohuang) · [DarrellThomas](https://github.com/DarrellThomas) · [ddlw](https://github.com/ddlw) · [demian-welt](https://github.com/demian-welt) · [Dev-next-gen](https://github.com/Dev-next-gen) · [dfwqdyl-ui](https://github.com/dfwqdyl-ui) · [DI-HUO-MING-YI](https://github.com/DI-HUO-MING-YI) · [DieMoe233](https://github.Line truncated
"sha256": "4368939ea860f6db9484fd86e613574b72eca71361f08a1130ec87c5f5c66c78",
"content": "# Contributors\n\nCodewhale is built in the open with a growing community of contributors. Every\nissue report and pull request is real project work — welcome at any experience\nlevel. This is the full per-PR contributor record, grouped into release/time\nbands with the newest band first so it stays scannable. Expand any band to see\neveryone.\n\nFor the live list, see the\n[GitHub contributors page](https://github.com/codewhale-hq/CodeWhale/graphs/contributors),\n[`AUTHOR_MAP`](https://github.com/codewhale-hq/CodeWhale/blob/main/.github/AUTHOR_MAP),\nand [CHANGELOG.md](../CHANGELOG.md).\n\n<!-- contributor-wall:start -->\n## Everyone\n\n[![433 people built Codewhale](assets/contributors.png)](#everyone)\n\n433 people have code, co-authored commits, carried-forward pull requests or credited reports in Codewhale. GitHub's contributor list shows only commit authors whose email is linked to their account, so it counts 212 of them. If your commits are missing there, add your commit email to your GitHub account and they will link.\n\n[0gl20shk0sbt36](https://github.com/0gl20shk0sbt36) · [1Git2Clone](https://github.com/1Git2Clone) · [20bytes](https://github.com/20bytes) · [47Cid](https://github.com/47Cid) · [7jrxt42BxFZo4iAnN4CX](https://github.com/7jrxt42BxFZo4iAnN4CX) · [8sero](https://github.com/8sero) · [A-Corner](https://github.com/A-Corner) · [aboimpinto](https://github.com/aboimpinto) · [Aboysky](https://github.com/Aboysky) · [adity982](https://github.com/adity982) · [AdityaVG13](https://github.com/AdityaVG13) · [AGSaturn](https://github.com/AGSaturn) · [ai-dengwsh](https://github.com/ai-dengwsh) · [aiapienthusiast](https://github.com/aiapienthusiast) · [Aitensa](https://github.com/Aitensa) · [AiurArtanis](https://github.com/AiurArtanis) · [alitvak69](https://github.com/alitvak69) · [all-lopezg](https://github.com/all-lopezg) · [alvin1](https://github.com/alvin1) · [amuthantamil](https://github.com/amuthantamil) · [AnaheimEX](https://github.com/AnaheimEX) · [Andrea-Bruno](https://github.com/Andrea-Bruno) · [Angel-Hair](https://github.com/Angel-Hair) · [angziii](https://github.com/angziii) · [AnonymousUser443](https://github.com/AnonymousUser443) · [antarikshraya](https://github.com/antarikshraya) · [Anyexyz](https://github.com/Anyexyz) · [Apeiron0w0](https://github.com/Apeiron0w0) · [aqilaziz](https://github.com/aqilaziz) · [AresNing](https://github.com/AresNing) · [ArronAI007](https://github.com/ArronAI007) · [Artenx](https://github.com/Artenx) · [asdfg314284230](https://github.com/asdfg314284230) · [Asteria1028](https://github.com/Asteria1028) · [asto18089](https://github.com/asto18089) · [atmosuwiryo](https://github.com/atmosuwiryo) · [axobase001](https://github.com/axobase001) · [aznikline](https://github.com/aznikline) · [baendlorel](https://github.com/baendlorel) · [banqii](https://github.com/banqii) · [Beltran12138](https://github.com/Beltran12138) · [bevis-wong](https://github.com/bevis-wong) · [BH8GCJ](https://github.com/BH8GCJ) · [BigBenLabs](https://github.com/BigBenLabs) · [Biilow](https://github.com/Biilow) · [bistack](https://github.com/bistack) · [BrathonBai](https://github.com/BrathonBai) · [bruce6135](https://github.com/bruce6135) · [BryonGo](https://github.com/BryonGo) · [buiducnhat](https://github.com/buiducnhat) · [buko](https://github.com/buko) · [BX166](https://github.com/BX166) · [c020627](https://github.com/c020627) · [cacdcaecawae](https://github.com/cacdcaecawae) · [caeserchen](https://github.com/caeserchen) · [caiyilian](https://github.com/caiyilian) · [CCChisato](https://github.com/CCChisato) · [ccomma](https://github.com/ccomma) · [cenab](https://github.com/cenab) · [ChaceLyee2101](https://github.com/ChaceLyee2101) · [chatman-media](https://github.com/chatman-media) · [chennest](https://github.com/chennest) · [chinaqy110](https://github.com/chinaqy110) · [chnjames](https://github.com/chnjames) · [chuntseevolving](https://github.com/chuntseevolving) · [ci4ic4](https://github.com/ci4ic4) · [cian-lien](https://github.com/cian-lien) · [cmdcorp6534](https://github.com/cmdcorp6534) · [cmyyy](https://github.com/cmyyy) · [codepgq](https://github.com/codepgq) · [Colorful-glassblock](https://github.com/Colorful-glassblock) · [ComeFromTheMars](https://github.com/ComeFromTheMars) · [CrepuscularIRIS](https://github.com/CrepuscularIRIS) · [ct-jaryn](https://github.com/ct-jaryn) · [ctxyao](https://github.com/ctxyao) · [cy2311](https://github.com/cy2311) · [cyq1017](https://github.com/cyq1017) · [czf0718](https://github.com/czf0718) · [dajiaohuang](https://github.com/dajiaohuang) · [DarrellThomas](https://github.com/DarrellThomas) · [ddlw](https://github.com/ddlw) · [demian-welt](https://github.com/demian-welt) · [Dev-next-gen](https://github.com/Dev-next-gen) · [dfwqdyl-ui](https://github.com/dfwqdyl-ui) · [DI-HUO-MING-YI](https://github.com/DI-HUO-MING-YI) · [DieMoe233](https://github.Line truncated
},
{
"path": "docs/RELEASE_CHECKLIST.md",
@@ -403,13 +434,13 @@
"sources": [
{
"path": "docs/RUNTIME_API.md",
"sha256": "e3040635df0cf62717f5acbe9aacf4909b6ad60db8468b06e90a5e9be1d76d1e",
"content": "# Codewhale Engine Runtime API & Integration Contract\n\n> 阅读简体中文版:[zh_hans/RUNTIME_API.md](zh_hans/RUNTIME_API.md)。\n\n`codewhale app-server --http` exposes the [Codewhale Engine](ARCHITECTURE.md) through\nits canonical local Runtime API and control plane.\nLocal SDKs, mobile/remote-control clients, and editor integrations talk to it\ninstead of screen-scraping terminal output. It serves the full HTTP/SSE runtime\nAPI (`/v1/*`), a JSON-RPC control transport over stdio, and the phone-friendly\nmobile page. `codewhale doctor --json` provides machine-readable health, and\n`codewhale serve --acp` speaks the Agent Client Protocol over stdio for editors\nsuch as Zed.\n\n`codewhale serve --http` / `serve --mobile` remain as **compatibility aliases**\nfor `codewhale app-server --http` / `--mobile`; both launch the identical\nserver. Desktop, web, SDK and custom integrations should target\n`app-server --http`. `--stdio` and `--socket` expose compatibility controls.\nBare `app-server` selects initialized JSONL control over stdio. Local stdio,\nsocket and ACP owners do not bind a hidden TCP listener. Explicit `--http`\nretains HTTP/SSE and compatibility routes on the same Runtime owner.\n\n`codewhale exec` is the separate one-shot headless worker path (stream-json,\nfleet worker subprocess, CI primitive). It is not part of this API, but it\nshares the same runtime, provider/model resolution, permission profiles, and\nevent vocabulary.\n\nThis document is the stable integration contract for native workbench\napplications (and other local supervisors) that embed the Codewhale Engine.\n\n## Architecture\n\n```\nlocal supervisor / SDK / automation harness\n │\n ├─ codewhale app-server --http → HTTP/SSE runtime API (/v1/*) [canonical]\n ├─ codewhale app-server --mobile → runtime API + mobile control page\n ├─ codewhale app-server --stdio → JSON-RPC control transport over stdio\n ├─ codewhale app-server --socket → same JSON-RPC over a unix domain socket (desktop daemon)\n ├─ codewhale doctor --json → machine-readable health & capability\n ├─ codewhale serve --acp → ACP stdio agent for editors such as Zed\n ├─ codewhale serve --mcp → MCP stdio server\n ├─ codewhale serve --http/--mobile → legacy aliases for `app-server --http/--mobile`\n └─ codewhale exec [args] → one-shot headless worker (stream-json)\n```\n\nThe engine runs as a local-only process. All APIs bind to `localhost` by\ndefault. No hosted relay, no provider-token custody, no secret leakage.\n\nFor the read-only record of what a thread or turn did, see\n[`docs/RECEIPTS.md`](RECEIPTS.md): `codewhale receipts` on the CLI and the\n`/receipt` routes under **Threads** below.\n\n## Runtime API entrypoints\n\n| Entry | Transport | Use |\n|---|---|---|\n| `codewhale web [--port 7878]` | HTTP/SSE on `127.0.0.1:7878` + embedded client | First-class loopback-only browser client; opens the default browser |\n| `codewhale app-server --http` | HTTP/SSE on `127.0.0.1:7878` | Full `/v1/*` runtime API (canonical) |\n| `codewhale app-server --mobile` | HTTP/SSE on loopback + `/mobile` | Runtime API + local mobile control page |\n| `codewhale app-server --stdio` | Initialized JSONL over stdio | Compatibility controls and explicit tool-free turns through the held manager; no hidden TCP listener or full Codex interaction channel. |\n| `codewhale app-server --socket [--socket-path P]` | RPC text messages over WebSocket on a `0600` Unix domain socket (macOS/Linux) or an owner-only named pipe (Windows) | Desktop daemon: multi-client, kernel peer identity checked, `daemon/attach` claim handshake. Windows native-app qualification remains open. |\n| `codewhale app-server` | Initialized JSONL over stdio | Same selection as `--stdio`; HTTP requires explicit `--http`. |\n| `codewhale serve --http` / `--mobile` | same server as `app-server --http`/`--mobile` | Compatibility aliases |\n\n`app-server --http` and `--mobile` launch the same mature runtime API server\nhistorically reached through `serve --http` — no routes or behavior changed, so\nevery endpoint documented below is identical across both entrypoints. The\nruntime API token is read from `--auth-token`, then `CODEWHALE_RUNTIME_TOKEN`,\nthen `DEEPSEEK_RUNTIME_TOKEN`; use `--insecure-no-auth` only with a loopback\nbind. The `serve` compatibility aliases keep their `--insecure` flag.\nBare `app-server` does not open an HTTP port. Selected attached HTTP listeners\nretain their explicit bearer and non-loopback security checks.\n\nInitialized stdio/private-socket controls support `thread/loaded/list` with\n`{cursor?: string | null, limit?: number | null}`. The result is\n`{data: string[], nextCursor: string | null}` from the held manager's loaded\nengine snapshot, including idle and archived engines still in memory. It dLine truncated
"sha256": "6f3b8885978ef2e1b33d0fdf66ad6ccf5c25464c1951b59658aec00745390db2",
"content": "# Codewhale Engine Runtime API & Integration Contract\n\n> 阅读简体中文版:[zh_hans/RUNTIME_API.md](zh_hans/RUNTIME_API.md)。\n\n`codewhale app-server --http` exposes the [Codewhale Engine](ARCHITECTURE.md) through\nits canonical local Runtime API and control plane.\nLocal SDKs, mobile/remote-control clients, and editor integrations talk to it\ninstead of screen-scraping terminal output. It serves the full HTTP/SSE runtime\nAPI (`/v1/*`), a JSON-RPC control transport over stdio, and the phone-friendly\nmobile page. `codewhale doctor --json` provides machine-readable health, and\n`codewhale serve --acp` speaks the Agent Client Protocol over stdio for editors\nsuch as Zed.\n\n`codewhale serve --http` / `serve --mobile` remain as **compatibility aliases**\nfor `codewhale app-server --http` / `--mobile`; both launch the identical\nserver. Desktop, web, SDK and custom integrations should target\n`app-server --http`. `--stdio` and `--socket` expose compatibility controls.\nBare `app-server` selects initialized JSONL control over stdio. Local stdio,\nsocket and ACP owners do not bind a hidden TCP listener. Explicit `--http`\nretains HTTP/SSE and compatibility routes on the same Runtime owner.\n\n`codewhale exec` is the separate one-shot headless worker path (stream-json,\nfleet worker subprocess, CI primitive). It is not part of this API, but it\nshares the same runtime, provider/model resolution, permission profiles, and\nevent vocabulary.\n\nThis document is the stable integration contract for native workbench\napplications (and other local supervisors) that embed the Codewhale Engine.\n\n## Architecture\n\n```\nlocal supervisor / SDK / automation harness\n │\n ├─ codewhale app-server --http → HTTP/SSE runtime API (/v1/*) [canonical]\n ├─ codewhale app-server --mobile → runtime API + mobile control page\n ├─ codewhale app-server --stdio → JSON-RPC control transport over stdio\n ├─ codewhale app-server --socket → same JSON-RPC over a unix domain socket (desktop daemon)\n ├─ codewhale doctor --json → machine-readable health & capability\n ├─ codewhale serve --acp → ACP stdio agent for editors such as Zed\n ├─ codewhale serve --mcp → MCP stdio server\n ├─ codewhale serve --http/--mobile → legacy aliases for `app-server --http/--mobile`\n └─ codewhale exec [args] → one-shot headless worker (stream-json)\n```\n\nThe engine runs as a local-only process. All APIs bind to `localhost` by\ndefault. No hosted relay, no provider-token custody, no secret leakage.\n\nFor the read-only record of what a thread or turn did, see\n[`docs/RECEIPTS.md`](RECEIPTS.md): `codewhale receipts` on the CLI and the\n`/receipt` routes under **Threads** below.\n\n## Runtime API entrypoints\n\n| Entry | Transport | Use |\n|---|---|---|\n| `codewhale web [--port 7878]` | HTTP/SSE on `127.0.0.1:7878` + embedded client | First-class loopback-only browser client; opens the default browser |\n| `codewhale app-server --http` | HTTP/SSE on `127.0.0.1:7878` | Full `/v1/*` runtime API (canonical) |\n| `codewhale app-server --mobile` | HTTP/SSE on loopback + `/mobile` | Runtime API + local mobile control page |\n| `codewhale app-server --stdio` | Initialized JSONL over stdio | Compatibility controls and explicit tool-free turns through the held manager; no hidden TCP listener or full Codex interaction channel. |\n| `codewhale app-server --socket [--socket-path P]` | RPC text messages over WebSocket on a `0600` Unix domain socket (macOS/Linux) or an owner-only named pipe (Windows) | Desktop daemon: multi-client, kernel peer identity checked, `daemon/attach` claim handshake. Windows native-app qualification remains open. |\n| `codewhale app-server` | Initialized JSONL over stdio | Same selection as `--stdio`; HTTP requires explicit `--http`. |\n| `codewhale serve --http` / `--mobile` | same server as `app-server --http`/`--mobile` | Compatibility aliases |\n\n`app-server --http` and `--mobile` launch the same mature runtime API server\nhistorically reached through `serve --http` — no routes or behavior changed, so\nevery endpoint documented below is identical across both entrypoints. The\nruntime API token is read from `--auth-token`, then `CODEWHALE_RUNTIME_TOKEN`,\nthen `DEEPSEEK_RUNTIME_TOKEN`; use `--insecure-no-auth` only with a loopback\nbind. The `serve` compatibility aliases keep their `--insecure` flag.\nBare `app-server` does not open an HTTP port. Selected attached HTTP listeners\nretain their explicit bearer and non-loopback security checks.\n\nInitialized stdio/private-socket controls support `thread/loaded/list` with\n`{cursor?: string | null, limit?: number | null}`. The result is\n`{data: string[], nextCursor: string | null}` from the held manager's loaded\nengine snapshot, including idle and archived engines still in memory. It dLine truncated
},
{
"path": "docs/RUNTIME_ROUTES.md",
"sha256": "eae9cc4778f7c6f4ae2db5fe0d2d39d757521337a0818b2c2bf4a4f5dd686e0e",
"content": "# Runtime HTTP route inventory\n\nThis table is generated from literal route registrations in\n`crates/app-server/src/runtime_api.rs`. It names the paths and methods that\nthe current source registers, including native-client and compatibility\nsurfaces. A registered path is not a grant of permission or a promise that\na feature is enabled in your installed version.\n\nThe [Runtime API contract](RUNTIME_API.md) explains request fields, events,\nauthorization and compatibility. This inventory is a navigation index, not\na replacement for those schemas; routes used by the native client can have\nclient-specific contracts. Provider calls, file mutations and connected-service\nactions remain subject to their documented authority boundaries.\n\nRun `node web/scripts/check-runtime-routes.mjs --write` after changing a\nregistration; `npm run check:docs` refuses a stale inventory. Dynamic nested\nrouters, fallback handlers and static-asset mounts are outside this literal\nregistration index.\n\n| HTTP method | Path |\n| --- | --- |\n| GET | `/` |\n| GET | `/__codewhale/bootstrap/{nonce}` |\n| GET | `/__codewhale/mobile/bootstrap/{nonce}` |\n| POST | `/__codewhale/mobile/session` |\n| POST | `/__codewhale/mobile/stream-ticket` |\n| POST | `/__codewhale/web/stream-ticket` |\n| GET | `/assets/codewhale-192.png` |\n| GET | `/assets/codewhale-web.css` |\n| GET | `/assets/codewhale-web.js` |\n| GET | `/health` |\n| GET | `/mobile` |\n| GET | `/mobile/` |\n| DELETE / GET / PUT | `/v1/account/model-access` |\n| POST | `/v1/agent-mail` |\n| GET | `/v1/agent-runs` |\n| GET | `/v1/agent-runs/{run_id}` |\n| POST | `/v1/agent-runs/{run_id}/cancel` |\n| GET | `/v1/agents/external` |\n| GET | `/v1/agents/external/{id}/options` |\n| GET | `/v1/agents/external/quota` |\n| GET | `/v1/approvals` |\n| POST | `/v1/approvals/{approval_id}` |\n| GET | `/v1/apps/avatars` |\n| GET | `/v1/apps/avatars/{handle}/atlases/{page}` |\n| GET / POST | `/v1/apps/marketplaces` |\n| DELETE / GET | `/v1/apps/marketplaces/{name}` |\n| POST | `/v1/apps/marketplaces/{name}/install` |\n| POST | `/v1/apps/mcp/desktop-browser` |\n| GET / POST | `/v1/apps/mcp/imports` |\n| GET / POST | `/v1/apps/mcp/servers` |\n| DELETE / GET / PATCH | `/v1/apps/mcp/servers/{name}` |\n| POST | `/v1/apps/mcp/servers/{name}/disable` |\n| POST | `/v1/apps/mcp/servers/{name}/enable` |\n| POST | `/v1/apps/mcp/servers/{name}/reconnect` |\n| GET | `/v1/apps/mcp/tools` |\n| GET | `/v1/apps/plugin-index` |\n| GET | `/v1/apps/plugins` |\n| DELETE / GET | `/v1/apps/plugins/{selector}` |\n| POST | `/v1/apps/plugins/{selector}/disable` |\n| POST | `/v1/apps/plugins/{selector}/enable` |\n| POST | `/v1/apps/plugins/{selector}/restage` |\n| POST | `/v1/apps/plugins/{selector}/revoke` |\n| ANY | `/v1/apps/plugins/{selector}/routes/{*path}` |\n| POST | `/v1/apps/plugins/{selector}/trust` |\n| GET | `/v1/apps/plugins/{selector}/ui` |\n| GET | `/v1/apps/plugins/{selector}/ui/{*path}` |\n| POST | `/v1/apps/plugins/{selector}/update` |\n| GET | `/v1/apps/plugins/catalog` |\n| POST | `/v1/apps/plugins/catalog/{id}/disable` |\n| POST | `/v1/apps/plugins/catalog/{id}/enable` |\n| PUT | `/v1/apps/plugins/catalog/{id}/settings` |\n| POST | `/v1/apps/plugins/import/dsh/preview` |\n| POST | `/v1/apps/plugins/install` |\n| GET / POST | `/v1/automations` |\n| DELETE / GET / PATCH | `/v1/automations/{id}` |\n| GET | `/v1/automations/{id}/missed` |\n| PUT | `/v1/automations/{id}/missed/policy` |\n| POST | `/v1/automations/{id}/missed/run` |\n| POST | `/v1/automations/{id}/missed/skip` |\n| POST | `/v1/automations/{id}/pause` |\n| POST | `/v1/automations/{id}/resume` |\n| POST | `/v1/automations/{id}/run` |\n| GET | `/v1/automations/{id}/runs` |\n| GET | `/v1/automations/missed` |\n| GET | `/v1/changes` |\n| GET / POST | `/v1/cloud` |\n| POST | `/v1/cloud/attach` |\n| GET | `/v1/commands` |\n| GET / POST | `/v1/config` |\n| POST | `/v1/config/reload` |\n| GET | `/v1/constitution` |\n| POST | `/v1/constitution/preview` |\n| GET | `/v1/crashes` |\n| GET | `/v1/crashes/{name}` |\n| GET | `/v1/definition` |\n| GET | `/v1/diagnostics` |\n| GET | `/v1/diff` |\n| POST | `/v1/fleet/placement` |\n| GET | `/v1/fleet/profiles` |\n| GET | `/v1/fleet/profiles/{id}/document` |\n| GET / POST | `/v1/fleet/runs` |\n| GET | `/v1/fleet/runs/{run_id}` |\n| GET | `/v1/fleet/runs/{run_id}/events` |\n| GET | `/v1/fleet/runs/{run_id}/events/replay` |\n| GET | `/v1/fleet/runs/{run_id}/receipts` |\n| GET | `/v1/fleet/runs/{run_id}/receipts/{task_id}` |\n| GET | `/v1/fleet/runs/{run_id}/receipts/{task_id}/evidence` |\n| POST | `/v1/fleet/runs/{run_id}/start` |\n| POST | `/v1/fleet/runs/{run_id}/stop` |\n| GET | `/v1/fleet/runs/{run_id}/workers` |\n| GET | `/v1/fleet/workers/{worker_id}` |\n| POST | `/v1/fleet/workers/{worker_id}/interrupt` |\n| POST | `/v1/fleet/workers/{worker_id}/restart` |\n| POST | `/v1/fleet/workers/{worker_id}/stop` |\n| GET | `/v1/git` |\n| POST | `/v1/git/branch` |\n| POST | `/v1/git/commitLine truncated
"sha256": "1a21c05b0249c2ff225f7b554e1e610fc12122ab3da52e6ac7ceb06c90448984",
"content": "# Runtime HTTP route inventory\n\nThis table is generated from literal route registrations in\n`crates/app-server/src/runtime_api.rs`. It names the paths and methods that\nthe current source registers, including native-client and compatibility\nsurfaces. A registered path is not a grant of permission or a promise that\na feature is enabled in your installed version.\n\nThe [Runtime API contract](RUNTIME_API.md) explains request fields, events,\nauthorization and compatibility. This inventory is a navigation index, not\na replacement for those schemas; routes used by the native client can have\nclient-specific contracts. Provider calls, file mutations and connected-service\nactions remain subject to their documented authority boundaries.\n\nRun `node web/scripts/check-runtime-routes.mjs --write` after changing a\nregistration; `npm run check:docs` refuses a stale inventory. Dynamic nested\nrouters, fallback handlers and static-asset mounts are outside this literal\nregistration index.\n\n| HTTP method | Path |\n| --- | --- |\n| GET | `/` |\n| GET | `/__codewhale/bootstrap/{nonce}` |\n| GET | `/__codewhale/mobile/bootstrap/{nonce}` |\n| POST | `/__codewhale/mobile/session` |\n| POST | `/__codewhale/mobile/stream-ticket` |\n| POST | `/__codewhale/web/stream-ticket` |\n| GET | `/assets/codewhale-192.png` |\n| GET | `/assets/codewhale-web.css` |\n| GET | `/assets/codewhale-web.js` |\n| GET | `/health` |\n| GET | `/mobile` |\n| GET | `/mobile/` |\n| DELETE / GET / PUT | `/v1/account/model-access` |\n| POST | `/v1/agent-mail` |\n| GET | `/v1/agent-runs` |\n| GET | `/v1/agent-runs/{run_id}` |\n| POST | `/v1/agent-runs/{run_id}/cancel` |\n| GET | `/v1/agents/external` |\n| GET | `/v1/agents/external/{id}/options` |\n| GET | `/v1/agents/external/quota` |\n| GET | `/v1/approvals` |\n| POST | `/v1/approvals/{approval_id}` |\n| GET | `/v1/apps/avatars` |\n| GET | `/v1/apps/avatars/{handle}/atlases/{page}` |\n| GET / POST | `/v1/apps/marketplaces` |\n| DELETE / GET | `/v1/apps/marketplaces/{name}` |\n| POST | `/v1/apps/marketplaces/{name}/install` |\n| POST | `/v1/apps/mcp/desktop-browser` |\n| GET / POST | `/v1/apps/mcp/imports` |\n| GET / POST | `/v1/apps/mcp/servers` |\n| DELETE / GET / PATCH | `/v1/apps/mcp/servers/{name}` |\n| POST | `/v1/apps/mcp/servers/{name}/disable` |\n| POST | `/v1/apps/mcp/servers/{name}/enable` |\n| POST | `/v1/apps/mcp/servers/{name}/reconnect` |\n| GET | `/v1/apps/mcp/tools` |\n| GET | `/v1/apps/plugins` |\n| DELETE / GET | `/v1/apps/plugins/{selector}` |\n| POST | `/v1/apps/plugins/{selector}/disable` |\n| POST | `/v1/apps/plugins/{selector}/enable` |\n| POST | `/v1/apps/plugins/{selector}/restage` |\n| POST | `/v1/apps/plugins/{selector}/revoke` |\n| ANY | `/v1/apps/plugins/{selector}/routes/{*path}` |\n| POST | `/v1/apps/plugins/{selector}/trust` |\n| GET | `/v1/apps/plugins/{selector}/ui` |\n| GET | `/v1/apps/plugins/{selector}/ui/{*path}` |\n| POST | `/v1/apps/plugins/{selector}/update` |\n| GET | `/v1/apps/plugins/catalog` |\n| POST | `/v1/apps/plugins/catalog/{id}/disable` |\n| POST | `/v1/apps/plugins/catalog/{id}/enable` |\n| PUT | `/v1/apps/plugins/catalog/{id}/settings` |\n| POST | `/v1/apps/plugins/import/dsh/preview` |\n| POST | `/v1/apps/plugins/install` |\n| GET / POST | `/v1/automations` |\n| DELETE / GET / PATCH | `/v1/automations/{id}` |\n| GET | `/v1/automations/{id}/missed` |\n| PUT | `/v1/automations/{id}/missed/policy` |\n| POST | `/v1/automations/{id}/missed/run` |\n| POST | `/v1/automations/{id}/missed/skip` |\n| POST | `/v1/automations/{id}/pause` |\n| POST | `/v1/automations/{id}/resume` |\n| POST | `/v1/automations/{id}/run` |\n| GET | `/v1/automations/{id}/runs` |\n| GET | `/v1/automations/missed` |\n| GET | `/v1/changes` |\n| GET / POST | `/v1/cloud` |\n| POST | `/v1/cloud/attach` |\n| GET | `/v1/commands` |\n| GET / POST | `/v1/config` |\n| POST | `/v1/config/reload` |\n| GET | `/v1/constitution` |\n| POST | `/v1/constitution/preview` |\n| GET | `/v1/crashes` |\n| GET | `/v1/crashes/{name}` |\n| GET | `/v1/definition` |\n| GET | `/v1/diagnostics` |\n| GET | `/v1/diff` |\n| POST | `/v1/fleet/placement` |\n| GET | `/v1/fleet/profiles` |\n| GET | `/v1/fleet/profiles/{id}/document` |\n| GET / POST | `/v1/fleet/runs` |\n| GET | `/v1/fleet/runs/{run_id}` |\n| GET | `/v1/fleet/runs/{run_id}/events` |\n| GET | `/v1/fleet/runs/{run_id}/events/replay` |\n| GET | `/v1/fleet/runs/{run_id}/receipts` |\n| GET | `/v1/fleet/runs/{run_id}/receipts/{task_id}` |\n| GET | `/v1/fleet/runs/{run_id}/receipts/{task_id}/evidence` |\n| POST | `/v1/fleet/runs/{run_id}/start` |\n| POST | `/v1/fleet/runs/{run_id}/stop` |\n| GET | `/v1/fleet/runs/{run_id}/workers` |\n| GET | `/v1/fleet/workers/{worker_id}` |\n| POST | `/v1/fleet/workers/{worker_id}/interrupt` |\n| POST | `/v1/fleet/workers/{worker_id}/restart` |\n| POST | `/v1/fleet/workers/{worker_id}/stop` |\n| GET | `/v1/git` |\n| POST | `/v1/git/branch` |\n| POST | `/v1/git/commit` |\n| POST | `/v1/git/discard` |\nLine truncated
}
]
},
+1
View File
@@ -1,6 +1,7 @@
{
"tag": "v0.10.2",
"slugs": [
"first-time",
"install",
"guide",
"auth",
+32
View File
@@ -3,6 +3,38 @@
"tag": "v0.10.2",
"version": "0.10.2",
"topics": [
{
"id": "first-time",
"slug": "first-time",
"sources": [
{
"path": "docs/GUIDE.md",
"sha256": "43b307ea1fc95898f1ec2484f0b9073b7774d14504d4b12c41e4bf0d5af3b336",
"content": "# Codewhale User Guide\n\nThis guide is for your first hour with Codewhale. It explains the main\nworkflow, the important safety controls, and where to go next when you need a\ncomplete reference.\n\nCodewhale has deeper reference documents for installation, configuration,\nproviders, modes, keybindings, tools, and operations. Use this page as a guided\nwalkthrough, then follow the \"Next\" links when you need every option.\n\n## 1. Welcome to Codewhale\n\nCodewhale is a terminal coding agent. You run it from a workspace, give it a\ntask, and it can use structured tools to inspect files, run commands, edit\ncode, and report back with evidence.\n\nThe important difference from a normal chat model is that Codewhale is built\naround a harness:\n\n- It keeps the active workspace and session visible.\n- It routes each turn through explicit modes and approval rules.\n- It shows tool calls in the transcript instead of hiding the work.\n- It can preserve sessions, fork conversations, and continue later.\n- It can run sub-agents for focused background work.\n\nYou can use Codewhale for small questions:\n\n```text\nExplain the authentication flow in this repository.\n```\n\nYou can also use it for multi-step work:\n\n```text\nFind the failing validation path, propose a fix, and wait for my approval\nbefore editing files.\n```\n\nFor a new repository, start conservatively. Ask Codewhale to explore and plan\nbefore asking it to change files. That gives you a reviewable path and makes it\neasier to catch wrong assumptions early.\n\nNext: [ARCHITECTURE.md](ARCHITECTURE.md) explains the internal harness and\nruntime model.\n\n## 2. First Launch\n\nFor a new macOS or Linux installation, use the official GitHub release.\nThe installer verifies the release checksums and provides the same runtime\nunder the `codewhale` and `codew` command names:\n\n```bash\ncurl -fsSL https://codewhale.net/install.sh | sh\n```\n\nWindows users should choose the matching installer or archive from\n[GitHub Releases](https://github.com/codewhale-hq/CodeWhale/releases/latest).\nFor an existing direct install, use `codewhale update --check`, then\n`codewhale update`. npm and Cargo remain secondary packaging routes; Cargo\nalso supports source builds where a compatible prebuilt is unavailable.\nFor occupied directories, package-managed installs, and PATH setup, follow\n[the installation and migration guide](INSTALL.md#recommended-official-github-releases).\nAndroid/Termux uses its own [preview archive or source-build path](INSTALL.md#android--termux-arm64).\n\nDocker is also available when you want an isolated runtime. The published\nimage is currently 0.10.0; to test current `main`, [build it from source](DOCKER.md#building-locally).\n\n```bash\ndocker volume create codewhale-home\ndocker run --rm -it \\\n -e DEEPSEEK_API_KEY=\"$DEEPSEEK_API_KEY\" \\\n -v codewhale-home:/home/codewhale/.codewhale \\\n -v \"$PWD:/workspace\" \\\n -w /workspace \\\n ghcr.io/hmbown/codewhale:latest\n```\n\nOnce the install directory is on PATH, launch Codewhale from the repository or\ndirectory you want it to work in:\n\n```bash\ncodewhale\n```\n\nFor the default GitHub installer destination, you can use\n`\"$HOME/.local/bin/codewhale\"` until that directory is on PATH.\n\nOn first launch, Codewhale opens the composer, even with no key or route.\nWith no key or route, workspace trust is asked once a provider is connected;\notherwise only when the folder requires a decision. A provider is asked for\nwhen you send your first message: the message stays in the box while you sign\nin or use your own key, and a local model is offered only when a local Ollama\nserver answers. The provider step also has an explicit offline route.\n\nEverything optional stays available after that. Use `/setup` for the\nprogressive setup and repair guide, `/settings` for the full typed editor, and\n`/constitution` when you want your own constitution on top of the bundled one.\nThe localized telemetry choice appears only after the workspace is ready and\ndoes not block the composer.\n\nDeepSeek is the default provider. If you want to configure its key before or\nafter the first launch, the most direct setup path is:\n\n```bash\ncodewhale auth set --provider deepseek\n```\n\nYou can also provide a key through the environment:\n\n```bash\nexport DEEPSEEK_API_KEY=\"your-key\"\ncodewhale\n```\n\nNew Codewhale config is stored under `~/.codewhale/config.toml`. Legacy\n`~/.deepseek/config.toml` files are still supported for users migrating from\nthe old name.\n\nUse `/constitution` to review or change standing guidance. After setup, run a\ndoctor check:\n\n```bash\ncodewhale doctor\n```\n\nUse the JSON form when you need a machine-readable report for an issue:\n\n```bash\ncodewhale doctor --json\n```\n\nBoth forms are offline by default. They report structural configuration and\nliteral unknown/not-probed credential states without loading workspace `.env`\ncredentials, opeLine truncated
},
{
"path": "docs/INSTALL.md",
"sha256": "e8d15c92107272146ad38f66c487bda9c821a4fd85b46c0a1c884907acc6803a",
"content": "# Installing Codewhale\n\n> 阅读简体中文版:[zh_hans/INSTALL.md](zh_hans/INSTALL.md)\n\nCodewhale is an open-source coding agent that runs in your terminal. You give\nit a task (\"fix the failing test\", \"add a CLI flag\"). It reads your repository,\nedits files and runs commands. In the default **Ask** posture it applies file\nedits inside the workspace immediately (and shows you the diff), but asks before\nrunning shell commands, so commit or stash anything you care about first. It\nworks with many model providers. **DeepSeek** is the default.\n\nThe command is `codewhale`. `codew` is a shorter alias for the same program.\n\nThe installation paths were first checked with **v0.10.0** (released 2026-09-22) on a\nfresh **Ubuntu 24.04 x86_64** machine. The dated\n[install receipts](https://github.com/codewhale-hq/Codewhale/blob/37ecdfcc49bc68a9b0d058b97c3946e62c34bd31/docs/install-report/v0.10.0-2026-09-23/RECEIPTS.md) record the commands and output checked at that time. Steps that\ncould not be run on that machine are marked **(untested on this VM: reason)**.\nmacOS, Windows and Android are out of scope, apart from a few notes. A second\npass re-ran the installer, manual-download, archive and npm paths, the no-key\nchecks and zsh completion on **macOS 26.1 (Apple silicon)**; see\n[macOS notes](#macos-notes). Steps that need a model call were not re-run\nthere.\n\nInstall commands that use `latest` resolve to the latest **published** GitHub\nRelease or package. Between releases, `main` may already describe the next\nversion (for example a v0.10.2 source candidate before its tag). A\nprebuilt candidate is available through the official installer only after its\ntag, checksums and release assets exist. Contributors can build `main` now.\n\n## No account needed\n\nCodewhale needs no account. Keys you save with `/provider` or\n`codewhale auth set` stay on your machine; see\n[API keys and providers](#8-api-keys-and-providers). New Codewhale accounts\nare not open, so this guide does not cover account key storage.\n\n## Test and contribute to current source\n\n`latest` selects the latest published release. To test unreleased repairs,\nbuild canonical GitHub `main`, or CNB `main` after verifying its commit matches.\nThe source version number alone does not establish a published package.\n\n```bash\ngit clone --branch main https://github.com/codewhale-hq/CodeWhale.git\ncd CodeWhale\ncargo build --release --locked -p codewhale-cli\ntarget/release/codewhale --version\n```\n\nSee the Cargo section for build prerequisites; use the repository's stable Rust\nselection (declared minimum 1.89). Windows uses `target/release/codewhale.exe`\nand needs MSVC. A development build is separate from a signed release package.\nFor mainland-China downloads and source validation, see [CNB mirror](CNB_MIRROR.md).\nThe [Tencent Lighthouse bootstrap](../scripts/tencent-lighthouse/README.md) is\nbilingual. An npm registry mirror serves the wrapper; native binary downloads\nstill need an available release asset source.\n\n---\n\n## 60-second quickstart (Linux or macOS)\n\n```bash\n# 1. Install. Downloads two checksum-verified binaries into ~/.local/bin (no sudo).\ncurl -fsSL https://codewhale.net/install.sh | sh\n\n# 2. Make sure ~/.local/bin is on your PATH, now and in future terminals.\necho 'export PATH=\"$HOME/.local/bin:$PATH\"' >> ~/.bashrc # zsh: use ~/.zshrc\nexport PATH=\"$HOME/.local/bin:$PATH\"\ncodewhale --version # -> codewhale 0.10.0 (1be1a703b975)\n\n# 3. Give it a DeepSeek API key (from https://platform.deepseek.com/api_keys).\ncodewhale auth set --provider deepseek # prompts for the key; nothing is echoed\ncodewhale auth status --provider deepseek # \"active source: secret store\"\n\n# 4. Run your first task inside a git repository.\ncd ~/your-project\ncodewhale\n```\n\nIn the TUI, type something concrete:\n\n```text\ncreate a Python file primes.py that prints the first 10 primes, run it, and show me the output\n```\n\nCodewhale writes the file, shows you a diff, then asks **APPROVAL: bash\npython3 primes.py – Do you want to proceed?** Press `y` to allow it once. It\nruns the command and reports the output. Press `Ctrl-D` (with an empty input\nbox) to quit. It prints the command to resume the session later.\n\n> **If nothing happens after you send your first message,** you have no key\n> configured. v0.10.0 doesn't warn you in that case. Press **F3**. If DeepSeek\n> shows `missing key`, press Enter, paste the key, then pick a model and\n> confirm.\n\n---\n\n## Contents\n\n1. [Before you start](#1-before-you-start)\n2. [Install: recommended installer](#2-recommended-installer-curl--sh)\n3. [Install: manual download from GitHub Releases](#3-manual-download-from-github-releases)\n4. [Install: npm](#4-npm)\n5. [Install: Cargo / build from source](#5-cargo-and-building-from-source)\n6. [Install: Homebrew (Linux) and Nix](#6-homebrew-on-linux-and-nix)\n7. [Updating and rolling back]Line truncated
},
{
"path": "docs/MODES.md",
"sha256": "ea1e371421770a8582195f4c80c70fe9d067b3800ef143c7fea1fdcf444f0ecd",
"content": "# Modes and Permission Postures\n\n> 阅读简体中文版:[zh_hans/MODES.md](zh_hans/MODES.md)\n\nCodewhale has three related concepts:\n\n- **TUI mode**: what kind of visible interaction you're in (Plan/Work/Operate).\n- **Permission posture**: how aggressively the UI asks before executing tools.\n- **Workflow**: named steps that coordinate sub-agents, with dependencies and\n results, available in any TUI mode.\n\nModel selection is separate. `--model auto` and `/model auto` route each turn to\na concrete model and thinking level; they are not TUI modes and are not part of\nthe `Tab` cycle.\n\nWorkflow runs named steps through the same sub-agent runtime; Fleet manages\ntheir saved roles and model assignments. A workflow adds ordering, result\nhandoffs, gates and a progress view. The active mode and permission posture\nstill control what each step may execute.\n\nBefore assigning steps, query `agent(action=\"roster\")` for the saved Fleet\nmodels and roles. A plan child can select a listed model with `model`, or use\nthe saved `role`/`profile` assignment. An Exact Fleet fixes each member's route.\n\n## TUI Modes\n\nPress `Tab` to complete composer menus or cycle through the visible modes\nwhen the composer is empty: **Plan → Work → Operate → Plan**. `Tab` never sends\nor queues composer text; use `Enter` to send or queue it.\nPress `Shift+Tab` to cycle permission posture (Ask → Auto-Review → Full Access).\nEvery change of effective permission writes a `Permissions changed to …` line\nin the transcript.\nPress `Ctrl+T` to cycle reasoning effort.\nRun `/mode` to open the mode picker, or switch directly with `/mode work`,\n`/mode plan`, or `/mode operate`.\n\n- **Plan**: design-first prompting. The stable primitive names remain familiar, but the runtime centrally refuses file mutation and shell execution. Read-only inspection and policy-allowed research, including deferred Web search/fetch, remain available. When a Plan turn successfully finishes a nonempty response, including a prose-only plan, the TUI asks how to continue: Work (Ask), Work (Auto-Review), or keep planning. Work sends the exact approved response as a normal follow-up and preserves that turn's existing To-dos, or adds one pending plan item without replacing unrelated work.\n- **Work** (internally `agent`): ordinary multi-step execution. The first-turn toolbox includes `read`, `write`, `edit`, `file_search`, `bash`, `agent`, `workflow`, `todo_write`, and `load_skill`, plus `create_goal`, `get_goal`, `update_goal`, and `task_shell_wait` so goal and background-shell controls are available without discovery. The agent calls `create_goal` when a request is a durable objective; the host never infers a goal from wording, and the contract must not tell the model otherwise. Approval, sandbox, repository law, and managed policy decide what may execute.\n- **Operate**: Work at full strength, the \"ultra\" mode. Same tools and authority as Work; the difference is how they are used. Each substantive request becomes a goal (`create_goal`), the plan stays visible (`todo_write`), multi-part work runs through `workflow` and parallel `agent` workers, non-trivial changes get an independent reviewer before they are called done, long commands keep running in the background, and recurring or watch-type work is offered as an `automation` (created after approval). It keeps going until the goal is verified, blocked on you, or paused.\n\n`Act` and `/mode act` remain compatibility aliases for Work. Saved settings\nstill normalize to the internal value `agent`.\n\n### Tool availability by mode\n\n| Tool family | Plan | Work | Operate |\n|:---|:---:|:---:|:---:|\n| `read` and policy-allowed deferred research tools | yes | yes | yes |\n| `write` and `edit` | visible names; execution denied | approval- and policy-gated | same as Work |\n| `bash` | visible name; execution denied | approval- and policy-gated | same as Work; delegation is preferred when parallelism or isolation helps |\n| `agent` | yes, subject to child-depth authority | yes, subject to child-depth authority | yes, subject to child-depth authority |\n| Deferred native, MCP, and plugin tools | discoverable through `tool_search` when policy permits | same | same |\n| Paid or external-service tools | follows permission posture | follows permission posture | follows permission posture |\n| Access outside the workspace root | explicit trusted paths only | only through trusted paths or trust mode | same trusted-path/trust policy as Work; fleet profiles never widen it |\n\nOperate changes scheduling emphasis, not authority. It neither adds a\nmode-specific tool denial nor bypasses the active approval, sandbox, shell,\nask-rule, repository-law, or managed-policy boundary. Plan remains the\nmode-specific execution boundary for shell and write-capable tools; that\nauthority difference does not require a different primitive vocabulary.\n\n### Operate loop (one screen)\n\n```text\nUseLine truncated
},
{
"path": "docs/SANDBOX.md",
"sha256": "1626a34e894f491c15959ebe82b87d2f669c792f9c9457fb912023dcee1a5a33",
"content": "# Sandbox threat model\n\n> 阅读简体中文版:[zh_hans/SANDBOX.md](zh_hans/SANDBOX.md)。\n\nCodewhale can launch shell commands proposed by a model. Approval policy,\nworkspace-aware tools, and an operating-system command wrapper are separate\ncontrols: an approval is not a sandbox, and selecting `workspace-write` does\nnot prove that the current platform has an OS wrapper available.\n\nThis document describes only behavior wired into the command execution path.\nSee [Authorization order](AUTHORIZATION_ORDER.md) for the policy layers that\nrun before execution reaches this boundary.\n\n## Platform overview\n\n| Mechanism | Platform | Selection | What Codewhale reports |\n|---|---|---|---|\n| Seatbelt (`sandbox-exec`) | macOS | Automatic when the runtime probe succeeds | `macos-seatbelt` |\n| Bubblewrap (`/usr/bin/bwrap`) | Linux | Default when installed and a wrapped probe run succeeds; `prefer_bwrap = false` opts out | `linux-bwrap` |\n| No OS wrapper | Linux without usable bwrap, or opted out | `prefer_bwrap = false`, or bwrap absent/unusable | `none` |\n| No OS wrapper | Windows | Current implementation | `none` |\n| OpenSandbox-compatible service | Any supported host | `sandbox_backend = \"opensandbox\"` | External execution path |\n\nThe repository contains a seccomp implementation module plus a future Windows\nhelper contract. They are not wired into child-command launch, so Codewhale\ndoes not advertise them as active sandboxes. Source-only sandbox code is not\nevidence that a command was restricted.\n\n## macOS: Seatbelt\n\nCodewhale probes `/usr/bin/sandbox-exec` by running a minimal profile. When the\nprobe succeeds and the selected `SandboxPolicy` requests a sandbox, the child\ncommand is wrapped with a generated Seatbelt profile.\n\nThe profile can provide:\n\n- broad filesystem reads;\n- writes limited by the selected policy, including the workspace and specific\n runtime/cache paths needed by supported tools;\n- network access only when the policy enables it. A `read-only` policy with\n network (the agent `read_only: true` shell mode) gets outbound IPv4 and the\n system DNS resolver only. IPv6 and the host's own addresses, loopback\n included, are denied, except the port of a local proxy named in\n `HTTP_PROXY`, `HTTPS_PROXY` or `ALL_PROXY`, and other local sockets stay\n denied.\n\nIf the probe fails or `sandbox-exec` is unavailable, Codewhale reports no OS\nsandbox and launches the command without a Seatbelt wrapper. It does not set a\nSeatbelt marker on that fallback.\n\n## Linux: default bubblewrap\n\nLinux command sandboxing is on by default whenever bubblewrap works. Set the\ntop-level configuration key to opt out:\n\n```toml\nprefer_bwrap = false\n```\n\nCodewhale selects bubblewrap only when `/usr/bin/bwrap` is a regular\nexecutable file AND a wrapped probe run proves it can create its namespaces\non this host — the exec bit alone lies where user namespaces are restricted\n(e.g. Ubuntu 24.04 with `kernel.apparmor_restrict_unprivileged_userns`),\nwhere every wrapped command would fail rather than run unsandboxed. The\nwrapper derives its mounts and network namespace from the resolved\n`SandboxPolicy`:\n\n```text\n/usr/bin/bwrap \\\n --unshare-all \\\n [--share-net] \\\n --ro-bind / / \\\n --dev /dev \\\n --proc /proc \\\n --tmpfs /tmp \\\n [--dev-bind <device-root> <device-root> ...] \\\n --bind <writable-root> <writable-root> ... \\\n --ro-bind <protected-descendant> <protected-descendant> ... \\\n [--ro-bind <extra-ro-root> <extra-ro-root> ...] \\\n --chdir <cwd> \\\n -- <program> <args>\n```\n\nThe sandbox always gets a private `/dev` (fresh device nodes, so `>/dev/null`\nworks), a private `/proc`, and a tmpfs `/tmp` (#5410). Two optional top-level\nconfig keys extend the mounts: `bwrap_ro_roots` (extra host paths bind-mounted\nread-only, applied last so they can narrow a policy-writable path) and\n`bwrap_dev_roots` (host character/block device nodes bind-mounted read-write;\ndirectories are never honored). Missing paths are skipped silently.\n\nThat gives the child a read-only root view. For `workspace-write`, every safe,\nexisting policy root is mounted read-write: the working directory, configured\nadditional roots, `/tmp` and `TMPDIR` unless excluded, and verified Git\nworktree metadata roots. Existing `.codewhale` and `.deepseek` descendants are\nremounted read-only after their writable parent. Missing paths, non-directory\npaths, and `/` are not promoted to writable mounts.\n\nFor `read-only`, there are no writable binds, so the working directory remains\ninside the read-only root view. `--unshare-all` isolates the network namespace\nby default. Codewhale adds `--share-net` only when the policy's\n`network_access` is true. The agent `read_only: true` shell mode turns it on\nonly in Full Access (see [Subagents](SUBAGENTS.md)); it then shares the host\nnetwork namespace, including loopback and abstract unix sockets, like a\nnetworLine truncated
},
{
"path": "docs/KEYBINDINGS.md",
"sha256": "26d213c47e899e376f240d2f9f08efeac49911a7472ee7d7ed11fea249509ab3",
"content": "# Keybindings\n\n> 阅读简体中文版:[zh_hans/KEYBINDINGS.md](zh_hans/KEYBINDINGS.md)\n\nThis is the source-of-truth catalog of every keyboard shortcut the TUI recognizes. Bindings are grouped by **context** — the focus or modal state they fire in. A binding listed under \"Composer\" only takes effect when the composer is focused; one under \"Transcript\" only when the transcript has focus; and so on.\n\nGlobal key chords are not yet user-configurable — tracked for a future release (#436, #437). Hotbar slot actions are configurable with `[[hotbar]]` and `/hotbar`; the Hotbar activation chord remains `Alt-1` through `Alt-8`.\n\n## Global (any context)\n\n| Chord | Action |\n|----------------------|---------------------------------------------------------------|\n| `/help`, `F1` or `Ctrl-/` | Toggle the help overlay. `/help` is what the chrome advertises: it is the one route every terminal delivers, where `F1` is eaten by tmux and some emulators and `Ctrl-/` encoding varies |\n| `F2` | Toggle the typed Settings editor |\n| `F3` | Open the provider/model picker (same as `/provider`) |\n| `Ctrl-K` | Open the command palette (slash-command finder) |\n| `Ctrl-C` | Cancel current turn / dismiss modal / arm-then-confirm quit |\n| `Ctrl-B` | Release the current shell wait so the turn can continue; foreground commands move into `/jobs`, and background tasks keep running. Use `/jobs` or `Bash` with `action: \"wait\"` to inspect output |\n| `Ctrl-D` | Quit (only when the composer is empty) |\n| `Tab` | When the composer is empty, cycle TUI mode: Plan → Work → Operate → Plan |\n| `Shift+Tab` | Cycle permission posture: Ask → Auto-Review → Full Access. Live regardless of composer contents or whether a turn is running (suppressed only while a modal other than Config is open) |\n| `Ctrl-T` | Cycle reasoning effort for the active model. Walks the same ladder as `/model` and `/effort` (catalog or documented route dialect). Always-thinking models omit `off`; Grok 4.6 includes `xhigh`. |\n| `Ctrl-Shift-T` | Toggle live transcript overlay (sticky-tail auto-scroll) |\n| `Ctrl-R` | Open the resume-session picker |\n| `Ctrl-L` | Compact the conversation context (status line shows progress; no-op while a compaction is already running) |\n| `Ctrl-O` | Open the reasoning detail for the selected or current turn, regardless of composer contents |\n| `Ctrl-Alt-O` | Open the whole-turn Turn Inspector, regardless of composer contents |\n| `Alt-V` / `Option-V` (macOS) | Open the details pager for the selected tool/sub-agent card; with nothing selected, the newest visible error, else the first visible card with details, else the most recent one. Click a card first to keep Alt-V on it while newer cards arrive. Terminals that emit the legacy Option-V glyph are also handled |\n| `Ctrl-Shift-E` / `Cmd-Shift-E` | Toggle the file tree in the workbar |\n| `Ctrl-]` or `Ctrl-Tab` | Cycle the pet view while the pet main view is showing; otherwise cycle the work dock: TODO → Agents → Jobs → Background. `Ctrl-]` is portable; `Ctrl-Tab` requires the kitty keyboard protocol. Terminal.app uses it to switch terminal tabs |\n| `Ctrl-Shift-Tab` | Cycle the pet view or work dock backwards where the terminal sends the kitty keyboard chord. Terminal.app uses it to switch terminal tabs |\n| `Alt-G` / `Alt-Shift-G` | Scroll transcript to top / bottom when the composer is empty |\n| `Alt-1`-`Alt-8` | Dispatch Hotbar slots 1-8 when no modal or inline picker is open |\n| `Alt-!` / `Alt-@` / `Alt-#` / `Alt-$` | Select the workbar panel: Tasks / Agents / Context / Files |\n| `Ctrl-Alt-0` | Toggle the workbar off / back to the bottom placement |\n| `Alt-L` | Open the pager for the last message (composer empty) |\n| `Alt-P` / `Alt-A` / `Alt-Y` | Jump to Plan / Work, or request Full Access (`Alt-Y` is the legacy permission channel — Work + Full Access — not a separate mode; it honors a locked approval policy) |\n| `Ctrl-X` (Activity workbar) | Cancel all running background shell jobs |\n| `Esc` | Close topmost modal · cancel slash menu · dismiss toast. During a compact that is serving an in-flight turn, Esc stops the turn (the compact is collateral). |\n\n## Composer\n\nEditing the message you're about to send.\n\n| Chord | Action |\n|-----------------------------|---------------------------------------------------------|\n| `Enter` | Send when idle; queue while busLine truncated
}
],
"missing": []
},
{
"id": "install",
"slug": "install",
+273
View File
@@ -2299,6 +2299,279 @@
],
"sourceNote": "Source documents: docs/public-surface-facts.json (trust), docs/TELEMETRY.md, docs/SANDBOX.md · Update docs-map.ts when changing."
},
"docs-first-time": {
"metaTitle": "Use Codewhale for the first time · Codewhale Docs",
"metaDescription": "Open a terminal, install Codewhale, pick the folder it works in, connect a model and finish a first task, with no terminal experience needed.",
"bodyClassName": "text-ink-soft leading-relaxed",
"title": "Use Codewhale for the first time",
"lede": "This page takes you from opening a terminal for the first time to finishing a small task with Codewhale. It also explains which folder to start in, because that folder decides what Codewhale can change.",
"sections": [
{
"id": "what-you-need",
"title": "Check what you need",
"blocks": [
{
"p": "You need a Mac, a Windows x64 PC or a Linux computer, and an internet connection for the install. To get answers you also need a model: an API key from a hosted provider such as DeepSeek, or a model running on your own computer with Ollama."
},
{
"p": "Codewhale is free and needs no account. A hosted provider bills you directly for what you use, and a model you run yourself needs no key."
}
]
},
{
"id": "open-terminal",
"title": "Open a terminal",
"blocks": [
{
"p": "A terminal is a window where you type commands instead of clicking. A command runs when you press Return, which is Enter on Windows."
},
{
"rows": [
[
"macOS",
"Press Cmd+Space, type Terminal and press Return."
],
[
"Windows",
"Open the Start menu, type PowerShell and press Enter."
],
[
"Linux",
"Open your applications menu and search for Terminal. On Ubuntu, Ctrl+Alt+T opens one."
]
]
},
{
"note": "Paste with Cmd+V on a Mac and Ctrl+V on Windows. Most Linux terminals paste with Ctrl+Shift+V."
}
]
},
{
"id": "install",
"title": "Install Codewhale",
"blocks": [
{
"p": "On a Mac or Linux, paste this command and press Return. It downloads Codewhale, checks the download, and adds Codewhale to your PATH, the list of folders your terminal searches for commands."
},
{
"code": "curl -fsSL https://codewhale.net/install.sh | sh",
"lang": "macOS · Linux"
},
{
"p": "On Windows, paste this command and press Enter:"
},
{
"code": "winget install --id HunterBown.CodeWhale --exact --source winget",
"lang": "Windows"
},
{
"p": "When it finishes, open a new terminal window so it picks up the new PATH. Then check that it worked: a version number means Codewhale is installed."
},
{
"code": "codewhale --version",
"lang": "Terminal"
},
{
"p": "If the terminal says `command not found`, follow [the PATH steps on the install page](/install#install-path)."
}
]
},
{
"id": "choose-folder",
"title": "Choose the folder to work in",
"blocks": [
{
"p": "Codewhale works in the folder your terminal is in when you start it. That folder is its workspace: Codewhale reads and changes files there, and its file tools cannot reach other folders unless you allow it."
},
{
"p": "On a Mac, an operating-system sandbox also stops its commands from writing outside the workspace, apart from temporary files. Linux gets the same sandbox when bubblewrap is installed. Windows has no such sandbox yet, so Codewhale relies on asking you first."
},
{
"rows": [
[
"A folder for one project",
"The best choice for most work. Codewhale sees only that project, and `/undo` can take back what it changed."
],
[
"A new, empty folder",
"A good start for something new, such as a website or a script. The next section shows how to make one."
],
[
"A folder that holds several projects",
"Lets one task read and change files across all of them. Pick the smallest folder that holds everything the task needs; in a very large folder Codewhale turns snapshots off and tells you."
],
[
"Your home folder, Desktop, Documents or Downloads",
"Codewhale runs there, but `/undo` and snapshots are off in these folders themselves because they are too broad to snapshot safely. A folder inside one of them works normally."
]
]
},
{
"note": "Snapshots are Codewhale's own copies of the files it changes. It keeps them in its settings folder, not in your project, and they work without git."
},
{
"p": "To let Codewhale reach one more folder, type `/trust add` and that folder's path in Codewhale. Full Access mode reaches every folder and runs commands without the sandbox, so use it only when you want Codewhale to work across your whole computer."
},
{
"p": "By default, Codewhale makes file edits in a git repository without asking and shows you what changed. In a folder without git, it asks before each edit."
}
]
},
{
"id": "open-in-folder",
"title": "Open a terminal in that folder",
"blocks": [
{
"rows": [
[
"macOS Finder",
"Control-click the folder and choose Services > New Terminal at Folder. If it is missing, turn it on in System Settings > Keyboard > Keyboard Shortcuts > Services > Files and Folders."
],
[
"Windows File Explorer",
"Right-click the folder and choose Open in Terminal. On Windows 10, hold Shift while you right-click and choose Open PowerShell window here."
],
[
"Any terminal",
"Type `cd` and a space, drag the folder from Finder or File Explorer into the window, and press Return."
],
[
"Without moving",
"Start Codewhale with `-C` and the folder's path, for example `codewhale -C ~/Projects/my-site` on a Mac."
]
]
},
{
"p": "These commands find your way around from inside a terminal. They work on a Mac, on Linux and in PowerShell."
},
{
"codeTerms": true,
"rows": [
[
"pwd",
"Shows the folder you are in."
],
[
"ls",
"Lists what is in it."
],
[
"cd Documents",
"Moves into the Documents folder inside the current one."
],
[
"cd ..",
"Moves up one folder."
],
[
"mkdir my-site",
"Makes a new folder called my-site."
]
]
},
{
"note": "Type the first letters of a folder name and press Tab, and the terminal finishes the name for you."
}
]
},
{
"id": "connect",
"title": "Start Codewhale and connect a model",
"blocks": [
{
"code": "codewhale",
"lang": "Terminal"
},
{
"p": "Codewhale opens a message box right away. When you send your first message, it asks where your model runs: pick a hosted provider and paste its API key, or pick Ollama when it is running on your computer."
},
{
"p": "Keys you enter stay on your computer, in Codewhale's settings folder `~/.codewhale`. Change the provider later with `/provider`."
},
{
"p": "If Codewhale asks whether to work with the instructions and files in this folder, answer yes for a folder you made yourself."
}
]
},
{
"id": "first-task",
"title": "Give it a first task",
"blocks": [
{
"p": "Type what you want in plain words and press Return. A safe first task changes nothing, for example: `Explain what is in this folder and what each file is for. Do not change anything.`"
},
{
"p": "Then try one that makes a file, for example: `Make a file called packing-list.md with a packing list for a weekend trip.`"
},
{
"p": "By default, Codewhale asks before it runs a command that is not read-only. Read what the approval says will run before you allow it."
}
]
},
{
"id": "stop-undo-quit",
"title": "Stop, undo and quit",
"blocks": [
{
"codeTerms": true,
"rows": [
[
"Esc",
"Stops the task that is running. Ctrl+C does the same."
],
[
"/undo",
"Takes back your last request and the file changes it made."
],
[
"/diff",
"Shows what changed in this folder."
],
[
"/receipts",
"Lists what Codewhale did: files, commands and approvals."
],
[
"/exit",
"Quits Codewhale. Ctrl+D also quits when the message box is empty."
],
[
"codewhale --continue",
"Run it in the same folder to pick up your last conversation there."
]
]
}
]
},
{
"id": "help",
"title": "Get help",
"blocks": [
{
"p": "Type `/help` in Codewhale to list its commands. [Ask in the Codewhale Discord](https://discord.gg/37gfS3ksug), or read [what to do when something goes wrong](/docs/troubleshooting)."
}
]
}
],
"next": [
{
"href": "/docs/guide",
"label": "Start your first task",
"note": "More first prompts and how to check the answer."
},
{
"href": "/docs/auth",
"label": "Connect a provider",
"note": "Save a key, check which key is used, or run a local model."
},
{
"href": "/docs/modes",
"label": "Set modes and approvals",
"note": "Decide which commands and edits stop for your approval."
}
],
"sourceNote": "Source documents: docs/GUIDE.md, docs/INSTALL.md, docs/MODES.md, docs/SANDBOX.md, docs/KEYBINDINGS.md · Update docs-map.ts when changing."
},
"states": {
"loadingLabel": "Loading…",
"emptyTitle": "Nothing here yet",
+273
View File
@@ -2296,6 +2296,279 @@
],
"sourceNote": "来源文档:docs/public-surface-facts.json(trust)、docs/TELEMETRY.md、docs/SANDBOX.md · 修改时同步更新 docs-map.ts。"
},
"docs-first-time": {
"metaTitle": "第一次使用 Codewhale · Codewhale 文档",
"metaDescription": "打开终端、安装 Codewhale、选择它工作的文件夹、连接模型并完成第一个任务,不需要任何终端经验。",
"bodyClassName": "text-ink-soft leading-[1.9] tracking-wide",
"title": "第一次使用 Codewhale",
"lede": "这一页带你从第一次打开终端开始,一直到用 Codewhale 完成一个小任务。它还会说明应该从哪个文件夹启动,因为这个文件夹决定了 Codewhale 能修改什么。",
"sections": [
{
"id": "what-you-need",
"title": "确认你需要准备什么",
"blocks": [
{
"p": "你需要一台 Mac、一台 Windows x64 电脑或一台 Linux 电脑,安装时还需要联网。要得到回答,你还需要一个模型:来自 DeepSeek 等托管提供商的 API 密钥,或者用 Ollama 在你自己电脑上运行的模型。"
},
{
"p": "Codewhale 免费,也不需要账号。托管提供商会按你的用量直接向你收费;你自己运行的模型不需要密钥。"
}
]
},
{
"id": "open-terminal",
"title": "打开终端",
"blocks": [
{
"p": "终端是一个用输入命令代替点击的窗口。按下回车键后,命令就会运行。"
},
{
"rows": [
[
"macOS",
"按 Cmd+空格键,输入 Terminal,然后按回车键。"
],
[
"Windows",
"打开“开始”菜单,输入 PowerShell,然后按回车键。"
],
[
"Linux",
"打开应用程序菜单并搜索“终端”。在 Ubuntu 上,按 Ctrl+Alt+T 也能打开。"
]
]
},
{
"note": "在 Mac 上用 Cmd+V 粘贴,在 Windows 上用 Ctrl+V 粘贴。大多数 Linux 终端用 Ctrl+Shift+V 粘贴。"
}
]
},
{
"id": "install",
"title": "安装 Codewhale",
"blocks": [
{
"p": "在 Mac 或 Linux 上,粘贴下面这条命令并按回车键。它会下载 Codewhale、校验下载内容,并把 Codewhale 加入 PATH,也就是终端查找命令时会搜索的文件夹列表。"
},
{
"code": "curl -fsSL https://codewhale.net/install.sh | sh",
"lang": "macOS · Linux"
},
{
"p": "在 Windows 上,粘贴下面这条命令并按回车键:"
},
{
"code": "winget install --id HunterBown.CodeWhale --exact --source winget",
"lang": "Windows"
},
{
"p": "安装完成后,打开一个新的终端窗口,让它读取新的 PATH。然后确认安装成功:显示版本号就说明 Codewhale 已经安装好了。"
},
{
"code": "codewhale --version",
"lang": "终端"
},
{
"p": "如果终端提示 `command not found`,请按照[安装页面上的 PATH 步骤](/install#install-path)操作。"
}
]
},
{
"id": "choose-folder",
"title": "选择工作的文件夹",
"blocks": [
{
"p": "Codewhale 在你启动它时终端所在的文件夹里工作。这个文件夹就是它的工作区:Codewhale 在这里读取和修改文件,除非你允许,它的文件工具无法访问其他文件夹。"
},
{
"p": "在 Mac 上,操作系统沙箱还会阻止它的命令写入工作区以外的位置(临时文件除外)。在 Linux 上,安装 bubblewrap 后也有同样的沙箱。Windows 目前还没有这种沙箱,因此 Codewhale 依靠先征求你的同意。"
},
{
"rows": [
[
"只放一个项目的文件夹",
"大多数情况下的最佳选择。Codewhale 只看到这个项目,`/undo` 可以撤回它做的修改。"
],
[
"一个新的空文件夹",
"适合开始做新东西,比如一个网站或一个脚本。下一节会说明如何创建。"
],
[
"包含多个项目的文件夹",
"让一个任务可以读取和修改所有这些项目里的文件。请选择能装下任务所需全部内容的最小文件夹;文件夹非常大时,Codewhale 会关闭快照并告诉你。"
],
[
"你的主文件夹、桌面、文稿或下载",
"Codewhale 可以在这里运行,但在这些文件夹本身中 `/undo` 和快照是关闭的,因为它们范围太大,无法安全地做快照。它们里面的子文件夹可以正常使用。"
]
]
},
{
"note": "快照是 Codewhale 为它修改的文件保存的副本。它们保存在 Codewhale 的设置文件夹里,而不是你的项目里,并且不需要 git。"
},
{
"p": "要让 Codewhale 多访问一个文件夹,请在 Codewhale 中输入 `/trust add` 和该文件夹的路径。Full Access 模式可以访问所有文件夹,并且在没有沙箱的情况下运行命令,所以只有在你希望 Codewhale 在整台电脑范围内工作时才使用它。"
},
{
"p": "默认情况下,在 git 仓库中 Codewhale 会直接修改文件,并向你展示改了什么。在没有 git 的文件夹中,它会在每次修改前询问你。"
}
]
},
{
"id": "open-in-folder",
"title": "在那个文件夹中打开终端",
"blocks": [
{
"rows": [
[
"macOS 访达",
"按住 Control 点按该文件夹,选择“服务”>“新建位于文件夹位置的终端窗口”。如果没有这一项,请在“系统设置”>“键盘”>“键盘快捷键”>“服务”>“文件和文件夹”中打开它。"
],
[
"Windows 文件资源管理器",
"右键点击该文件夹,选择“在终端中打开”。在 Windows 10 上,按住 Shift 再右键点击,选择“在此处打开 PowerShell 窗口”。"
],
[
"任意终端",
"输入 `cd` 和一个空格,把文件夹从访达或文件资源管理器拖进窗口,然后按回车键。"
],
[
"不切换文件夹",
"启动 Codewhale 时加上 `-C` 和文件夹路径,例如在 Mac 上输入 `codewhale -C ~/Projects/my-site`。"
]
]
},
{
"p": "下面这些命令可以在终端里切换和查看文件夹。它们在 Mac、Linux 和 PowerShell 中都能用。"
},
{
"codeTerms": true,
"rows": [
[
"pwd",
"显示你当前所在的文件夹。"
],
[
"ls",
"列出其中的内容。"
],
[
"cd Documents",
"进入当前文件夹里的 Documents 文件夹。"
],
[
"cd ..",
"回到上一级文件夹。"
],
[
"mkdir my-site",
"新建一个名为 my-site 的文件夹。"
]
]
},
{
"note": "输入文件夹名的前几个字母后按 Tab 键,终端会帮你补全名字。"
}
]
},
{
"id": "connect",
"title": "启动 Codewhale 并连接模型",
"blocks": [
{
"code": "codewhale",
"lang": "终端"
},
{
"p": "Codewhale 会立即打开一个输入框。你发送第一条消息时,它会询问模型在哪里运行:选择一个托管提供商并粘贴它的 API 密钥,或者在你的电脑上运行着 Ollama 时选择 Ollama。"
},
{
"p": "你输入的密钥保存在你自己的电脑上,位于 Codewhale 的设置文件夹 `~/.codewhale` 中。之后可以用 `/provider` 更换提供商。"
},
{
"p": "如果 Codewhale 询问是否使用这个文件夹中的说明和文件,对于你自己创建的文件夹,回答“是”即可。"
}
]
},
{
"id": "first-task",
"title": "交给它第一个任务",
"blocks": [
{
"p": "用平常的话写下你想做的事,然后按回车键。安全的第一个任务不修改任何东西,例如:`说明这个文件夹里有什么,以及每个文件的用途。不要修改任何内容。`"
},
{
"p": "然后试一个会创建文件的任务,例如:`创建一个名为 packing-list.md 的文件,写一份周末出游的行李清单。`"
},
{
"p": "默认情况下,Codewhale 在运行非只读命令前会先询问你。在允许之前,请先看清审批里写明要运行什么。"
}
]
},
{
"id": "stop-undo-quit",
"title": "停止、撤回和退出",
"blocks": [
{
"codeTerms": true,
"rows": [
[
"Esc",
"停止正在运行的任务。Ctrl+C 的作用相同。"
],
[
"/undo",
"撤回你的上一个请求及其造成的文件修改。"
],
[
"/diff",
"显示这个文件夹中改了什么。"
],
[
"/receipts",
"列出 Codewhale 做过的事:文件、命令和审批。"
],
[
"/exit",
"退出 Codewhale。输入框为空时,按 Ctrl+D 也会退出。"
],
[
"codewhale --continue",
"在同一个文件夹中运行它,可以继续你在那里的上一次对话。"
]
]
}
]
},
{
"id": "help",
"title": "获取帮助",
"blocks": [
{
"p": "在 Codewhale 中输入 `/help` 可以列出它的命令。你也可以[在 Codewhale Discord 中提问](https://discord.gg/37gfS3ksug),或阅读[出现问题时怎么办](/docs/troubleshooting)。"
}
]
}
],
"next": [
{
"href": "/docs/guide",
"label": "开始第一个任务",
"note": "更多第一个提示词的示例,以及如何检查回答。"
},
{
"href": "/docs/auth",
"label": "连接模型提供商",
"note": "保存密钥、查看当前使用的密钥,或运行本地模型。"
},
{
"href": "/docs/modes",
"label": "设置模式与审批",
"note": "决定哪些命令和修改需要你批准。"
}
],
"sourceNote": "来源文档:docs/GUIDE.md、docs/INSTALL.md、docs/MODES.md、docs/SANDBOX.md、docs/KEYBINDINGS.md · 修改时请同步更新 docs-map.ts。"
},
"states": {
"loadingLabel": "加载中…",
"emptyTitle": "这里还没有内容",
+24
View File
@@ -48,6 +48,30 @@ export const INSTALL_COPY = {
scriptLink: { en: "View install script", zh: "查看安装脚本" },
releaseLine: { en: "This installs the latest published release, v{version}.", zh: "这会安装最新已发布版本 v{version}。" },
newTitle: { en: "New to the terminal?", zh: "第一次使用终端?" },
newLead: {
en: "You install Codewhale by pasting one command into a terminal. These steps show how to open one and how to check that it worked.",
zh: "你在终端中粘贴一条命令来安装 Codewhale。下面的步骤说明如何打开终端,以及如何确认安装成功。",
},
newMacTitle: { en: "macOS", zh: "macOS" },
newMac1: { en: "Press Cmd+Space, type Terminal and press Return.", zh: "按 Cmd+空格键,输入 Terminal,然后按回车键。" },
newMac2: { en: "Paste this command with Cmd+V and press Return:", zh: "用 Cmd+V 粘贴下面这条命令,然后按回车键:" },
newMac3: {
en: "When it finishes, open a new window with Cmd+N, type `codewhale --version` and press Return. A version number means it worked.",
zh: "安装完成后,按 Cmd+N 打开一个新窗口,输入 `codewhale --version` 并按回车键。显示版本号就说明安装成功了。",
},
newWinTitle: { en: "Windows", zh: "Windows" },
newWin1: { en: "Open the Start menu, type PowerShell and press Enter.", zh: "打开“开始”菜单,输入 PowerShell,然后按回车键。" },
newWin2: { en: "Paste this command with Ctrl+V and press Enter:", zh: "用 Ctrl+V 粘贴下面这条命令,然后按回车键:" },
newWin3: {
en: "When it finishes, close the window, open PowerShell again, type `codewhale --version` and press Enter. A version number means it worked.",
zh: "安装完成后,关闭窗口,重新打开 PowerShell,输入 `codewhale --version` 并按回车键。显示版本号就说明安装成功了。",
},
newNext: {
en: "Read the first-time guide: which folder to work in, your first task, and how to undo",
zh: "阅读首次使用指南:在哪个文件夹工作、第一个任务,以及如何撤回",
},
pathTitle: { en: "What the installer does to your PATH", zh: "安装程序对 PATH 做了什么" },
pathPlaces: {
en: "The installer puts `codewhale` and `codew` in `~/.local/bin`. When that folder is not on your PATH, it adds three marked lines to the startup file for your shell:",
+2 -2
View File
@@ -4,7 +4,7 @@ import {
getDocsAuth, getDocsClaudeCode, getDocsConfiguration, getDocsFleet,
getDocsHooks, getDocsMcp, getDocsModes, getDocsPet, getDocsReview,
getDocsRuntimeApi, getDocsSandbox, getDocsSkills, getDocsSubagents,
getDocsTroubleshooting, getDocsTrust, getDocsWeb, getDocsWork,
getDocsTroubleshooting, getDocsTrust, getDocsWeb, getDocsWork, getDocsFirstTime,
getDocsGuide, getDocsVocabulary, getConstitution, getChrome, getHome, fill, pickText,
} from "./i18n/dictionaries";
import type { DocsBlock, DocsPageDict } from "./i18n/dictionaries/types";
@@ -31,7 +31,7 @@ const ARTICLES: Record<string, (locale: string) => DocsPageDict> = {
pet: getDocsPet, review: getDocsReview, "runtime-api": getDocsRuntimeApi,
sandbox: getDocsSandbox, skills: getDocsSkills, subagents: getDocsSubagents,
troubleshooting: getDocsTroubleshooting, trust: getDocsTrust, web: getDocsWeb,
work: getDocsWork,
work: getDocsWork, "first-time": getDocsFirstTime,
};
function blockText(block: DocsBlock): string {
+12
View File
@@ -40,6 +40,18 @@ export const DOC_CATEGORY_LABELS: Record<DocTopic["category"], { en: string; zh:
};
export const DOC_TOPICS: DocTopic[] = [
{
id: "first-time",
slug: "first-time",
label: { en: "Use Codewhale for the first time", zh: "第一次使用 Codewhale" },
description: {
en: "Open a terminal, install, pick the folder Codewhale works in, connect a model and finish a first task.",
zh: "打开终端、安装、选择 Codewhale 工作的文件夹、连接模型并完成第一个任务。",
},
repoSource: ["docs/GUIDE.md", "docs/INSTALL.md", "docs/MODES.md", "docs/SANDBOX.md", "docs/KEYBINDINGS.md"],
hasPage: true,
category: "getting-started",
},
{
id: "install",
slug: "install",
+2 -1
View File
@@ -8,7 +8,7 @@ import { FEATURE_CATALOGUE, type Feature } from "./features";
import {
getDocsAuth, getDocsClaudeCode, getDocsConfiguration, getDocsFleet, getDocsHooks, getDocsMcp, getDocsModes, getDocsPet,
getDocsReview, getDocsRuntimeApi, getDocsSandbox, getDocsSkills, getDocsSubagents, getDocsTroubleshooting, getDocsTrust,
getDocsWeb, getDocsWork,
getDocsWeb, getDocsWork, getDocsFirstTime,
} from "./i18n/dictionaries";
import type { DocsBlock, DocsPageDict } from "./i18n/dictionaries/types";
import { DOC_TOPICS } from "./docs-map";
@@ -50,6 +50,7 @@ const PAGES: Record<string, (locale: string) => DocsPageDict> = {
fleet: getDocsFleet, hooks: getDocsHooks, mcp: getDocsMcp, modes: getDocsModes, pet: getDocsPet, review: getDocsReview,
"runtime-api": getDocsRuntimeApi, sandbox: getDocsSandbox, skills: getDocsSkills, subagents: getDocsSubagents,
troubleshooting: getDocsTroubleshooting, trust: getDocsTrust, web: getDocsWeb, work: getDocsWork,
"first-time": getDocsFirstTime,
};
describe("docs describe the published release first", () => {
+4
View File
@@ -14,6 +14,7 @@ import {
EN_DOCS_PET,
EN_DOCS_AUTH,
EN_DOCS_TRUST,
EN_DOCS_FIRST_TIME,
EN_DOCS_REVIEW,
EN_DOCS_VOCABULARY,
EN_COMPUTER_USE,
@@ -39,6 +40,7 @@ import {
getDocsPet,
getDocsAuth,
getDocsTrust,
getDocsFirstTime,
getComputerUse,
getChangelog,
getDocsShell,
@@ -285,6 +287,7 @@ describe("website dictionaries", () => {
["docs-pet", getDocsPet, EN_DOCS_PET],
["docs-auth", getDocsAuth, EN_DOCS_AUTH],
["docs-trust", getDocsTrust, EN_DOCS_TRUST],
["docs-first-time", getDocsFirstTime, EN_DOCS_FIRST_TIME],
["docs-review", getDocsReview, EN_DOCS_REVIEW],
["docs-vocabulary", getDocsVocabulary, EN_DOCS_VOCABULARY],
["changelog", getChangelog, EN_CHANGELOG],
@@ -347,6 +350,7 @@ describe("website dictionaries", () => {
["docs-subagents", getDocsSubagents],
["docs-troubleshooting", getDocsTroubleshooting],
["docs-trust", getDocsTrust],
["docs-first-time", getDocsFirstTime],
["docs-web", getDocsWeb],
["docs-work", getDocsWork],
["docs-pet", getDocsPet],
@@ -0,0 +1,196 @@
import type { DocsFirstTimeDict } from "../types";
export const docsFirstTime: DocsFirstTimeDict = {
metaTitle: "Use Codewhale for the first time · Codewhale Docs",
metaDescription:
"Open a terminal, install Codewhale, pick the folder it works in, connect a model and finish a first task, with no terminal experience needed.",
bodyClassName: "text-ink-soft leading-relaxed",
title: "Use Codewhale for the first time",
lede:
"This page takes you from opening a terminal for the first time to finishing a small task with Codewhale. It also explains which folder to start in, because that folder decides what Codewhale can change.",
sections: [
{
id: "what-you-need",
title: "Check what you need",
blocks: [
{
p: "You need a Mac, a Windows x64 PC or a Linux computer, and an internet connection for the install. To get answers you also need a model: an API key from a hosted provider such as DeepSeek, or a model running on your own computer with Ollama.",
},
{
p: "Codewhale is free and needs no account. A hosted provider bills you directly for what you use, and a model you run yourself needs no key.",
},
],
},
{
id: "open-terminal",
title: "Open a terminal",
blocks: [
{
p: "A terminal is a window where you type commands instead of clicking. A command runs when you press Return, which is Enter on Windows.",
},
{
rows: [
["macOS", "Press Cmd+Space, type Terminal and press Return."],
["Windows", "Open the Start menu, type PowerShell and press Enter."],
["Linux", "Open your applications menu and search for Terminal. On Ubuntu, Ctrl+Alt+T opens one."],
],
},
{
note: "Paste with Cmd+V on a Mac and Ctrl+V on Windows. Most Linux terminals paste with Ctrl+Shift+V.",
},
],
},
{
id: "install",
title: "Install Codewhale",
blocks: [
{
p: "On a Mac or Linux, paste this command and press Return. It downloads Codewhale, checks the download, and adds Codewhale to your PATH, the list of folders your terminal searches for commands.",
},
{ code: "curl -fsSL https://codewhale.net/install.sh | sh", lang: "macOS · Linux" },
{ p: "On Windows, paste this command and press Enter:" },
{ code: "winget install --id HunterBown.CodeWhale --exact --source winget", lang: "Windows" },
{
p: "When it finishes, open a new terminal window so it picks up the new PATH. Then check that it worked: a version number means Codewhale is installed.",
},
{ code: "codewhale --version", lang: "Terminal" },
{
p: "If the terminal says `command not found`, follow [the PATH steps on the install page](/install#install-path).",
},
],
},
{
id: "choose-folder",
title: "Choose the folder to work in",
blocks: [
{
p: "Codewhale works in the folder your terminal is in when you start it. That folder is its workspace: Codewhale reads and changes files there, and its file tools cannot reach other folders unless you allow it.",
},
{
p: "On a Mac, an operating-system sandbox also stops its commands from writing outside the workspace, apart from temporary files. Linux gets the same sandbox when bubblewrap is installed. Windows has no such sandbox yet, so Codewhale relies on asking you first.",
},
{
rows: [
["A folder for one project", "The best choice for most work. Codewhale sees only that project, and `/undo` can take back what it changed."],
["A new, empty folder", "A good start for something new, such as a website or a script. The next section shows how to make one."],
["A folder that holds several projects", "Lets one task read and change files across all of them. Pick the smallest folder that holds everything the task needs; in a very large folder Codewhale turns snapshots off and tells you."],
["Your home folder, Desktop, Documents or Downloads", "Codewhale runs there, but `/undo` and snapshots are off in these folders themselves because they are too broad to snapshot safely. A folder inside one of them works normally."],
],
},
{
note: "Snapshots are Codewhale's own copies of the files it changes. It keeps them in its settings folder, not in your project, and they work without git.",
},
{
p: "To let Codewhale reach one more folder, type `/trust add` and that folder's path in Codewhale. Full Access mode reaches every folder and runs commands without the sandbox, so use it only when you want Codewhale to work across your whole computer.",
},
{
p: "By default, Codewhale makes file edits in a git repository without asking and shows you what changed. In a folder without git, it asks before each edit.",
},
],
},
{
id: "open-in-folder",
title: "Open a terminal in that folder",
blocks: [
{
rows: [
["macOS Finder", "Control-click the folder and choose Services > New Terminal at Folder. If it is missing, turn it on in System Settings > Keyboard > Keyboard Shortcuts > Services > Files and Folders."],
["Windows File Explorer", "Right-click the folder and choose Open in Terminal. On Windows 10, hold Shift while you right-click and choose Open PowerShell window here."],
["Any terminal", "Type `cd` and a space, drag the folder from Finder or File Explorer into the window, and press Return."],
["Without moving", "Start Codewhale with `-C` and the folder's path, for example `codewhale -C ~/Projects/my-site` on a Mac."],
],
},
{ p: "These commands find your way around from inside a terminal. They work on a Mac, on Linux and in PowerShell." },
{
codeTerms: true,
rows: [
["pwd", "Shows the folder you are in."],
["ls", "Lists what is in it."],
["cd Documents", "Moves into the Documents folder inside the current one."],
["cd ..", "Moves up one folder."],
["mkdir my-site", "Makes a new folder called my-site."],
],
},
{
note: "Type the first letters of a folder name and press Tab, and the terminal finishes the name for you.",
},
],
},
{
id: "connect",
title: "Start Codewhale and connect a model",
blocks: [
{ code: "codewhale", lang: "Terminal" },
{
p: "Codewhale opens a message box right away. When you send your first message, it asks where your model runs: pick a hosted provider and paste its API key, or pick Ollama when it is running on your computer.",
},
{
p: "Keys you enter stay on your computer, in Codewhale's settings folder `~/.codewhale`. Change the provider later with `/provider`.",
},
{
p: "If Codewhale asks whether to work with the instructions and files in this folder, answer yes for a folder you made yourself.",
},
],
},
{
id: "first-task",
title: "Give it a first task",
blocks: [
{
p: "Type what you want in plain words and press Return. A safe first task changes nothing, for example: `Explain what is in this folder and what each file is for. Do not change anything.`",
},
{
p: "Then try one that makes a file, for example: `Make a file called packing-list.md with a packing list for a weekend trip.`",
},
{
p: "By default, Codewhale asks before it runs a command that is not read-only. Read what the approval says will run before you allow it.",
},
],
},
{
id: "stop-undo-quit",
title: "Stop, undo and quit",
blocks: [
{
codeTerms: true,
rows: [
["Esc", "Stops the task that is running. Ctrl+C does the same."],
["/undo", "Takes back your last request and the file changes it made."],
["/diff", "Shows what changed in this folder."],
["/receipts", "Lists what Codewhale did: files, commands and approvals."],
["/exit", "Quits Codewhale. Ctrl+D also quits when the message box is empty."],
["codewhale --continue", "Run it in the same folder to pick up your last conversation there."],
],
},
],
},
{
id: "help",
title: "Get help",
blocks: [
{
p: "Type `/help` in Codewhale to list its commands. [Ask in the Codewhale Discord](https://discord.gg/37gfS3ksug), or read [what to do when something goes wrong](/docs/troubleshooting).",
},
],
},
],
next: [
{
href: "/docs/guide",
label: "Start your first task",
note: "More first prompts and how to check the answer.",
},
{
href: "/docs/auth",
label: "Connect a provider",
note: "Save a key, check which key is used, or run a local model.",
},
{
href: "/docs/modes",
label: "Set modes and approvals",
note: "Decide which commands and edits stop for your approval.",
},
],
sourceNote:
"Source documents: docs/GUIDE.md, docs/INSTALL.md, docs/MODES.md, docs/SANDBOX.md, docs/KEYBINDINGS.md · Update docs-map.ts when changing.",
};
+12
View File
@@ -37,6 +37,7 @@ import type {
DocsSubagentsDict,
DocsTroubleshootingDict,
DocsTrustDict,
DocsFirstTimeDict,
DocsWebDict,
DocsWorkDict,
DocsPetDict,
@@ -100,6 +101,8 @@ import { docsAuth as enDocsAuth } from "./en/docs-auth";
import { docsAuth as zhDocsAuth } from "./zh/docs-auth";
import { docsTrust as enDocsTrust } from "./en/docs-trust";
import { docsTrust as zhDocsTrust } from "./zh/docs-trust";
import { docsFirstTime as enDocsFirstTime } from "./en/docs-first-time";
import { docsFirstTime as zhDocsFirstTime } from "./zh/docs-first-time";
import { docsReview as enDocsReview } from "./en/docs-review";
import { docsReview as zhDocsReview } from "./zh/docs-review";
import { docsVocabulary as enDocsVocabulary } from "./en/docs-vocabulary";
@@ -337,6 +340,10 @@ const DOCS_TRUST: Record<string, DocsTrustDict> = {
zh: zhDocsTrust,
};
const DOCS_FIRST_TIME: Record<string, DocsFirstTimeDict> = {
zh: zhDocsFirstTime,
};
const DOCS_REVIEW: Record<string, DocsReviewDict> = {
zh: zhDocsReview,
};
@@ -492,6 +499,10 @@ export function getDocsTrust(locale: string): DocsTrustDict {
return DOCS_TRUST[locale] ?? enDocsTrust;
}
export function getDocsFirstTime(locale: string): DocsFirstTimeDict {
return DOCS_FIRST_TIME[locale] ?? enDocsFirstTime;
}
export function getDocsReview(locale: string): DocsReviewDict {
return DOCS_REVIEW[locale] ?? enDocsReview;
}
@@ -574,6 +585,7 @@ export const EN_DOCS_WORK = enDocsWork;
export const EN_DOCS_PET = enDocsPet;
export const EN_DOCS_AUTH = enDocsAuth;
export const EN_DOCS_TRUST = enDocsTrust;
export const EN_DOCS_FIRST_TIME = enDocsFirstTime;
export const EN_DOCS_REVIEW = enDocsReview;
export const EN_DOCS_VOCABULARY = enDocsVocabulary;
export const EN_COMPUTER_USE = enComputerUse;
+2
View File
@@ -442,6 +442,8 @@ export type DocsAuthDict = DocsPageDict;
export type DocsTrustDict = DocsPageDict;
export type DocsFirstTimeDict = DocsPageDict;
/** `app/[locale]/changelog/page.tsx` — version-aware release record. */
export interface ChangelogDict {
metaTitle: string;
@@ -0,0 +1,196 @@
import type { DocsFirstTimeDict } from "../types";
export const docsFirstTime: DocsFirstTimeDict = {
metaTitle: "第一次使用 Codewhale · Codewhale 文档",
metaDescription:
"打开终端、安装 Codewhale、选择它工作的文件夹、连接模型并完成第一个任务,不需要任何终端经验。",
bodyClassName: "text-ink-soft leading-[1.9] tracking-wide",
title: "第一次使用 Codewhale",
lede:
"这一页带你从第一次打开终端开始,一直到用 Codewhale 完成一个小任务。它还会说明应该从哪个文件夹启动,因为这个文件夹决定了 Codewhale 能修改什么。",
sections: [
{
id: "what-you-need",
title: "确认你需要准备什么",
blocks: [
{
p: "你需要一台 Mac、一台 Windows x64 电脑或一台 Linux 电脑,安装时还需要联网。要得到回答,你还需要一个模型:来自 DeepSeek 等托管提供商的 API 密钥,或者用 Ollama 在你自己电脑上运行的模型。",
},
{
p: "Codewhale 免费,也不需要账号。托管提供商会按你的用量直接向你收费;你自己运行的模型不需要密钥。",
},
],
},
{
id: "open-terminal",
title: "打开终端",
blocks: [
{
p: "终端是一个用输入命令代替点击的窗口。按下回车键后,命令就会运行。",
},
{
rows: [
["macOS", "按 Cmd+空格键,输入 Terminal,然后按回车键。"],
["Windows", "打开“开始”菜单,输入 PowerShell,然后按回车键。"],
["Linux", "打开应用程序菜单并搜索“终端”。在 Ubuntu 上,按 Ctrl+Alt+T 也能打开。"],
],
},
{
note: "在 Mac 上用 Cmd+V 粘贴,在 Windows 上用 Ctrl+V 粘贴。大多数 Linux 终端用 Ctrl+Shift+V 粘贴。",
},
],
},
{
id: "install",
title: "安装 Codewhale",
blocks: [
{
p: "在 Mac 或 Linux 上,粘贴下面这条命令并按回车键。它会下载 Codewhale、校验下载内容,并把 Codewhale 加入 PATH,也就是终端查找命令时会搜索的文件夹列表。",
},
{ code: "curl -fsSL https://codewhale.net/install.sh | sh", lang: "macOS · Linux" },
{ p: "在 Windows 上,粘贴下面这条命令并按回车键:" },
{ code: "winget install --id HunterBown.CodeWhale --exact --source winget", lang: "Windows" },
{
p: "安装完成后,打开一个新的终端窗口,让它读取新的 PATH。然后确认安装成功:显示版本号就说明 Codewhale 已经安装好了。",
},
{ code: "codewhale --version", lang: "终端" },
{
p: "如果终端提示 `command not found`,请按照[安装页面上的 PATH 步骤](/install#install-path)操作。",
},
],
},
{
id: "choose-folder",
title: "选择工作的文件夹",
blocks: [
{
p: "Codewhale 在你启动它时终端所在的文件夹里工作。这个文件夹就是它的工作区:Codewhale 在这里读取和修改文件,除非你允许,它的文件工具无法访问其他文件夹。",
},
{
p: "在 Mac 上,操作系统沙箱还会阻止它的命令写入工作区以外的位置(临时文件除外)。在 Linux 上,安装 bubblewrap 后也有同样的沙箱。Windows 目前还没有这种沙箱,因此 Codewhale 依靠先征求你的同意。",
},
{
rows: [
["只放一个项目的文件夹", "大多数情况下的最佳选择。Codewhale 只看到这个项目,`/undo` 可以撤回它做的修改。"],
["一个新的空文件夹", "适合开始做新东西,比如一个网站或一个脚本。下一节会说明如何创建。"],
["包含多个项目的文件夹", "让一个任务可以读取和修改所有这些项目里的文件。请选择能装下任务所需全部内容的最小文件夹;文件夹非常大时,Codewhale 会关闭快照并告诉你。"],
["你的主文件夹、桌面、文稿或下载", "Codewhale 可以在这里运行,但在这些文件夹本身中 `/undo` 和快照是关闭的,因为它们范围太大,无法安全地做快照。它们里面的子文件夹可以正常使用。"],
],
},
{
note: "快照是 Codewhale 为它修改的文件保存的副本。它们保存在 Codewhale 的设置文件夹里,而不是你的项目里,并且不需要 git。",
},
{
p: "要让 Codewhale 多访问一个文件夹,请在 Codewhale 中输入 `/trust add` 和该文件夹的路径。Full Access 模式可以访问所有文件夹,并且在没有沙箱的情况下运行命令,所以只有在你希望 Codewhale 在整台电脑范围内工作时才使用它。",
},
{
p: "默认情况下,在 git 仓库中 Codewhale 会直接修改文件,并向你展示改了什么。在没有 git 的文件夹中,它会在每次修改前询问你。",
},
],
},
{
id: "open-in-folder",
title: "在那个文件夹中打开终端",
blocks: [
{
rows: [
["macOS 访达", "按住 Control 点按该文件夹,选择“服务”>“新建位于文件夹位置的终端窗口”。如果没有这一项,请在“系统设置”>“键盘”>“键盘快捷键”>“服务”>“文件和文件夹”中打开它。"],
["Windows 文件资源管理器", "右键点击该文件夹,选择“在终端中打开”。在 Windows 10 上,按住 Shift 再右键点击,选择“在此处打开 PowerShell 窗口”。"],
["任意终端", "输入 `cd` 和一个空格,把文件夹从访达或文件资源管理器拖进窗口,然后按回车键。"],
["不切换文件夹", "启动 Codewhale 时加上 `-C` 和文件夹路径,例如在 Mac 上输入 `codewhale -C ~/Projects/my-site`。"],
],
},
{ p: "下面这些命令可以在终端里切换和查看文件夹。它们在 Mac、Linux 和 PowerShell 中都能用。" },
{
codeTerms: true,
rows: [
["pwd", "显示你当前所在的文件夹。"],
["ls", "列出其中的内容。"],
["cd Documents", "进入当前文件夹里的 Documents 文件夹。"],
["cd ..", "回到上一级文件夹。"],
["mkdir my-site", "新建一个名为 my-site 的文件夹。"],
],
},
{
note: "输入文件夹名的前几个字母后按 Tab 键,终端会帮你补全名字。",
},
],
},
{
id: "connect",
title: "启动 Codewhale 并连接模型",
blocks: [
{ code: "codewhale", lang: "终端" },
{
p: "Codewhale 会立即打开一个输入框。你发送第一条消息时,它会询问模型在哪里运行:选择一个托管提供商并粘贴它的 API 密钥,或者在你的电脑上运行着 Ollama 时选择 Ollama。",
},
{
p: "你输入的密钥保存在你自己的电脑上,位于 Codewhale 的设置文件夹 `~/.codewhale` 中。之后可以用 `/provider` 更换提供商。",
},
{
p: "如果 Codewhale 询问是否使用这个文件夹中的说明和文件,对于你自己创建的文件夹,回答“是”即可。",
},
],
},
{
id: "first-task",
title: "交给它第一个任务",
blocks: [
{
p: "用平常的话写下你想做的事,然后按回车键。安全的第一个任务不修改任何东西,例如:`说明这个文件夹里有什么,以及每个文件的用途。不要修改任何内容。`",
},
{
p: "然后试一个会创建文件的任务,例如:`创建一个名为 packing-list.md 的文件,写一份周末出游的行李清单。`",
},
{
p: "默认情况下,Codewhale 在运行非只读命令前会先询问你。在允许之前,请先看清审批里写明要运行什么。",
},
],
},
{
id: "stop-undo-quit",
title: "停止、撤回和退出",
blocks: [
{
codeTerms: true,
rows: [
["Esc", "停止正在运行的任务。Ctrl+C 的作用相同。"],
["/undo", "撤回你的上一个请求及其造成的文件修改。"],
["/diff", "显示这个文件夹中改了什么。"],
["/receipts", "列出 Codewhale 做过的事:文件、命令和审批。"],
["/exit", "退出 Codewhale。输入框为空时,按 Ctrl+D 也会退出。"],
["codewhale --continue", "在同一个文件夹中运行它,可以继续你在那里的上一次对话。"],
],
},
],
},
{
id: "help",
title: "获取帮助",
blocks: [
{
p: "在 Codewhale 中输入 `/help` 可以列出它的命令。你也可以[在 Codewhale Discord 中提问](https://discord.gg/37gfS3ksug),或阅读[出现问题时怎么办](/docs/troubleshooting)。",
},
],
},
],
next: [
{
href: "/docs/guide",
label: "开始第一个任务",
note: "更多第一个提示词的示例,以及如何检查回答。",
},
{
href: "/docs/auth",
label: "连接模型提供商",
note: "保存密钥、查看当前使用的密钥,或运行本地模型。",
},
{
href: "/docs/modes",
label: "设置模式与审批",
note: "决定哪些命令和修改需要你批准。",
},
],
sourceNote:
"来源文档:docs/GUIDE.md、docs/INSTALL.md、docs/MODES.md、docs/SANDBOX.md、docs/KEYBINDINGS.md · 修改时请同步更新 docs-map.ts。",
};
+1
View File
@@ -16,6 +16,7 @@ export const DOCS_TOPICS = [
"computers",
"configuration",
"constitution",
"first-time",
"fleet",
"guide",
"hooks",
+17
View File
@@ -96,6 +96,23 @@ function installLines(facts: RepoFacts): string[] {
"",
...link(I.scriptLink, `${SITE_URL}/install.sh`),
...(version ? p(I.releaseLine) : []),
...h(I.newTitle),
...p(I.newLead),
...h(I.newMacTitle, 3),
`1. ${t(I.newMac1)}`,
`2. ${t(I.newMac2)}`,
"",
...code(INSTALL_COMMANDS.shell),
`3. ${t(I.newMac3)}`,
"",
...h(I.newWinTitle, 3),
`1. ${t(I.newWin1)}`,
`2. ${t(I.newWin2)}`,
"",
...code(INSTALL_COMMANDS.windows),
`3. ${t(I.newWin3)}`,
"",
...link(I.newNext, `${SITE_URL}/en/docs/first-time`),
...h(I.pathTitle),
...p(I.pathPlaces),
...bullets([I.pathZsh, I.pathBashLinux, I.pathBashMac, I.pathOther, I.pathFish]),
+1
View File
@@ -35,6 +35,7 @@ export const RELEASE_CONTRIBUTORS: string[] = [
"@luochen211",
"@gannp",
"@Felixkw12",
"@MannXo",
];
/**
+1
View File
@@ -49,6 +49,7 @@ const STEMS = {
"docs-web": { exportName: "docsWeb", typeName: "DocsWebDict" },
"docs-auth": { exportName: "docsAuth", typeName: "DocsAuthDict" },
"docs-trust": { exportName: "docsTrust", typeName: "DocsTrustDict" },
"docs-first-time": { exportName: "docsFirstTime", typeName: "DocsFirstTimeDict" },
states: { exportName: "states", typeName: "StatesDict" },
changelog: { exportName: "changelog", typeName: "ChangelogDict" },
"computer-use": { exportName: "computerUse", typeName: "ComputerUseDict" },