Compare commits

..
Author SHA1 Message Date
CodeWhale BotandClaude Sonnet 5.5 86db50729e feat(plugins): apply installs, enables and MCP adds made by other processes when idle
The plugin catalog stamp now includes a SHA-256 of the plugin state file, so an
install, enable or disable from another terminal is a catalog change by content
rather than by mtime. The registry refreshes its own stamp after its own state
writes, so a session's /plugin enable never looks like an outside change.

The TUI idle poll also digests the configured MCP config file. When a turn is
not running, an outside plugin or MCP change is applied through the existing
PluginRegistryChanged handler (transcript kept via SyncSession) and the
existing MCP reload; the transcript records the applied change and any added or
removed skills, and the engine's existing new-turn context_update appends the
skill delta and declares the tool-surface change, so the pinned prefix is not
spliced. While a turn runs, the toast stays and the change applies at the next
idle poll. A change to an active provider-declaring plugin says provider
changes apply in a new session.

The MCP digest is tracked in the TUI (not inside PluginCatalogStamp) because
the MCP config path is a per-session setting the plugins crate does not know.

Checks: see the review packet; hosted ci.yml on this SHA.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 18:25:39 -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
48 changed files with 647 additions and 1952 deletions

No files matched your search

+1 -5
View File
@@ -12,11 +12,7 @@ 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).
- **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.
- **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.
+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>,
}
+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()),
+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 {
+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")]
+1 -1
View File
@@ -182,7 +182,7 @@ impl PluginDiscoveryContext {
super::discovery::default_workspace_plugins_dir(workspace),
];
roots.extend(self.builtin_plugin_dirs.iter().cloned());
super::discovery::PluginCatalogStamp::capture(roots)
super::discovery::PluginCatalogStamp::capture(roots, &self.state_path)
}
}
+34 -7
View File
@@ -27,20 +27,40 @@ pub struct DiscoveryConfig {
pub state_path: PathBuf,
}
/// Cheap on-disk catalog fingerprint: plugin-bundle directories only.
/// Cheap on-disk catalog fingerprint: plugin-bundle directories plus the
/// content of the plugin state file.
///
/// Used to nudge `/plugin reload` when a bundle appears, disappears, or is
/// rewritten without consulting process environment or auto-applying trust.
/// `state.json` writes are ignored so enable/trust does not look like a
/// catalog change.
/// Used to apply or nudge a reload when a bundle appears, disappears, or is
/// rewritten, or when another process installs, enables or disables a plugin.
/// The state file is compared by content digest, never by mtime, so a rewrite
/// with identical bytes is not a change and a same-second rewrite is.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PluginCatalogStamp {
entries: BTreeMap<PathBuf, Option<SystemTime>>,
state: Option<[u8; 32]>,
}
const MAX_STAMPED_FILE_BYTES: u64 = 8 * 1024 * 1024;
/// SHA-256 of a small file's bytes, or `None` when it is absent, unreadable or
/// larger than the stamp bound.
#[must_use]
pub fn file_content_digest(path: &Path) -> Option<[u8; 32]> {
use std::io::Read;
let file = fs::File::open(path).ok()?;
let mut bytes = Vec::new();
file.take(MAX_STAMPED_FILE_BYTES + 1)
.read_to_end(&mut bytes)
.ok()?;
if bytes.len() as u64 > MAX_STAMPED_FILE_BYTES {
return None;
}
Some(Sha256::digest(&bytes).into())
}
impl PluginCatalogStamp {
#[must_use]
pub fn capture(roots: impl IntoIterator<Item = PathBuf>) -> Self {
pub fn capture(roots: impl IntoIterator<Item = PathBuf>, state_path: &Path) -> Self {
let mut entries = BTreeMap::new();
for root in roots {
let Ok(read) = fs::read_dir(&root) else {
@@ -57,7 +77,14 @@ impl PluginCatalogStamp {
entries.insert(path, metadata.modified().ok());
}
}
Self { entries }
Self {
entries,
state: file_content_digest(state_path),
}
}
pub fn refresh_state(&mut self, state_path: &Path) {
self.state = file_content_digest(state_path);
}
}
+31 -1
View File
@@ -27,11 +27,41 @@ pub mod test_fixture;
#[cfg(test)]
mod tests;
use std::collections::BTreeMap;
pub use context::{HostEnvironment, PluginDiscoveryContext};
pub use discovery::PluginCatalogStamp;
pub use registry::PluginRegistry;
pub const PLUGIN_RELOAD_NUDGE: &str = "Plugins changed on disk. Run /plugin reload to apply.";
pub const PLUGIN_RELOAD_NUDGE: &str =
"Plugins changed on disk. They apply when the session is idle, or run /plugin reload.";
pub const PLUGIN_PROVIDER_NEW_SESSION_NOTICE: &str =
"Plugin provider changes apply in a new session.";
fn active_provider_declarations(
registry: &PluginRegistry,
) -> BTreeMap<String, (String, Vec<String>)> {
registry
.active_plugins()
.into_iter()
.filter(|plugin| !plugin.manifest.providers.is_empty())
.map(|plugin| {
(
plugin.id.as_str().to_string(),
(
plugin.content_hash.clone(),
plugin.manifest.providers.keys().cloned().collect(),
),
)
})
.collect()
}
#[must_use]
pub fn provider_declarations_changed(before: &PluginRegistry, after: &PluginRegistry) -> bool {
active_provider_declarations(before) != active_provider_declarations(after)
}
/// Returns the reload nudge once per distinct on-disk catalog stamp.
#[must_use]
+1
View File
@@ -1326,6 +1326,7 @@ impl PluginRegistry {
let mut next = load_state_unlocked(path)?;
mutate(&mut next)?;
save_state(path, &next)?;
self.catalog_stamp.refresh_state(path);
self.state = next;
// Runtime selection rechecks the persisted review through its own
// read lock. Publish the completed state transaction before deriving
+20
View File
@@ -35,6 +35,26 @@ fn write_named_plugin(config: &DiscoveryConfig, name: &str, extra: &str) -> Path
plugin
}
#[test]
fn catalog_stamp_changes_when_only_state_bytes_change() {
let tmp = tempfile::tempdir().unwrap();
let config = config(tmp.path());
write_plugin(&config, "");
let registry = discover_with_config(&config);
assert!(!registry.on_disk_catalog_changed());
std::fs::create_dir_all(config.state_path.parent().unwrap()).unwrap();
std::fs::write(&config.state_path, "{\"version\":1}").unwrap();
assert!(registry.on_disk_catalog_changed());
let reloaded = registry.rediscover_for_workspace(&config.workspace);
assert!(!reloaded.on_disk_catalog_changed());
std::fs::write(&config.state_path, "{\"version\":1}").unwrap();
assert!(!reloaded.on_disk_catalog_changed());
std::fs::write(&config.state_path, "{\"version\":2}").unwrap();
assert!(reloaded.on_disk_catalog_changed());
}
#[test]
fn on_disk_catalog_change_nudges_reload_once_until_rediscover() {
let tmp = tempfile::tempdir().unwrap();
+1 -5
View File
@@ -12,11 +12,7 @@ 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).
- **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.
- **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.
-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;
+1
View File
@@ -1736,6 +1736,7 @@ pub struct App {
pub plugin_reload_nudge_stamp: Option<codewhale_plugins::PluginCatalogStamp>,
/// Last idle catalog fingerprint poll, so disk changes can surface between turns.
pub last_plugin_catalog_poll: Option<Instant>,
pub mcp_config_watch: Option<crate::tui::plugin_suggestions::McpConfigWatch>,
/// Live composer plugin CTA (debounce + one match, never auto-install).
pub plugin_cta: crate::tui::plugin_suggestions::PluginCtaState,
pub model: String,
+1
View File
@@ -858,6 +858,7 @@ impl App {
context_pressure_warning_dismissed: None,
plugin_reload_nudge_stamp: None,
last_plugin_catalog_poll: None,
mcp_config_watch: None,
plugin_cta: crate::tui::plugin_suggestions::PluginCtaState::from_settings(&settings),
model,
provider_models,
+107 -11
View File
@@ -31,6 +31,22 @@ use codewhale_plugins::recommend::{
const CATALOG_POLL_INTERVAL: Duration = Duration::from_secs(2);
pub const MCP_CONFIG_NUDGE: &str =
"MCP servers changed on disk. They apply when the session is idle, or run /mcp reload.";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ExternalCatalogChange {
pub plugins: bool,
pub mcp: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct McpConfigWatch {
pub digest: Option<[u8; 32]>,
pub generation: u64,
pub notified: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PluginCtaPhase {
Hidden,
@@ -124,24 +140,63 @@ impl App {
true
}
/// Cheap idle poll so on-disk plugin changes can surface between turns,
/// not only on send. Fingerprints directories; never auto-reloads.
pub fn maybe_poll_plugin_catalog_idle(&mut self) {
pub fn poll_external_catalog_changes(
&mut self,
engine_busy: bool,
) -> Option<ExternalCatalogChange> {
let now = Instant::now();
if self
.last_plugin_catalog_poll
.is_some_and(|seen| now.duration_since(seen) < CATALOG_POLL_INTERVAL)
{
return;
return None;
}
self.last_plugin_catalog_poll = Some(now);
if let Some(message) = codewhale_plugins::plugin_reload_nudge(
self.plugin_registry.as_ref(),
&mut self.plugin_reload_nudge_stamp,
) {
self.push_status_toast(message, StatusToastLevel::Warning, Some(8_000));
self.needs_redraw = true;
let digest = codewhale_plugins::discovery::file_content_digest(&self.mcp_config_path);
let generation = self.mcp_snapshot_generation;
let watch = self.mcp_config_watch.get_or_insert(McpConfigWatch {
digest,
generation,
notified: false,
});
if watch.generation != generation {
*watch = McpConfigWatch {
digest,
generation,
notified: false,
};
}
let mcp = watch.digest != digest;
let mcp_unannounced = mcp && !std::mem::replace(&mut watch.notified, true);
let plugins = self.plugin_registry.on_disk_catalog_changed();
if !plugins && !mcp {
return None;
}
if engine_busy || self.pending_fresh_session.is_some() {
if plugins
&& let Some(message) = codewhale_plugins::plugin_reload_nudge(
self.plugin_registry.as_ref(),
&mut self.plugin_reload_nudge_stamp,
)
{
self.push_status_toast(message, StatusToastLevel::Warning, Some(8_000));
self.needs_redraw = true;
}
if mcp_unannounced {
self.push_status_toast(MCP_CONFIG_NUDGE, StatusToastLevel::Warning, Some(8_000));
self.needs_redraw = true;
}
return None;
}
Some(ExternalCatalogChange { plugins, mcp })
}
pub fn mark_external_catalog_applied(&mut self) {
self.mcp_config_watch = Some(McpConfigWatch {
digest: codewhale_plugins::discovery::file_content_digest(&self.mcp_config_path),
generation: self.mcp_snapshot_generation,
notified: false,
});
}
#[must_use]
@@ -417,12 +472,53 @@ mod tests {
);
}
#[test]
fn external_state_and_mcp_writes_apply_when_idle_and_toast_while_busy() {
let _lock = crate::test_support::lock_test_env();
let (mut app, _root, _home) = app_with_supabase_plugin();
assert_eq!(app.poll_external_catalog_changes(false), None);
let state_path = app.plugin_registry.state_path().unwrap().to_path_buf();
fs::create_dir_all(state_path.parent().unwrap()).unwrap();
fs::write(&state_path, "{\"version\":1,\"plugins\":{}}").unwrap();
fs::write(&app.mcp_config_path, "{\"servers\":{}}").unwrap();
app.last_plugin_catalog_poll = None;
assert_eq!(app.poll_external_catalog_changes(true), None);
let toasts = app
.status_toasts
.iter()
.map(|toast| toast.text.to_string())
.collect::<Vec<_>>();
assert!(toasts.iter().any(|text| text.contains("Plugins changed")));
assert!(
toasts
.iter()
.any(|text| text.contains("MCP servers changed"))
);
app.last_plugin_catalog_poll = None;
assert_eq!(
app.poll_external_catalog_changes(false),
Some(ExternalCatalogChange {
plugins: true,
mcp: true
})
);
app.mark_external_catalog_applied();
app.plugin_registry = app
.plugin_registry
.rediscover_for_workspace(&app.workspace.clone());
app.last_plugin_catalog_poll = None;
assert_eq!(app.poll_external_catalog_changes(false), None);
}
#[test]
fn typing_a_matching_draft_never_shows_a_row() {
let _lock = crate::test_support::lock_test_env();
let (mut app, _root, _home) = app_with_supabase_plugin();
app.input = "add supabase auth to login".to_string();
app.maybe_poll_plugin_catalog_idle();
let _ = app.poll_external_catalog_changes(false);
assert!(!app.plugin_cta.phase.is_visible());
assert_eq!(app.plugin_cta_row_height(), 0);
}
+2
View File
@@ -981,7 +981,9 @@ pub(crate) use compaction_flow::*;
pub(crate) use provider_setup::*;
mod dispatch;
mod dispatch_prepare;
mod external_catalog;
pub(crate) use dispatch_prepare::*;
pub(crate) use external_catalog::*;
pub(crate) mod fatal_signal_guard;
// #6169: runtime half of the foreground-ownership contract — restore on stop,
// rebuild on continue. Sits next to the fatal guard because both write the same
-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()),
+11 -1
View File
@@ -2640,7 +2640,17 @@ pub(crate) async fn run_event_loop(
// #1830/#2317: service any already-arrived terminal keys before a
// potentially long engine batch so composer/modal input stays live.
collect_pending_terminal_events(&terminal_input, &mut pending_terminal_events)?;
app.maybe_poll_plugin_catalog_idle();
if let Some(change) = app.poll_external_catalog_changes(mcp_engine_busy(app)) {
apply_external_catalog_change(
terminal,
app,
&mut engine_handle,
&task_manager,
config,
change,
)
.await?;
}
if drain_remote_control_events(app, config, &engine_handle).await? {
app.needs_redraw = true;
+73
View File
@@ -0,0 +1,73 @@
use super::*;
use crate::tui::plugin_suggestions::ExternalCatalogChange;
fn skill_delta(before: &[(String, String)], after: &[(String, String)]) -> String {
let names = |skills: &[(String, String)]| {
skills
.iter()
.map(|(name, _)| name.clone())
.collect::<std::collections::BTreeSet<_>>()
};
let (before, after) = (names(before), names(after));
let added = after.difference(&before).cloned().collect::<Vec<_>>();
let removed = before.difference(&after).cloned().collect::<Vec<_>>();
let mut parts = Vec::new();
if !added.is_empty() {
parts.push(format!("skills added: {}", added.join(", ")));
}
if !removed.is_empty() {
parts.push(format!("skills removed: {}", removed.join(", ")));
}
parts.join("; ")
}
pub(crate) async fn apply_external_catalog_change(
terminal: &mut AppTerminal,
app: &mut App,
engine_handle: &mut EngineHandle,
task_manager: &SharedTaskManager,
config: &mut Config,
change: ExternalCatalogChange,
) -> Result<()> {
app.mark_external_catalog_applied();
if change.plugins {
let before = app.plugin_registry.clone();
let skills_before = app.cached_skills.clone();
let workspace = app.workspace.clone();
app.plugin_registry = before.rediscover_for_workspace(&workspace);
app.refresh_skill_cache();
let mut message = String::from("Plugins changed outside this session and were applied");
let delta = skill_delta(&skills_before, &app.cached_skills);
if !delta.is_empty() {
message.push_str(&format!(" ({delta})"));
}
message.push('.');
if codewhale_plugins::provider_declarations_changed(
before.as_ref(),
app.plugin_registry.as_ref(),
) {
message.push(' ');
message.push_str(codewhale_plugins::PLUGIN_PROVIDER_NEW_SESSION_NOTICE);
}
let result = commands::CommandResult::with_message_and_action(
message,
AppAction::PluginRegistryChanged,
);
apply_command_result(terminal, app, engine_handle, task_manager, config, result).await?;
}
if change.mcp {
let extensions_was_open = app.view_stack.extensions_is_top();
handle_mcp_ui_action(
app,
engine_handle,
config,
crate::tui::app::McpUiAction::Reload,
)
.await;
if !extensions_was_open && app.view_stack.extensions_is_top() {
app.view_stack.pop();
}
}
app.needs_redraw = true;
Ok(())
}
+1 -1
View File
@@ -740,7 +740,7 @@ fn mcp_scoped_config_path(
/// Whether a turn (or its compaction work) owns the engine. The engine
/// services ops only between turns, so an op sent now waits for that turn.
fn mcp_engine_busy(app: &App) -> bool {
pub(super) fn mcp_engine_busy(app: &App) -> bool {
app.is_loading
|| app.dispatch_in_flight
|| matches!(app.runtime_turn_status.as_deref(), Some("in_progress"))
+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.
-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 线程为该工作区运行的钩子集合
+8 -8
View File
@@ -323,8 +323,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 +334,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": "c4f348c27735a7df47a79faf11a6d5e3284a1e67a76c9372e76a8809236af45a",
"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
}
]
},
@@ -403,13 +403,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": "c4f348c27735a7df47a79faf11a6d5e3284a1e67a76c9372e76a8809236af45a",
"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
}
]
},