Compare commits

..
1 Commits
Author SHA1 Message Date
CodeWhale BotandClaude Sonnet 5.5 6f979cac18 Bring pet/tui/Cargo.lock in line with the workspace protocol crate versions
The lock still named codewhale-app-server-protocol 0.10.2 without its chrono
dependency, so cargo fetch --locked in the Pet conformance workflow refused.
Regenerated with cargo update --workspace; no other version changed.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 04:19:35 -08:00
125 changed files with 748 additions and 7449 deletions

No files matched your search

+8 -51
View File
@@ -4,12 +4,11 @@
# alias = Display Name <[email protected]>
#
# The right-hand side must use GitHub's numeric noreply address so a harvested
# commit authored with it, or a Co-authored-by trailer in the commit's final
# trailer block, links to the contributor's account. GitHub resolves the
# address by its numeric id, so the id must be the contributor's own
# (`gh api users/<login> --jq .id`). The left-hand side may be a GitHub login,
# old-style noreply address, raw email from a contributor commit, or local
# machine email seen in older harvested history.
# commit authored with it links to the contributor's account. Only commit
# authorship puts someone in the repository's contributor list; a co-author
# trailer shows on their profile but not there. The left-hand side may be a
# GitHub login, old-style noreply address, raw email from a contributor commit,
# or local machine email seen in older harvested history.
hmbown = Hmbown <[email protected]>
[email protected] = Hmbown <[email protected]>
@@ -51,7 +50,6 @@ Oliver-ZPLiu = Oliver-ZPLiu <[email protected]>
idling11 = idling11 <[email protected]>
Hanmiao Li = idling11 <[email protected]>
[email protected] = idling11 <[email protected]>
894876246 = idling11 <[email protected]>
angziii = angziii <[email protected]>
aboimpinto = aboimpinto <[email protected]>
Paulo Aboim Pinto = aboimpinto <[email protected]>
@@ -81,24 +79,19 @@ Vince = Liu-Vince <[email protected]>
[email protected] = Liu-Vince <[email protected]>
LI-Jialu = LI-Jialu <[email protected]>
JiarenWang = JiarenWang <[email protected]>
wdw8276 = kitty <3972439+mars-base@users.noreply.github.com>
mars-base = kitty <[email protected]>
[email protected] = kitty <[email protected]>
wdw8276 = wdw8276 <3972439+wdw8276@users.noreply.github.com>
pengyou200902 = pengyou200902 <[email protected]>
linzhiqin2003 = linzhiqin2003 <[email protected]>
LING71671 = LING71671 <[email protected]>
[email protected] = LING71671 <[email protected]>
JasonOA888 = JasonOA888 <[email protected]>
Inference1 = Inference1 <[email protected]>
hongqitai = hongqitai <[email protected]>
gordonlu = gordonlu <[email protected]>
angus-guo = gus <217034332+im[email protected]>
imgusguo = gus <217034332+im[email protected]>
[email protected] = gus <[email protected]>
angus-guo = gus <217034332+angus-[email protected]>
gus.guo@tec-do.com = gus <217034332+angus-[email protected]>
gaord = gaord <[email protected]>
Ben Gao = gaord <[email protected]>
[email protected] = gaord <[email protected]>
bengao168 = gaord <[email protected]>
MXAntian = MXAntian <[email protected]>
[email protected] = MXAntian <[email protected]>
DarrellThomas = Darrell Thomas <[email protected]>
@@ -254,7 +247,6 @@ [email protected] = zhuowp <[email protected]>
[email protected] = 秋月凉梦 <[email protected]>
asto18089 = asto18089 <[email protected]>
[email protected] = asto18089 <[email protected]>
xiechimon = Xmon Dai <[email protected]>
yrk111222 = Rongkun Yan <[email protected]>
VincentCorleone = Vincent Corleone <[email protected]>
@@ -286,38 +278,3 @@ Felixkw12 = Felix Wunderlich <[email protected]>
[email protected] = Felix Wunderlich <[email protected]>
Felix Wunderlich = Felix Wunderlich <[email protected]>
[email protected] = luochen211 <[email protected]>
AiurArtanis = AiurArtanis <[email protected]>
[email protected] = AiurArtanis <[email protected]>
Apeiron0w0 = Apeiron <[email protected]>
[email protected] = Apeiron <[email protected]>
[email protected] = ChaceLyee2101 <[email protected]>
AmosLeeeeeeee = ChaceLyee2101 <[email protected]>
ddlw = ddlw <[email protected]>
mo-vic = movic <[email protected]>
[email protected] = movic <[email protected]>
[email protected] = merchloubna70-dot <[email protected]>
[email protected] = merchloubna70-dot <[email protected]>
xulongzhe = luola <[email protected]>
[email protected] = luola <[email protected]>
ZhulongNT = SONG ZHENRUI <[email protected]>
[email protected] = SONG ZHENRUI <[email protected]>
xiluoduyu = xiluoduyu <[email protected]>
[email protected] = xiluoduyu <[email protected]>
wlon = Stephen Xu <[email protected]>
[email protected] = Stephen Xu <[email protected]>
krisclarkdev = Kristopher Clark <[email protected]>
THatch26 = THatch26 <[email protected]>
jeoor = Kayro <[email protected]>
harvey2011888 = harvey2011888 <[email protected]>
duanchao-lab = DC <[email protected]>
dumbjack = dumbjack <[email protected]>
[email protected] = dumbjack <[email protected]>
AquaZhang = IceTear <[email protected]>
disappear00 = idontknow <[email protected]>
namezzb = sailorZ <[email protected]>
alphapku-ux = Alpha Wu <[email protected]>
BX166 = BX166 <[email protected]>
c020627 = c020627 <[email protected]>
[email protected] = Parman Mohammadalizadeh <[email protected]>
Parman Mohammadalizadeh = Parman Mohammadalizadeh <[email protected]>
-14
View File
@@ -14,17 +14,3 @@ Hunter Bown <[email protected]> Cursor Agent <[email protected]>
Hunter Bown <[email protected]> cursoragent <[email protected]>
Hunter Bown <[email protected]> gemini-code-assist[bot] <gemini-code-assist[bot]@users.noreply.github.com>
Hunter Bown <[email protected]> gemini-code-assist[bot] <956858+gemini-code-assist[bot]@users.noreply.github.com>
ddlw <[email protected]> <[email protected]>
dmitri-0 <[email protected]> <[email protected]>
Paulo Aboim Pinto <[email protected]> <[email protected]>
Paulo Aboim Pinto <[email protected]> <[email protected]>
Paulo Aboim Pinto <[email protected]> <[email protected]>
AiurArtanis <[email protected]> <[email protected]>
Apeiron <[email protected]> <[email protected]>
ChaceLyee2101 <[email protected]> <[email protected]>
movic <[email protected]> <[email protected]>
merchloubna70-dot <[email protected]> <[email protected]>
merchloubna70-dot <[email protected]> <[email protected]>
luola <[email protected]> <[email protected]>
SONG ZHENRUI <[email protected]> <[email protected]>
xiluoduyu <[email protected]> <[email protected]>
+3 -10
View File
@@ -259,16 +259,9 @@ prefer merging the contributor's PR itself). When a contributor's work
lands as our commit, the contributor is that commit's **author** (`git commit
--author` with their GitHub-linked address), it carries `Harvested from PR #N by
@handle`, and maintainers or agents go in `Co-authored-by`. GitHub's contributor
count takes commit authors and the `Co-authored-by` trailers in a commit's final
trailer block, and only addresses linked to an account: a trailer cut off by a
blank line, an unlinked email, or a noreply address carrying someone else's
numeric id credits nobody or the wrong person. Harvested commits authored by
maintainer identities without such a trailer left their contributors off it,
which is why `docs/CONTRIBUTORS.md` carries the full wall
(`scripts/contributor-wall.py`). Credit lost in history that cannot change is
restored by a later commit with one trailer per person, naming each pull
request; `scripts/check-contributor-credit.py` fails the release window when a
harvest, a trailer or a noreply id would leave someone uncounted.
list counts commit authors only: about 200 harvested commits authored by
maintainer identities left their contributors off it, which is why
`docs/CONTRIBUTORS.md` carries the full wall (`scripts/contributor-wall.py`).
A maintainer closes their PR by hand with a thank-you naming the commit;
nothing closes a PR automatically. Canonical human identities come from
`.github/AUTHOR_MAP`.
+1 -17
View File
@@ -7,23 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- **Renaming sessions with Chinese titles:** the session picker no longer rejects a Chinese, Japanese, Korean or emoji title of more than about 33 characters as "Title must be 1–100 characters". It now counts characters, as the message says and as `/rename` and the Runtime API already did, so any title up to 100 characters is accepted. Thanks [@luochen211](https://github.com/luochen211) (PR 7322).
- **Non-ASCII file names:** `/diff`, the `@git` and `@diff` mentions and the Runtime API workspace diff now show paths with Chinese, emoji and other non-ASCII characters as written instead of as quoted octal escapes, and the turn context no longer lists the workspace root as an empty "Active paths" entry. Thanks [@luochen211](https://github.com/luochen211) (PR 7317).
- **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).
- **MCP and language servers that send requests mid-call:** a tool call no longer fails with "neither a result nor an error" when an MCP server sends a `ping` or other request that reuses the call's id while the call is running, and a language server's `workspace/configuration` or similar request no longer ends a pending LSP request. Codewhale now answers `ping`, answers other server requests with "Method not found" (it never advertised them), keeps waiting for the real reply, and a refused answer over Streamable HTTP no longer drops the connection to legacy SSE. Thanks [@luochen211](https://github.com/luochen211) (PR 7319).
- **Qwen Code as an external agent:** Codewhale now finds an installed Qwen Code CLI (`qwen`, 0.6.1 or newer) and can run it as a session over ACP with `qwen --acp`. `GET /v1/agents/external` lists it, an older install is reported as needing `npm i -g @qwen-code/qwen-code@latest`, and sign-in stays inside Qwen Code (`/auth`). Thanks [@MannXo](https://github.com/MannXo) (#7293).
- **README and contributor credit:** the repository front page now opens the way the website does: the line "The model can be anyone's. The agent should be yours.", one sentence on what Codewhale is, the install command and one picture, with reference detail moved into the linked guides; the 18 translated READMEs follow the same structure and say they are machine-drafted. The contributor wall and the website now count the same 433 people (407 GitHub accounts and the commit authors whose email is not linked to an account), where the website had counted accounts only. `DEPENDENCY_SECURITY.md` moved into `docs/dependency-maintenance.md`, and `.github/SUPPORT.md` says where to ask for help.
- **Website Plugins page:** The website's Plugins page now describes what the released version does, and a labelled section says what 0.10.3 adds: one Plugins place, Standard and Creator mode, and the Permissions control.
- **First-time guide on the website:** a new docs page, "Use Codewhale for the first time" (`/docs/first-time`, English and Simplified Chinese), walks someone who has never used a terminal through opening one, installing, choosing the folder Codewhale works in and when that choice matters, opening a terminal in a folder from Finder or File Explorer, connecting a model, a first task, and stopping, undoing and quitting. The install page gains a short "New to the terminal?" block for macOS and Windows that links to it, and `llms.txt` carries the same steps.
- **Plugin docs and plugin-creator:** Docs now say everything you add is a plugin. The bundled plugin-creator skill no longer tells an agent never to trust or enable a plugin (in Full Access it may, for one it wrote and installed from its project), points code and screen plugins at cordis-plugin-development, and an untouched copy from 0.10.2 now upgrades. The Chinese extension and Runtime API docs now say editor ACP sessions get plugin tools and configured MCP servers.
- **Model thinking:** New installs show model thinking by default: it streams live in the transcript and collapses under a "Thought for …" header when the answer starts. Existing installs keep their saved setting; turn it on under Model reasoning in chat in /config.
- **Footer clock:** The footer clock and phase now say "thinking", matching the transcript.
- **Paused stream:** A paused stream now shows the thinking it has already received instead of waiting for the next token.
- **Sign-in notice:** The "Account not loaded" notice now appears once per sign-in in the footer, and Esc dismisses it; it no longer lands in the transcript.
- **Usage reporting:** Installers now run their closing diagnostic with usage reporting and update checks off and print how to turn usage reporting off. `codewhale doctor` now says a saved opt-out comes from the config file, not the environment, and help and English notices call the feature "usage reporting".
- **Long tool names:** Tools whose encoded name is longer than the route's limit are now left out with a visible warning instead of failing every request with a 400. The limit is 64 characters on OpenAI and on other OpenAI-compatible providers that document no larger limit, and 128 on Anthropic-style routes, DeepSeek, Ollama, Ollama Cloud, vLLM, SGLang and custom routes. Forcing such a tool gives an error that names the tool and the limit, and a session that already called one on a route with a documented limit (Anthropic, OpenAI, Codex, DeepSeek) is refused with a message saying to switch route or start a new session. A forced tool_choice in any chat form now names the encoded tool, and a tool whose real name looks like an encoded escape can no longer be mistaken for another tool.
- **Start after a hard kill:** The Engine now also starts after a hard kill when its home was spelled through a symbolic link (for example under /tmp on macOS) or, on macOS and Windows, in a different letter case: a dead owner's record for the deleted data directory is discarded instead of refusing every start.
- **Context window labels:** the model picker and fleet capability badges now show `1.5M` and `2.1M` for 1,500,000- and 2,100,000-token windows instead of `1.50M` and `2.10M`. Thanks [@Aboysky](https://github.com/Aboysky) (PR 7295).
- **REPL output limit:** REPL output is now cut to its byte limit, so Chinese, Japanese, Korean or emoji output no longer comes back up to three times longer than the limit, and the cut never splits a character. Thanks [@Aboysky](https://github.com/Aboysky) (PR 7296).
- **Git status with renamed files:** the Git and Changes views no longer fail with "git status failed" or show a phantom row when a file is renamed on the worktree side (for example a moved file marked with `git add -N`), including when the original name starts with non-ASCII text such as Chinese. Thanks [@luochen211](https://github.com/luochen211) (PR 7310).
@@ -38,7 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Simplified Chinese punctuation and /status:** in the zh-Hans interface, the `/router` setup and test screens, label prompts and usage lines now use full-width punctuation (`:;,()`) instead of ASCII marks next to Chinese text, and `/status` values start in one column under Chinese labels instead of starting at a different column for each label. Thanks [@luochen211](https://github.com/luochen211) (PR 7314).
- **Git tool on older git:** the Git tool's `review_scope` action no longer passes `--no-lazy-fetch`, which git older than 2.44 rejects, so it failed on every call there. It sets `GIT_NO_LAZY_FETCH=1` instead, which keeps the same protection on newer git and is ignored by older git.
- **Plugins from a shell:** `codewhale plugins trust <name>` prints the same capability review as `/plugin trust` with its bundle review token and, in a terminal, asks once. `--review-token <token> --yes` trusts without a prompt while that token still matches the installed content and capabilities; the token ties trust to the exact content, does not show that anyone read the review, and is not the host-service review token. A `--yes` trust is recorded as the command line and grants no host service. Every command the agent runs (shell, persistent terminal, verifier and test runs, the Python kernel, MCP servers it starts) now carries `CODEWHALE_AGENT_PROJECT` with the session's project, while hooks and configured MCP servers do not; with it, a trust or enable is recorded as the agent's, for that project only, even when a prompt is answered through a terminal, and the agent may trust only a plugin it installed from a folder inside the project. `plugins enable`, `disable` and `revoke` work the same way, each with `--json`, and `plugins status` shows who approved. The agent's shell commands that run them go through the session's approval like any other shell command, so a Full Access agent can trust and enable a plugin it wrote; the mark is a label the agent could remove in Full Access. (CW-7891)
- **Sandbox:** a managed configuration can set `tool_launcher` (Linux) to run every sandboxed tool command as another user, in front of bubblewrap; commands then run inside the sandbox or not at all, masks are computed for that user, `/tmp` is private per command, and `GET /v1/runtime/info` reports `sandbox.kind` and `sandbox.tool_launcher`. Without the key nothing changes. A managed configuration named by `CODEWHALE_MANAGED_CONFIG_PATH` or `managed_config_path` is applied on every load, and one that is missing or cannot be read stops the load instead of loading with no managed layer; a blank `CODEWHALE_MANAGED_CONFIG_PATH` is now ignored instead of switching the managed layer off, and a config file's `managed_config_path` can no longer point away from an existing file at the default managed path. Fleet local workers inherit the managed-config variable, and `codewhale serve --mcp` does not expose shell tools when the managed configuration it loads sets `tool_launcher`. A configuration whose provider is declared only by a plugin fails to load under a managed configuration, with an error that says to declare the provider table or pick a built-in provider.
- **Sandbox:** a managed configuration can set `tool_launcher` (Linux) to run every sandboxed tool command as another user, in front of bubblewrap; commands then run inside the sandbox or not at all, masks are computed for that user, `/tmp` is private per command, and `GET /v1/runtime/info` reports `sandbox.kind` and `sandbox.tool_launcher`. Without the key nothing changes. A managed configuration named by `CODEWHALE_MANAGED_CONFIG_PATH` is now applied on every load; a configuration whose provider comes from a plugin no longer loads with the managed file skipped, it fails to load instead.
- **Receipts:** a terminal session's receipt (`/receipts`, `codewhale receipts`) now shows the provider's token counts and the same cost report `/cost` prints, saying "unknown" and why when the route's price is not known, never a guess. A Runtime thread's receipt shows token counts only; its cost line follows in a later release. `codewhale metrics` now counts usage saved in terminal sessions instead of reporting none. Sessions saved by 0.10.2 carry only a combined token total.
- **ACP server on the official SDK:** The ACP server (`codewhale serve --acp`) now runs on the official Agent Client Protocol SDK. Its messages follow the official v1 types, and it no longer advertises the non-standard `modelSelection` capability or session `models` field. Session list entries carry `createdAt` and `messageCount` under `_meta.codewhale`, terminal sign-in methods are advertised only to clients that enabled terminal authentication, a notification is never answered, a permission request always carries text, and session list and configuration requests are served while a turn runs, response ids are echoed unchanged for every client (the Zed-only string rewrite is gone), and `initialize` without `protocolVersion` is refused with `-32602`.
- **External agents over ACP:** Codewhale now drives external agents through the official Agent Client Protocol SDK. Request ids on the wire are strings, and an agent's error is treated as a sign-in problem only when it carries the protocol's auth-required code.
+9 -35
View File
@@ -41,34 +41,6 @@ mod platform {
tokio::io::WriteHalf<Connection>,
>;
fn resolved_store_form(path: PathBuf) -> Option<PathBuf> {
let checked = codewhale_engine::runtime_threads::checked_runtime_store_root(path).ok()?;
let mut tail = Vec::new();
let mut probe = checked.as_path();
loop {
match probe.canonicalize() {
Ok(real) => {
return Some(tail.iter().rev().fold(real, |acc, part| acc.join(part)));
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
tail.push(probe.file_name()?.to_owned());
probe = probe.parent()?;
}
Err(_) => return None,
}
}
}
fn resolved_store_form_of(path: &std::path::Path) -> PathBuf {
resolved_store_form(path.to_path_buf()).unwrap_or_else(|| path.to_path_buf())
}
fn same_store_path(left: &std::path::Path, right: &std::path::Path) -> bool {
left == right
|| (cfg!(any(target_os = "macos", windows))
&& left.to_string_lossy().to_lowercase() == right.to_string_lossy().to_lowercase())
}
fn same_socket_selection(left: &std::path::Path, right: &std::path::Path) -> bool {
if left == right {
return true;
@@ -256,13 +228,15 @@ mod platform {
Ok(())
}
fn discard_if_store_gone_blocking(&self, selected_store: PathBuf) -> Result<bool> {
let gone = resolved_store_form(selected_store).is_some_and(|selected| {
same_store_path(&selected, &resolved_store_form_of(&self.receipt().data_dir))
&& matches!(
selected.symlink_metadata(),
Err(error) if error.kind() == std::io::ErrorKind::NotFound
)
});
let gone =
codewhale_engine::runtime_threads::checked_runtime_store_root(selected_store)
.is_ok_and(|selected| {
selected == self.receipt().data_dir
&& matches!(
selected.symlink_metadata(),
Err(error) if error.kind() == std::io::ErrorKind::NotFound
)
});
if !gone || !matches!(self.revalidate_receipt(), Ok(true)) {
return Ok(false);
}
+1 -37
View File
@@ -76,18 +76,6 @@ impl McpServerSettings {
}
}
fn refuse_shell_tools(&mut self) -> Vec<String> {
let (refused, kept): (Vec<String>, Vec<String>) = std::mem::take(&mut self.expose_tools)
.into_iter()
.partition(|name| {
build_exposed_tools(std::slice::from_ref(name))
.iter()
.any(|tool| tool.internal == "exec_shell")
});
self.expose_tools = kept;
refused
}
fn from_toml(contents: &str) -> Result<Self> {
let config: McpServerConfigFile = toml::from_str(contents)?;
Ok(Self {
@@ -110,21 +98,12 @@ pub async fn run_mcp_server(
workspace: PathBuf,
skills_dir: PathBuf,
skills_config: std::sync::Arc<codewhale_runtime::config::SkillsConfig>,
tool_launcher_configured: bool,
) -> Result<()> {
// Settings load is a synchronous config read; keep it off the async
// worker per the blocking-call convention.
let mut settings = tokio::task::spawn_blocking(McpServerSettings::load)
let settings = tokio::task::spawn_blocking(McpServerSettings::load)
.await
.context("MCP server settings task failed")??;
if tool_launcher_configured {
for name in settings.refuse_shell_tools() {
eprintln!(
"codewhale mcp server: not exposing '{name}': the managed configuration sets \
tool_launcher, and this server does not run commands through it."
);
}
}
let server = McpServer::new(workspace, settings, skills_dir, skills_config)?;
// stdout carries the protocol; the notice goes to stderr so an operator
// whose config lists write tools sees why they are missing.
@@ -927,21 +906,6 @@ mod tests {
);
}
#[test]
fn a_tool_launcher_refuses_shell_tools_even_when_the_operator_allows_them() {
let mut settings = McpServerSettings::from_toml(
"[server]\nexpose_tools = [\"file_read\", \"shell\", \"exec_shell\", \"Bash\"]\nrequire_approval = false\n",
)
.expect("parse config");
assert_eq!(settings.refuse_shell_tools(), vec!["shell", "exec_shell"]);
let server = test_server(PathBuf::from("."), settings);
let names: Vec<&str> = server.tools.iter().map(|tool| tool.name.as_ref()).collect();
assert_eq!(names, vec!["file_read"]);
for internal in ["exec_shell", "Bash", "bash"] {
assert!(server.registry.get(internal).is_none(), "{internal}");
}
}
#[test]
fn existing_config_without_require_approval_withholds_and_names_write_tools() {
let settings = McpServerSettings::from_toml(
-47
View File
@@ -296,53 +296,6 @@ async fn dead_owner_whose_store_was_deleted_is_discarded_and_startup_proceeds()
fixture.bind_replacement(None).await.unwrap();
}
async fn assert_gone_store_spelled_as_is_discarded(
label: &str,
spell: impl FnOnce(&Path) -> PathBuf,
) {
let fixture = BootstrapPublicationFixture::new(label, true, false).await;
std::fs::remove_dir(&fixture.owner.data_dir).unwrap();
let spelled = spell(&fixture.harness.root);
assert_ne!(spelled, fixture.owner.data_dir);
assert!(matches!(
codewhale_app_server::daemon_client::probe_owner_for_host_startup(
fixture.owner.config_path.clone(),
Some(fixture.owner.socket_path.clone()),
&spelled,
)
.await
.unwrap(),
codewhale_app_server::daemon_client::HostOwnerProbe::Absent
));
assert!(
fixture
.parent
.read_private_receipt("daemon.sock.owner.json", 16384)
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn dead_owner_is_discarded_when_the_selected_store_is_spelled_through_a_symlink() {
assert_gone_store_spelled_as_is_discarded("boot-gone-link", |root| {
std::os::unix::fs::symlink(root, root.join("alias")).unwrap();
root.join("alias").join("runtime")
})
.await;
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn dead_owner_is_discarded_when_the_selected_store_is_spelled_in_another_letter_case() {
assert_gone_store_spelled_as_is_discarded("boot-gone-case", |root| {
let upper = PathBuf::from(root.to_string_lossy().to_uppercase());
assert!(upper.exists(), "this test needs a case-insensitive volume");
upper.join("RUNTIME")
})
.await;
}
#[tokio::test]
async fn live_owner_with_a_missing_store_is_never_discarded() {
let fixture = BootstrapPublicationFixture::new("boot-gone-live", false, false).await;
+2 -30
View File
@@ -117,7 +117,7 @@ struct Cli {
#[arg(
long,
value_name = "BOOL",
help = "Control usage reporting (default on; Codewhale + PostHog; \
help = "Control aggregate usage counting (default on; Codewhale + PostHog; \
durable off: config set telemetry false; CODEWHALE_TELEMETRY=0 always wins)"
)]
telemetry: Option<bool>,
@@ -1957,7 +1957,7 @@ enum ConfigCommand {
Unset {
key: String,
},
/// Review usage reporting by Codewhale and PostHog (default on).
/// Review aggregate usage counting by Codewhale and PostHog (default on).
Telemetry {
/// Optional compatibility form: enable future sessions under this policy version.
#[arg(long, value_name = "VERSION")]
@@ -6792,10 +6792,6 @@ fn apply_tui_env(cli: &Cli, resolved_runtime: &ResolvedRuntimeOptions, passthrou
codewhale_config::TELEMETRY_FLOOR_ENV,
if floor { "1" } else { "0" },
);
set_tui_env(
codewhale_config::TELEMETRY_SOURCE_ENV,
resolved_runtime.telemetry_source.as_str(),
);
if let Some(endpoint) = resolved_runtime.telemetry_endpoint.as_ref() {
set_tui_env("CODEWHALE_TELEMETRY_ENDPOINT", endpoint);
}
@@ -7230,30 +7226,6 @@ mod tests {
);
}
#[test]
fn dispatcher_hands_the_telemetry_source_to_the_tui() {
let _lock = env_lock();
let _guards = [
ScopedEnvVar::remove("CODEWHALE_TELEMETRY"),
ScopedEnvVar::remove("DEEPSEEK_TELEMETRY"),
ScopedEnvVar::remove(codewhale_config::TELEMETRY_FLOOR_ENV),
ScopedEnvVar::remove(codewhale_config::TELEMETRY_SOURCE_ENV),
];
let cli = parse_ok(&["codewhale"]);
let mut runtime = resolved_runtime_for_test(ProviderKind::NvidiaNim, ProviderSource::Cli);
runtime.telemetry = false;
runtime.telemetry_source = codewhale_config::TelemetrySource::Config;
apply_tui_env(&cli, &runtime, &[]);
assert_eq!(std::env::var("CODEWHALE_TELEMETRY").as_deref(), Ok("false"));
assert!(std::env::var(codewhale_config::TELEMETRY_FLOOR_ENV).is_ok());
assert_eq!(
std::env::var(codewhale_config::TELEMETRY_SOURCE_ENV).as_deref(),
Ok("config")
);
}
#[test]
fn clap_command_definition_is_consistent() {
Cli::command().debug_assert();
+325 -87
View File
@@ -140,17 +140,48 @@ impl EnvRuntimeOverrides {
Self {
provider,
provider_source,
model: env_non_blank(&["CODEWHALE_MODEL", "DEEPSEEK_MODEL", "DEEPSEEK_DEFAULT_TEXT_MODEL"]),
volcengine_model: env_non_blank(&["VOLCENGINE_MODEL", "VOLCENGINE_ARK_MODEL"]),
wanjie_ark_model: env_non_blank(&["WANJIE_ARK_MODEL", "WANJIE_MODEL", "WANJIE_MAAS_MODEL"]),
openrouter_model: env_non_blank(&["OPENROUTER_MODEL"]),
orcarouter_model: env_non_blank(&["ORCAROUTER_MODEL"]),
moonshot_model: env_non_blank(&["MOONSHOT_MODEL", "KIMI_MODEL_NAME", "KIMI_MODEL"]),
xiaomi_mimo_model: env_non_blank(&["XIAOMI_MIMO_MODEL", "MIMO_MODEL"]),
xiaomi_mimo_mode: env_non_blank(&["XIAOMI_MIMO_MODE", "MIMO_MODE"]),
novita_model: env_non_blank(&["NOVITA_MODEL"]),
fireworks_model: env_non_blank(&["FIREWORKS_MODEL"]),
arcee_model: env_non_blank(&["ARCEE_MODEL"]),
model: std::env::var("CODEWHALE_MODEL")
.or_else(|_| std::env::var("DEEPSEEK_MODEL"))
.or_else(|_| std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
volcengine_model: std::env::var("VOLCENGINE_MODEL")
.or_else(|_| std::env::var("VOLCENGINE_ARK_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
wanjie_ark_model: std::env::var("WANJIE_ARK_MODEL")
.or_else(|_| std::env::var("WANJIE_MODEL"))
.or_else(|_| std::env::var("WANJIE_MAAS_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
openrouter_model: std::env::var("OPENROUTER_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
orcarouter_model: std::env::var("ORCAROUTER_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
moonshot_model: std::env::var("MOONSHOT_MODEL")
.or_else(|_| std::env::var("KIMI_MODEL_NAME"))
.or_else(|_| std::env::var("KIMI_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
xiaomi_mimo_model: std::env::var("XIAOMI_MIMO_MODEL")
.or_else(|_| std::env::var("MIMO_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
xiaomi_mimo_mode: std::env::var("XIAOMI_MIMO_MODE")
.or_else(|_| std::env::var("MIMO_MODE"))
.ok()
.filter(|v| !v.trim().is_empty()),
novita_model: std::env::var("NOVITA_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
fireworks_model: std::env::var("FIREWORKS_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
arcee_model: std::env::var("ARCEE_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
verbosity: env_non_blank(&["CODEWHALE_VERBOSITY", "DEEPSEEK_VERBOSITY"]),
auth_mode: env_non_blank(&["CODEWHALE_AUTH_MODE", "DEEPSEEK_AUTH_MODE"]),
log_level: env_non_blank(&["CODEWHALE_LOG_LEVEL", "DEEPSEEK_LOG_LEVEL"]),
@@ -185,7 +216,9 @@ impl EnvRuntimeOverrides {
None
}
}),
http_headers: env_non_blank(&["CODEWHALE_HTTP_HEADERS", "DEEPSEEK_HTTP_HEADERS"])
http_headers: std::env::var("CODEWHALE_HTTP_HEADERS")
.or_else(|_| std::env::var("DEEPSEEK_HTTP_HEADERS"))
.ok()
.and_then(|value| match parse_http_headers(&value) {
Ok(h) => Some(h),
Err(_) => {
@@ -194,85 +227,290 @@ impl EnvRuntimeOverrides {
}
})
.filter(|headers| !headers.is_empty()),
active_route_base_url: env_non_blank(&["CODEWHALE_BASE_URL", "DEEPSEEK_BASE_URL"]),
deepseek_anthropic_base_url: env_non_blank(&["DEEPSEEK_ANTHROPIC_BASE_URL", "DEEPSEEK_CLAUDE_BASE_URL"]),
nvidia_base_url: env_non_blank(&["NVIDIA_NIM_BASE_URL", "NIM_BASE_URL", "NVIDIA_BASE_URL"]),
openai_base_url: env_non_blank(&["OPENAI_BASE_URL"]),
atlascloud_base_url: env_non_blank(&["ATLASCLOUD_BASE_URL"]),
volcengine_base_url: env_non_blank(&["VOLCENGINE_BASE_URL", "VOLCENGINE_ARK_BASE_URL", "ARK_BASE_URL"]),
wanjie_ark_base_url: env_non_blank(&["WANJIE_ARK_BASE_URL", "WANJIE_BASE_URL", "WANJIE_MAAS_BASE_URL"]),
openrouter_base_url: env_non_blank(&["OPENROUTER_BASE_URL"]),
orcarouter_base_url: env_non_blank(&["ORCAROUTER_BASE_URL"]),
xiaomi_mimo_base_url: env_non_blank(&["XIAOMI_MIMO_BASE_URL", "MIMO_BASE_URL"]),
novita_base_url: env_non_blank(&["NOVITA_BASE_URL"]),
fireworks_base_url: env_non_blank(&["FIREWORKS_BASE_URL"]),
siliconflow_base_url: env_non_blank(&["SILICONFLOW_BASE_URL"]),
siliconflow_model: env_non_blank(&["SILICONFLOW_MODEL"]),
arcee_base_url: env_non_blank(&["ARCEE_BASE_URL"]),
moonshot_base_url: env_non_blank(&["MOONSHOT_BASE_URL", "KIMI_BASE_URL"]),
sglang_base_url: env_non_blank(&["SGLANG_BASE_URL"]),
vllm_base_url: env_non_blank(&["VLLM_BASE_URL"]),
ollama_base_url: env_non_blank(&["OLLAMA_BASE_URL"]),
ollama_cloud_base_url: env_non_blank(&["OLLAMA_CLOUD_BASE_URL"]),
ollama_cloud_model: env_non_blank(&["OLLAMA_CLOUD_MODEL"]),
huggingface_base_url: env_non_blank(&["HUGGINGFACE_BASE_URL", "HF_BASE_URL"]),
huggingface_model: env_non_blank(&["HUGGINGFACE_MODEL", "HF_MODEL"]),
modelscope_base_url: env_non_blank(&["MODELSCOPE_BASE_URL"]),
modelscope_model: env_non_blank(&["MODELSCOPE_MODEL"]),
together_base_url: env_non_blank(&["TOGETHER_BASE_URL"]),
together_model: env_non_blank(&["TOGETHER_MODEL"]),
qianfan_base_url: env_non_blank(&["QIANFAN_BASE_URL", "BAIDU_QIANFAN_BASE_URL"]),
qianfan_model: env_non_blank(&["QIANFAN_MODEL", "BAIDU_QIANFAN_MODEL"]),
openai_codex_base_url: env_non_blank(&["OPENAI_CODEX_BASE_URL", "CODEX_BASE_URL"]),
openai_codex_model: env_non_blank(&["OPENAI_CODEX_MODEL", "CODEX_MODEL"]),
anthropic_base_url: env_non_blank(&["ANTHROPIC_BASE_URL"]),
anthropic_model: env_non_blank(&["ANTHROPIC_MODEL"]),
openmodel_base_url: env_non_blank(&["OPENMODEL_BASE_URL"]),
openmodel_model: env_non_blank(&["OPENMODEL_MODEL"]),
zai_base_url: env_non_blank(&["ZAI_BASE_URL", "Z_AI_BASE_URL", "ZHIPU_BASE_URL", "ZHIPUAI_BASE_URL", "BIGMODEL_BASE_URL"]),
zai_model: env_non_blank(&["ZAI_MODEL", "Z_AI_MODEL", "ZHIPU_MODEL", "ZHIPUAI_MODEL", "BIGMODEL_MODEL", "GLM_MODEL"]),
stepfun_base_url: env_non_blank(&["STEPFUN_BASE_URL", "STEP_BASE_URL"]),
stepfun_model: env_non_blank(&["STEPFUN_MODEL", "STEP_MODEL"]),
minimax_base_url: env_non_blank(&["MINIMAX_BASE_URL"]),
minimax_anthropic_base_url: env_non_blank(&["MINIMAX_ANTHROPIC_BASE_URL"]),
minimax_model: env_non_blank(&["MINIMAX_MODEL"]),
deepinfra_base_url: env_non_blank(&["DEEPINFRA_BASE_URL"]),
deepinfra_model: env_non_blank(&["DEEPINFRA_MODEL"]),
sakana_base_url: env_non_blank(&["SAKANA_BASE_URL"]),
sakana_model: env_non_blank(&["SAKANA_MODEL"]),
longcat_base_url: env_non_blank(&["LONGCAT_BASE_URL"]),
longcat_model: env_non_blank(&["LONGCAT_MODEL"]),
opencode_go_base_url: env_non_blank(&["OPENCODE_GO_BASE_URL"]),
opencode_go_model: env_non_blank(&["OPENCODE_GO_MODEL"]),
opencode_zen_base_url: env_non_blank(&["OPENCODE_ZEN_BASE_URL"]),
opencode_zen_model: env_non_blank(&["OPENCODE_ZEN_MODEL"]),
meta_base_url: env_non_blank(&["META_MODEL_API_BASE_URL", "MODEL_API_BASE_URL"]),
meta_model: env_non_blank(&["META_MODEL_API_MODEL", "MODEL_API_MODEL"]),
xai_base_url: env_non_blank(&["XAI_BASE_URL"]),
xai_model: env_non_blank(&["XAI_MODEL"]),
google_base_url: env_non_blank(&["GOOGLE_BASE_URL", "GEMINI_BASE_URL"]),
google_model: env_non_blank(&["GOOGLE_MODEL", "GEMINI_MODEL"]),
mistral_base_url: env_non_blank(&["MISTRAL_BASE_URL"]),
mistral_model: env_non_blank(&["MISTRAL_MODEL"]),
telecomjs_base_url: env_non_blank(&["TELECOMJS_BASE_URL"]),
telecomjs_model: env_non_blank(&["TELECOMJS_MODEL"]),
edenai_base_url: env_non_blank(&["EDENAI_BASE_URL"]),
edenai_model: env_non_blank(&["EDENAI_MODEL"]),
zenmux_base_url: env_non_blank(&["ZENMUX_BASE_URL"]),
zenmux_model: env_non_blank(&["ZENMUX_MODEL"]),
csdn_base_url: env_non_blank(&["CSDN_BASE_URL"]),
csdn_model: env_non_blank(&["CSDN_MODEL"]),
concentrate_base_url: env_non_blank(&["CONCENTRATE_BASE_URL"]),
concentrate_model: env_non_blank(&["CONCENTRATE_MODEL"]),
active_route_base_url: std::env::var("CODEWHALE_BASE_URL")
.or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
deepseek_anthropic_base_url: std::env::var("DEEPSEEK_ANTHROPIC_BASE_URL")
.or_else(|_| std::env::var("DEEPSEEK_CLAUDE_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
nvidia_base_url: std::env::var("NVIDIA_NIM_BASE_URL")
.or_else(|_| std::env::var("NIM_BASE_URL"))
.or_else(|_| std::env::var("NVIDIA_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
openai_base_url: std::env::var("OPENAI_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
atlascloud_base_url: std::env::var("ATLASCLOUD_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
volcengine_base_url: std::env::var("VOLCENGINE_BASE_URL")
.or_else(|_| std::env::var("VOLCENGINE_ARK_BASE_URL"))
.or_else(|_| std::env::var("ARK_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
wanjie_ark_base_url: std::env::var("WANJIE_ARK_BASE_URL")
.or_else(|_| std::env::var("WANJIE_BASE_URL"))
.or_else(|_| std::env::var("WANJIE_MAAS_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
openrouter_base_url: std::env::var("OPENROUTER_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
orcarouter_base_url: std::env::var("ORCAROUTER_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
xiaomi_mimo_base_url: std::env::var("XIAOMI_MIMO_BASE_URL")
.or_else(|_| std::env::var("MIMO_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
novita_base_url: std::env::var("NOVITA_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
fireworks_base_url: std::env::var("FIREWORKS_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
siliconflow_base_url: std::env::var("SILICONFLOW_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
siliconflow_model: std::env::var("SILICONFLOW_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
arcee_base_url: std::env::var("ARCEE_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
moonshot_base_url: std::env::var("MOONSHOT_BASE_URL")
.or_else(|_| std::env::var("KIMI_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
sglang_base_url: std::env::var("SGLANG_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
vllm_base_url: std::env::var("VLLM_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
ollama_base_url: std::env::var("OLLAMA_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
ollama_cloud_base_url: std::env::var("OLLAMA_CLOUD_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
ollama_cloud_model: std::env::var("OLLAMA_CLOUD_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
huggingface_base_url: std::env::var("HUGGINGFACE_BASE_URL")
.or_else(|_| std::env::var("HF_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
huggingface_model: std::env::var("HUGGINGFACE_MODEL")
.or_else(|_| std::env::var("HF_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
modelscope_base_url: std::env::var("MODELSCOPE_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
modelscope_model: std::env::var("MODELSCOPE_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
together_base_url: std::env::var("TOGETHER_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
together_model: std::env::var("TOGETHER_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
qianfan_base_url: std::env::var("QIANFAN_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("BAIDU_QIANFAN_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
}),
qianfan_model: std::env::var("QIANFAN_MODEL")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("BAIDU_QIANFAN_MODEL")
.ok()
.filter(|v| !v.trim().is_empty())
}),
openai_codex_base_url: std::env::var("OPENAI_CODEX_BASE_URL")
.or_else(|_| std::env::var("CODEX_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
openai_codex_model: std::env::var("OPENAI_CODEX_MODEL")
.or_else(|_| std::env::var("CODEX_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
anthropic_base_url: std::env::var("ANTHROPIC_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
anthropic_model: std::env::var("ANTHROPIC_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
openmodel_base_url: std::env::var("OPENMODEL_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
openmodel_model: std::env::var("OPENMODEL_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
zai_base_url: std::env::var("ZAI_BASE_URL")
.or_else(|_| std::env::var("Z_AI_BASE_URL"))
.or_else(|_| std::env::var("ZHIPU_BASE_URL"))
.or_else(|_| std::env::var("ZHIPUAI_BASE_URL"))
.or_else(|_| std::env::var("BIGMODEL_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
zai_model: std::env::var("ZAI_MODEL")
.or_else(|_| std::env::var("Z_AI_MODEL"))
.or_else(|_| std::env::var("ZHIPU_MODEL"))
.or_else(|_| std::env::var("ZHIPUAI_MODEL"))
.or_else(|_| std::env::var("BIGMODEL_MODEL"))
.or_else(|_| std::env::var("GLM_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
stepfun_base_url: std::env::var("STEPFUN_BASE_URL")
.or_else(|_| std::env::var("STEP_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
stepfun_model: std::env::var("STEPFUN_MODEL")
.or_else(|_| std::env::var("STEP_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
minimax_base_url: std::env::var("MINIMAX_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
minimax_anthropic_base_url: std::env::var("MINIMAX_ANTHROPIC_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
minimax_model: std::env::var("MINIMAX_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
deepinfra_base_url: std::env::var("DEEPINFRA_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
deepinfra_model: std::env::var("DEEPINFRA_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
sakana_base_url: std::env::var("SAKANA_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
sakana_model: std::env::var("SAKANA_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
longcat_base_url: std::env::var("LONGCAT_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
longcat_model: std::env::var("LONGCAT_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
opencode_go_base_url: std::env::var("OPENCODE_GO_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
opencode_go_model: std::env::var("OPENCODE_GO_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
opencode_zen_base_url: std::env::var("OPENCODE_ZEN_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
opencode_zen_model: std::env::var("OPENCODE_ZEN_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
meta_base_url: std::env::var("META_MODEL_API_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("MODEL_API_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
}),
meta_model: std::env::var("META_MODEL_API_MODEL")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("MODEL_API_MODEL")
.ok()
.filter(|v| !v.trim().is_empty())
}),
xai_base_url: std::env::var("XAI_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
xai_model: std::env::var("XAI_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
google_base_url: std::env::var("GOOGLE_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("GEMINI_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
}),
google_model: std::env::var("GOOGLE_MODEL")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("GEMINI_MODEL")
.ok()
.filter(|v| !v.trim().is_empty())
}),
mistral_base_url: std::env::var("MISTRAL_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
mistral_model: std::env::var("MISTRAL_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
telecomjs_base_url: std::env::var("TELECOMJS_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
telecomjs_model: std::env::var("TELECOMJS_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
edenai_base_url: std::env::var("EDENAI_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
edenai_model: std::env::var("EDENAI_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
zenmux_base_url: std::env::var("ZENMUX_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
zenmux_model: std::env::var("ZENMUX_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
csdn_base_url: std::env::var("CSDN_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
csdn_model: std::env::var("CSDN_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
concentrate_base_url: std::env::var("CONCENTRATE_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
concentrate_model: std::env::var("CONCENTRATE_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
// `CODEWHALE_API_BASE` is a trust boundary, not a plain string:
// an origin this route would refuse to send a bearer to is
// dropped here rather than resolved into a route.
codewhale_base_url: provider::codewhale_api_base_from_env(),
codewhale_model: env_non_blank(&["CODEWHALE_MODEL"]),
modelstudio_token_plan_base_url: env_non_blank(&["MODELSTUDIO_TOKEN_PLAN_BASE_URL"]),
modelstudio_token_plan_model: env_non_blank(&["MODELSTUDIO_TOKEN_PLAN_MODEL"]),
modelstudio_coding_plan_base_url: env_non_blank(&["MODELSTUDIO_CODING_PLAN_BASE_URL"]),
modelstudio_coding_plan_model: env_non_blank(&["MODELSTUDIO_CODING_PLAN_MODEL"]),
codewhale_model: std::env::var("CODEWHALE_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
modelstudio_token_plan_base_url: std::env::var("MODELSTUDIO_TOKEN_PLAN_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
modelstudio_token_plan_model: std::env::var("MODELSTUDIO_TOKEN_PLAN_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
modelstudio_coding_plan_base_url: std::env::var("MODELSTUDIO_CODING_PLAN_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
modelstudio_coding_plan_model: std::env::var("MODELSTUDIO_CODING_PLAN_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
}
}
+7 -6
View File
@@ -27,11 +27,10 @@ pub fn resolve_stream_open_timeout(configured_secs: Option<u64>) -> std::time::D
secs.clamp(MIN_STREAM_OPEN_TIMEOUT_SECS, MAX_STREAM_OPEN_TIMEOUT_SECS),
),
_ => stream_open_timeout_from_env(
crate::env_overrides::env_non_blank(&[
"CODEWHALE_STREAM_OPEN_TIMEOUT_SECS",
"DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS",
])
.as_deref(),
std::env::var("CODEWHALE_STREAM_OPEN_TIMEOUT_SECS")
.or_else(|_| std::env::var("DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS"))
.ok()
.as_deref(),
),
}
}
@@ -51,7 +50,9 @@ pub fn stream_open_timeout_from_env(value: Option<&str>) -> std::time::Duration
/// `false`, ...) leaves HTTP/2 on.
#[must_use]
pub fn force_http1_from_env() -> bool {
crate::env_overrides::env_non_blank(&["CODEWHALE_FORCE_HTTP1", "DEEPSEEK_FORCE_HTTP1"])
std::env::var("CODEWHALE_FORCE_HTTP1")
.or_else(|_| std::env::var("DEEPSEEK_FORCE_HTTP1"))
.ok()
.map(|v| v.trim().to_ascii_lowercase())
.is_some_and(|v| matches!(v.as_str(), "1" | "true" | "yes" | "on"))
}
-28
View File
@@ -33,8 +33,6 @@ pub const LEGACY_CLI_API_KEY_SOURCE_ENV: &str = "DEEPSEEK_API_KEY_SOURCE";
/// [`CLI_API_KEY_SOURCE_ENV`]. Set to `1`/`0` on every delegated run.
pub const TELEMETRY_FLOOR_ENV: &str = "CODEWHALE_TELEMETRY_FLOOR";
pub const TELEMETRY_SOURCE_ENV: &str = "CODEWHALE_TELEMETRY_SOURCE";
/// Whether an environment-level kill switch forces telemetry off here.
///
/// A floor is *not* the same as "telemetry resolved to false": off is the
@@ -94,32 +92,6 @@ impl TelemetrySource {
Self::Default => "default",
}
}
#[must_use]
pub fn from_label(label: &str) -> Option<Self> {
match label {
"cli" => Some(Self::Cli),
"env" => Some(Self::Env),
"config" => Some(Self::Config),
"default" => Some(Self::Default),
_ => None,
}
}
}
#[must_use]
pub fn dispatched_telemetry_source() -> Option<TelemetrySource> {
parse_bool(&std::env::var(TELEMETRY_FLOOR_ENV).ok()?).ok()?;
TelemetrySource::from_label(&std::env::var(TELEMETRY_SOURCE_ENV).ok()?)
}
#[must_use]
pub fn dispatched_telemetry_consent(file_telemetry: Option<bool>) -> (bool, TelemetrySource) {
let (on, source) = resolved_telemetry_consent(file_telemetry);
match (source, dispatched_telemetry_source()) {
(TelemetrySource::Env, Some(declared)) => (on, declared),
_ => (on, source),
}
}
/// Read the telemetry environment override, reporting an unreadable value
-120
View File
@@ -8763,7 +8763,6 @@ struct TelemetryEnvGuard {
codewhale: Option<OsString>,
deepseek: Option<OsString>,
floor: Option<OsString>,
source: Option<OsString>,
codewhale_endpoint: Option<OsString>,
deepseek_endpoint: Option<OsString>,
_lock: std::sync::MutexGuard<'static, ()>,
@@ -8776,7 +8775,6 @@ impl TelemetryEnvGuard {
codewhale: env::var_os("CODEWHALE_TELEMETRY"),
deepseek: env::var_os("DEEPSEEK_TELEMETRY"),
floor: env::var_os(TELEMETRY_FLOOR_ENV),
source: env::var_os(TELEMETRY_SOURCE_ENV),
// The endpoint variables are cleared too. Resolution now has a
// shipped default, so an ambient endpoint in the developer's or
// CI's environment is the difference between pinning the default
@@ -8790,7 +8788,6 @@ impl TelemetryEnvGuard {
env::remove_var("CODEWHALE_TELEMETRY");
env::remove_var("DEEPSEEK_TELEMETRY");
env::remove_var(TELEMETRY_FLOOR_ENV);
env::remove_var(TELEMETRY_SOURCE_ENV);
env::remove_var("CODEWHALE_TELEMETRY_ENDPOINT");
env::remove_var("DEEPSEEK_TELEMETRY_ENDPOINT");
}
@@ -8811,13 +8808,6 @@ impl TelemetryEnvGuard {
}
}
fn set_source(&self, value: &str) {
// Safety: test-only environment mutation guarded by the module mutex.
unsafe {
env::set_var(TELEMETRY_SOURCE_ENV, value);
}
}
fn set_floor(&self, value: &str) {
// Safety: test-only environment mutation guarded by the module mutex.
unsafe {
@@ -8831,7 +8821,6 @@ impl TelemetryEnvGuard {
env::remove_var("CODEWHALE_TELEMETRY");
env::remove_var("DEEPSEEK_TELEMETRY");
env::remove_var(TELEMETRY_FLOOR_ENV);
env::remove_var(TELEMETRY_SOURCE_ENV);
}
}
}
@@ -8852,10 +8841,6 @@ impl Drop for TelemetryEnvGuard {
Some(value) => env::set_var(TELEMETRY_FLOOR_ENV, value),
None => env::remove_var(TELEMETRY_FLOOR_ENV),
}
match self.source.take() {
Some(value) => env::set_var(TELEMETRY_SOURCE_ENV, value),
None => env::remove_var(TELEMETRY_SOURCE_ENV),
}
match self.codewhale_endpoint.take() {
Some(value) => env::set_var("CODEWHALE_TELEMETRY_ENDPOINT", value),
None => env::remove_var("CODEWHALE_TELEMETRY_ENDPOINT"),
@@ -9010,57 +8995,6 @@ fn telemetry_consent_names_its_source() {
assert_eq!(source, TelemetrySource::Env);
}
#[test]
fn dispatched_consent_names_the_dispatchers_source_not_its_hand_off() {
let guard = TelemetryEnvGuard::take();
guard.set("false");
guard.set_floor("0");
guard.set_source("config");
let (on, source) = dispatched_telemetry_consent(Some(false));
assert!(!on);
assert_eq!(source, TelemetrySource::Config);
guard.set("false");
guard.set_floor("1");
guard.set_source("env");
let (on, source) = dispatched_telemetry_consent(Some(false));
assert!(!on);
assert_eq!(source, TelemetrySource::Env);
guard.set("true");
guard.set_floor("0");
guard.set_source("default");
let (on, source) = dispatched_telemetry_consent(None);
assert!(on);
assert_eq!(source, TelemetrySource::Default);
guard.set("false");
guard.set_floor("1");
guard.set_source("cli");
let (on, source) = dispatched_telemetry_consent(None);
assert!(!on);
assert_eq!(source, TelemetrySource::Cli);
guard.clear();
guard.set("0");
guard.set_source("config");
let (on, source) = dispatched_telemetry_consent(Some(false));
assert!(!on);
assert_eq!(
source,
TelemetrySource::Env,
"a source with no dispatcher floor statement is not believed"
);
guard.clear();
guard.set("0");
guard.set_floor("1");
guard.set_source("nonsense");
let (_, source) = dispatched_telemetry_consent(None);
assert_eq!(source, TelemetrySource::Env);
}
#[test]
fn resolved_runtime_options_mints_a_route_candidate() {
let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
@@ -10767,60 +10701,6 @@ fn blank_or_unrecognized_env_overrides_do_not_shadow_config_or_legacy_vars() {
));
}
#[test]
fn blank_primary_env_vars_fall_through_to_their_legacy_aliases() {
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
let names = [
"CODEWHALE_FORCE_HTTP1",
"DEEPSEEK_FORCE_HTTP1",
"CODEWHALE_STREAM_OPEN_TIMEOUT_SECS",
"DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS",
];
let saved: Vec<(&str, Option<OsString>)> = names
.iter()
.map(|name| (*name, env::var_os(name)))
.collect();
// SAFETY: env mutation is serialized by `env_lock` and restored below.
unsafe {
env::set_var("CODEWHALE_MODEL", "");
env::set_var("DEEPSEEK_MODEL", "legacy-model");
env::set_var("CODEWHALE_BASE_URL", " ");
env::set_var("DEEPSEEK_BASE_URL", "https://legacy.example/v1");
env::set_var("MOONSHOT_BASE_URL", "");
env::set_var("KIMI_BASE_URL", "https://kimi.example/v1");
env::set_var("CODEWHALE_FORCE_HTTP1", "");
env::set_var("DEEPSEEK_FORCE_HTTP1", "1");
env::set_var("CODEWHALE_STREAM_OPEN_TIMEOUT_SECS", "");
env::set_var("DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS", "120");
}
let env_overrides = EnvRuntimeOverrides::load();
let force_http1 = crate::stream_timeouts::force_http1_from_env();
let open_timeout = crate::stream_timeouts::resolve_stream_open_timeout(None);
unsafe {
for (name, value) in saved {
match value {
Some(value) => env::set_var(name, value),
None => env::remove_var(name),
}
}
}
assert_eq!(env_overrides.model.as_deref(), Some("legacy-model"));
assert_eq!(
env_overrides.active_route_base_url.as_deref(),
Some("https://legacy.example/v1")
);
assert_eq!(
env_overrides.moonshot_base_url.as_deref(),
Some("https://kimi.example/v1")
);
assert!(force_http1);
assert_eq!(open_timeout, std::time::Duration::from_secs(120));
}
#[cfg(unix)]
#[test]
fn failed_legacy_state_copy_leaves_no_partial_primary() {
+3 -5
View File
@@ -1183,7 +1183,7 @@ impl Git {
overrides: Vec<(String, &'static str)>,
) -> anyhow::Result<Command> {
let mut command = Self::review_base(workspace)?;
let mut count = 3;
let mut count = 2;
for (key, value) in overrides {
// A subsection may contain '='; `-c key=value` would then
// override a different key. Separate env fields preserve it.
@@ -1195,7 +1195,7 @@ impl Git {
Ok(command)
}
/// Git with fsmonitor, hooks, lazy fetch, replace objects and path quoting disabled,
/// Git with fsmonitor, hooks, lazy fetch and replace objects disabled,
/// running in `workspace`. [`Self::review_command`] adds filter overrides.
pub fn review_base(workspace: &Path) -> anyhow::Result<Command> {
use anyhow::Context;
@@ -1257,7 +1257,7 @@ impl Git {
// Both phases must observe the same effective repository config.
.env_remove("GIT_CONFIG")
.env_remove("GIT_CONFIG_PARAMETERS")
.env("GIT_CONFIG_COUNT", "3")
.env("GIT_CONFIG_COUNT", "2")
.env("GIT_CONFIG_KEY_0", "core.fsmonitor")
.env("GIT_CONFIG_VALUE_0", "false")
.env("GIT_CONFIG_KEY_1", "core.hooksPath")
@@ -1265,8 +1265,6 @@ impl Git {
"GIT_CONFIG_VALUE_1",
if cfg!(windows) { "NUL" } else { "/dev/null" },
)
.env("GIT_CONFIG_KEY_2", "core.quotePath")
.env("GIT_CONFIG_VALUE_2", "false")
.env("GIT_NO_LAZY_FETCH", "1")
.env("GIT_NO_REPLACE_OBJECTS", "1");
Ok(command)
@@ -1,432 +0,0 @@
import { readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const root = join(here, '..')
const SOURCES = {
compatibility: join(root, 'src/dsh/compatibility.json'),
hostRoot: join(root, 'src/root.ts'),
shellHooks: join(root, 'src/dsh/shell-hooks.ts'),
seatTable: join(root, '../../app-server/src/runtime_api/plugins.rs'),
catalog: join(root, 'src/dsh/inspect-catalog.json'),
}
const read = (path) => readFileSync(path, 'utf8').replace(/\r\n/g, '\n')
const SERVICES = {
tools: {
summary: 'The tool registry: a plugin adds model-callable tools. The Engine owns calling, approval and results.',
availability: 'always',
methods: [
{ name: 'register', signature: 'register(definition: ToolDefinition): () => void', summary: 'Register one tool built with defineTool from @deepseek-ai/dsh-tools. The definition must stay inside the supported JSON Schema subset (type, oneOf, properties, required, additionalProperties, items, enum, const). A second argument is refused. Returns an idempotent disposer.' },
{ name: 'get', signature: 'get(name: string, agent: Agent): { name: string } | undefined', summary: 'Whether the calling agent has a tool by this name. Answers only inside a live tool call, for that call\'s own agent.' },
],
},
commands: {
summary: 'Slash commands a person types. A handler runs only when the person types the command.',
availability: 'always',
methods: [
{ name: 'register', signature: 'register(definition: { name: string, description: string, argumentHint?: string, handler(call: { args: string, signal: AbortSignal }): CommandResult }): () => void', summary: 'Register one slash command. Names start with a lower case letter, then lower case letters, digits, _ and -, at most 64 characters; at most 64 commands per plugin. A name that clashes with a built-in command gets the alias plugin-<plugin-slug>-<command>, and is refused if that alias is invalid or also taken. A name another plugin already registered is refused, so pick a plugin-specific name. Returns an idempotent disposer.' },
],
},
storage: {
summary: 'Owner-local JSON persistence under the data folder the Engine assigned. It holds no session history and no secrets.',
availability: 'always',
methods: [
{ name: 'get', signature: 'get(key: string): Promise<Json | undefined>', summary: 'Read one stored value.' },
{ name: 'set', signature: 'set(key: string, value: Json): Promise<void>', summary: 'Store one JSON value, within the bounded key, value and owner limits.' },
{ name: 'delete', signature: 'delete(key: string): Promise<void>', summary: 'Remove one stored value.' },
],
},
skills: {
summary: 'Skill roots: reviewed folders of skills added to the Engine\'s skill catalog.',
availability: 'always',
methods: [
{ name: 'registerRoot', signature: 'registerRoot(definition: { path: string }): () => void', summary: 'Add a bundle-relative folder of SKILL.md packages. At most 8 roots per plugin. Absolute paths, links, empty components, . and .. are refused.' },
],
},
sessions: {
summary: 'The session of the calling tool invocation.',
availability: 'tool-invocation',
methods: [
{ name: 'flush', signature: 'flush(session: Session): Promise<boolean>', summary: 'Ask the Engine to persist the committed prefix of the exact session of this tool call. Nothing else on sessions is available.' },
],
},
userQuestions: {
summary: 'Questions put to the person through the Engine\'s own question card.',
availability: 'tool-invocation',
methods: [
{ name: 'ask', signature: 'ask(request: { agent: Agent, questions: Question[], signal?: AbortSignal }): Promise<Answers>', summary: 'Ask the person typed questions during a live root tool call. Timed or continued questions (wait) are not supported.' },
],
},
subagents: {
summary: 'One independent child agent a root tool call may start.',
availability: 'tool-invocation',
methods: [
{ name: 'getProvider', signature: 'getProvider(id: \'spawn\'): Provider', summary: 'Describe the one provider: a fresh child with no inherited conversation.' },
{ name: 'startActivation', signature: 'startActivation(options: { provider: \'spawn\', childId: string, request: { parent: Agent, prompt: TextBlock[], agentOptions?: { model?: string } }, signal?: AbortSignal, delivery?: \'caller\' | \'parent\' }): Promise<{ childId: string, messageId: string, result: Promise<ChildResult>, dispose(): Promise<void> }>', summary: 'Start the child through the Engine\'s approval gate. Resolves to the activation handle { childId, messageId, result, dispose }. Prompts are text only; only the model option is accepted. Needs the live calling agent as parent and is not available inside a child.' },
{ name: 'result', signature: 'activation.result: Promise<{ stopReason: string, output: { type: \'text\', text: string }[], diagnostic?: string }>', summary: 'A getter, not a method: read the property, do not call it. Resolves when the child finishes, with its stop reason and its text output blocks; diagnostic notes dropped non-text blocks.' },
{ name: 'dispose', signature: 'activation.dispose(): Promise<void>', summary: 'Stop that exact child and its own descendants.' },
],
},
agents: {
summary: 'Reserved. The host offers no methods on agents.',
availability: 'none',
methods: [],
},
systemPrompt: {
summary: 'The child agent\'s persona slot, reachable only inside an agent/created listener.',
availability: 'child-agent-hook',
methods: [
{ name: 'section', signature: 'agent.ctx.systemPrompt.section({ name: \'deployment:persona-prefix\', order: agent.ctx.systemPrompt.getSectionOrder(\'DEPLOYMENT_PERSONA_PREFIX\'), text: string }): () => void', summary: 'Set the child\'s persona text, at most 32 KiB, once. name must be the string deployment:persona-prefix (the PERSONA_PREFIX_SECTION value) and order must be what getSectionOrder(\'DEPLOYMENT_PERSONA_PREFIX\') returns, which is 0. Anything else is refused. Called anywhere else it fails. The Engine\'s own system prompt cannot be replaced.' },
],
},
logger: {
summary: 'Cordis logging. Every line becomes a host log notification attributed to the plugin.',
availability: 'always',
cordis: true,
methods: [
{ name: 'logger', signature: 'ctx.logger(name?: string): Logger', summary: 'A named logger with debug, info, warn and error. The host reports error and warn as such and everything else as info.' },
],
},
events: {
summary: 'Cordis event dispatch. Query the host Event provider for the events this host fires.',
availability: 'always',
cordis: true,
methods: [
{ name: 'on', signature: 'ctx.on(name: string, listener: Function, options?: boolean | { prepend?: boolean, global?: boolean }): () => boolean', summary: 'Listen to an event. The host refuses five lifecycle events and limits the options of two (see the Event provider).' },
{ name: 'once', signature: 'ctx.once(name: string, listener: Function, options?: boolean | { prepend?: boolean, global?: boolean }): () => boolean', summary: 'Listen to an event once.' },
{ name: 'emit', signature: 'ctx.emit(name: string, ...args: any[]): void', summary: 'Dispatch an event to its listeners.' },
],
},
reflect: {
summary: 'Cordis service reflection. A plugin may provide its own services but not the Engine\'s.',
availability: 'always',
cordis: true,
methods: [
{ name: 'provide', signature: 'ctx.reflect.provide(name: string, value?: any): () => void', summary: 'Provide a service under a name. Names in refusedServices are refused: the Engine owns them.' },
{ name: 'get', signature: 'ctx.reflect.get(name: string, strict?: boolean): any', summary: 'Read a provided service.' },
],
},
registry: {
summary: 'Cordis plugin registry.',
availability: 'always',
cordis: true,
methods: [
{ name: 'get', signature: 'ctx.registry.get(plugin: Plugin): Runtime | undefined', summary: 'Look up the runtime record of a plugin.' },
{ name: 'resolve', signature: 'ctx.registry.resolve(plugin: Plugin): Function | undefined', summary: 'Resolve a plugin shape to its callback.' },
],
},
prompt: {
summary: 'Attributed sections of the Engine\'s runtime prompt. A plugin cannot replace the system prompt.',
availability: 'always',
methods: [
{ name: 'registerSection', signature: 'registerSection(definition: { id: string, text: string, interpolate?: \'model-cwd\' }): () => void', summary: 'Contribute a plain-text section of at most 4 KiB; at most 128 sections per plugin. The id is lower case letters, digits, _ and -, starts with a letter, at most 64 characters. With interpolate set, only {{model}} and {{cwd}} are expanded.' },
],
},
avatars: {
summary: 'Avatar packs for the terminal.',
availability: 'always',
methods: [
{ name: 'registerPack', signature: 'registerPack(definition: { path: string }): () => void', summary: 'Register a reviewed bundle-relative avatar pack: a path ending in .json, at most 4 packs per plugin.' },
],
},
mcp: {
summary: 'Proposals of MCP servers. The Engine owns connection, credentials and tool calls.',
availability: 'native-review',
methods: [
{ name: 'registerServer', signature: 'registerServer(definition: { serverName: string, server: Record<string, unknown> }): () => void', summary: 'Propose one server. serverName is a letter or digit followed by up to 31 letters, digits, _ or -. headers and env must stay empty and the url may carry no credentials, query or fragment. At most 64 servers per plugin; at most 64 KiB per definition.' },
],
},
shellHooks: {
summary: 'Claude Code and Codex style command hooks, run by the Engine.',
availability: 'native-review',
methods: [
{ name: 'register', signature: 'register(definition: { dialect: \'claude-code\' | \'codex\', point: string, matcher?: string, hook: { event: string, command: string, timeout_secs: number, background: false, continue_on_error: false, name: string } }): () => void', summary: 'Register one command hook for a reviewed native plugin. Forced continuation, observer steering, non-command and asynchronous hooks are not supported.' },
],
},
remote: {
summary: 'Remote pairing, served by the Engine.',
availability: 'review-grant',
methods: [
{ name: 'status', signature: 'status(): Promise<Status>', summary: 'Whether remote access is on.' },
{ name: 'pair', signature: 'pair(options?: { label?: string }): Promise<Pairing>', summary: 'Start a pairing.' },
{ name: 'clients', signature: 'clients(): Promise<Client[]>', summary: 'List paired clients.' },
{ name: 'revoke', signature: 'revoke(id: string): Promise<void>', summary: 'Revoke one grant by the id pair returned.' },
],
},
}
const AVAILABILITY = {
always: 'Available to every plugin that injects it.',
'tool-invocation': 'Answers only inside a live tool call of the plugin.',
'child-agent-hook': 'Answers only inside an agent/created listener, on the child agent.',
'native-review': 'Available to a reviewed native plugin.',
'review-grant': 'Served by the Engine only when the plugin\'s review granted it.',
none: 'No methods.',
}
const EVENTS_SUPPORTED = [
{
name: 'tools/pre-execute',
status: 'supported',
mode: 'ordered listeners, native hooks first, then plugin listeners in registration order',
signature: 'ctx.on(\'tools/pre-execute\', async (exec: { name: string, callId: string, arguments: object, signal: AbortSignal, workspace: string, mode: string, model: string }, next: () => Promise<Abstention>) => Verdict | undefined)',
summary: 'Programmable tool admission for a reviewed native plugin. The frozen call view has no session, agent, tool or approval handle. Return { kind: \'deny\', reason }, { kind: \'ask\', reason? }, { kind: \'revise\', input }, { kind: \'annotate\', text } or { kind: \'abstain\' }; undefined abstains. A malformed answer, error or timeout fails the call closed. The listener batch has a five second deadline.',
options: 'prepend and global are refused.',
},
{
name: 'agent/created',
status: 'supported',
mode: 'listeners of the owning plugin only',
signature: 'ctx.on(\'agent/created\', async ({ agent }: { agent: { id: string, ctx: { systemPrompt: { getSectionOrder(name), section(section) }, tools: { restrict(filter: { deny: string[] }) } } } }) => void)',
summary: 'Fires for a child agent that this plugin\'s own subagents.startActivation creates, before the child starts. The listener may set the child\'s persona and deny tools to it; the window closes when the listeners return.',
options: 'global is refused.',
},
]
const REFUSED_EVENT_REASON = 'Not projected by this host: no core projection exists, so a listener would never fire.'
const ROW_PACKAGES = {
'@deepseek-ai/dsh-mcp-client': {
summary: 'An MCP server row. The Engine proposes the server and owns the connection.',
schema: {
type: 'object',
additionalProperties: false,
required: ['transport', 'serverName'],
properties: {
transport: { type: 'string', enum: ['stdio', 'streamable-http', 'sse'] },
serverName: { type: 'string', pattern: '^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$' },
command: { type: 'string', description: 'stdio only: the program to run.' },
args: { type: 'array', items: { type: 'string' }, description: 'stdio only.' },
env: { type: 'object', maxProperties: 0, description: 'Must be empty or absent: credentials stay with the Engine.' },
cwd: { type: 'string', description: 'stdio only: a bundle-relative directory, no .. components.' },
url: { type: 'string', description: 'streamable-http and sse: the endpoint, with no credentials, query or fragment.' },
headers: { type: 'object', maxProperties: 0, description: 'Must be empty or absent: credentials stay with the Engine.' },
toolCallTimeoutMs: { type: 'integer', minimum: 1000, maximum: 86400000, multipleOf: 1000 },
failOnStartupError: { type: 'boolean', const: false, description: 'Only false: the Engine connects lazily and reports failures through /mcp.' },
},
},
limits: ['stdio rows name a command; streamable-http and sse rows name a url.', 'Reconnect and instruction bounds belong to the Engine and are not configurable here.'],
example: { transport: 'stdio', serverName: 'docs', command: 'node', args: ['server.js'], cwd: '.' },
},
'@deepseek-ai/dsh-skill-filesystem': {
summary: 'A skill folder row, limited to reviewed roots inside the bundle.',
schema: {
type: 'object',
additionalProperties: false,
required: ['includeDefaultRoots', 'watch', 'customSkillDirs'],
properties: {
providerName: { type: 'string', maxLength: 64 },
includeDefaultRoots: { type: 'boolean', const: false, description: 'Must be false: ambient roots are not read.' },
watch: { type: 'boolean', const: false, description: 'Must be false: there is no file watcher.' },
customSkillDirs: { type: 'array', minItems: 1, maxItems: 8, items: { type: 'string' }, description: 'Bundle-relative skill roots, no duplicates.' },
},
},
limits: ['Both includeDefaultRoots and watch must be written out as false.'],
example: { includeDefaultRoots: false, watch: false, customSkillDirs: ['skills'] },
},
'@deepseek-ai/dsh-hooks-claude-code': {
summary: 'Claude Code style command hooks read from a reviewed file in the bundle.',
schema: {
type: 'object',
additionalProperties: true,
required: ['configPath'],
properties: {
configPath: { type: 'string', description: 'A regular file inside the bundle, at most 1 MiB, unchanged since review.' },
pluginRoot: { type: 'string', description: 'Absent, ".", or this reviewed bundle root.' },
defaultTimeoutMs: { type: 'integer', minimum: 1000, maximum: 86400000, multipleOf: 1000, description: 'Whole seconds, used when a hook sets none; default 600000.' },
},
},
limits: ['projectDir is refused: each process uses the current workspace.', 'Only command hooks run; non-command and asynchronous hooks fail the row.', 'At most 128 commands per row.'],
example: { configPath: 'hooks/hooks.json' },
},
'@deepseek-ai/dsh-hooks-codex': {
summary: 'Codex style command hooks read from a reviewed file in the bundle.',
schema: {
type: 'object',
additionalProperties: true,
required: ['configPath'],
properties: {
configPath: { type: 'string', description: 'A regular file inside the bundle, at most 1 MiB, unchanged since review.' },
pluginRoot: { type: 'string', description: 'Absent, ".", or this reviewed bundle root.' },
defaultTimeoutMs: { type: 'integer', minimum: 1000, maximum: 86400000, multipleOf: 1000, description: 'Whole seconds, used when a hook sets none; default 600000.' },
},
},
limits: ['projectDir is refused: each process uses the current workspace.', 'Only command hooks run; non-command and asynchronous hooks fail the row.', 'At most 128 commands per row.'],
example: { configPath: 'hooks/hooks.json' },
},
'@deepseek-ai/dsh-persona': {
summary: 'Text added before and after the Engine\'s own prompt for this plugin\'s entry.',
schema: {
type: 'object',
additionalProperties: false,
required: ['prefix'],
properties: {
prefix: { type: 'string', description: 'At most 4096 UTF-8 bytes, no control characters. Only {{model}} and {{cwd}} are expanded.' },
suffix: { type: 'string', description: 'Same limits as prefix.' },
complete: { type: 'boolean', const: false, description: 'Replacing the whole prompt belongs to the Engine.' },
includeRuntimeContext: { type: 'boolean', const: true, description: 'Suppressing the runtime context belongs to the Engine.' },
},
},
limits: ['Needs an exact Engine-selected entry scope.'],
example: { prefix: 'Answer in short sentences.' },
},
}
const THEME = {
tokens: [],
colorSchemes: ['light', 'dark'],
scheme: 'The frame sets data-theme and color-scheme on its document to the app\'s current scheme and follows changes.',
api: [
{ name: 'ctx.theme.getTheme', signature: 'getTheme(): { active: { colorScheme: \'light\' | \'dark\' } }', summary: 'The scheme in use now.' },
{ name: 'ctx.theme.overrideTokens', signature: 'overrideTokens(source: string, tokens: Record<string, { light: string, dark: string }>): () => void', summary: 'Set custom properties on the frame\'s document for each scheme. source is at most 256 characters; each name starts with -- and is at most 128 characters; each value is at most 512 characters. Returns a disposer.' },
],
notes: [
'The frame defines no named design tokens of its own. There are no --dsw-* tokens to read: a plugin that wants tokens defines them with overrideTokens and uses them in its own styles.',
'The frame\'s background is transparent and its margin is zero, so the plugin sits on the app\'s own surface.',
],
}
const SEAT_NOTES = {
'customize.plugins.detail': { drawn: true, where: 'The plugin\'s own page under Customize, Plugins.' },
'conversation.composer.dock': { drawn: false, where: 'Under the composer. The desktop app does not place it yet.' },
'conversation.chat.node': { drawn: false, where: 'Inside the conversation. The desktop app does not place it yet.' },
'workpane.panel': { drawn: false, where: 'The work pane. The desktop app does not place it yet.' },
'sidebar.section': { drawn: false, where: 'The sidebar. The desktop app does not place it yet.' },
'command.row': { drawn: false, where: 'The command list. The desktop app does not place it yet.' },
}
function between(text, open, close, what) {
const start = text.indexOf(open)
if (start < 0) throw new Error(`${what}: cannot find ${open}`)
const end = text.indexOf(close, start + open.length)
if (end < 0) throw new Error(`${what}: cannot find the end of ${open}`)
return text.slice(start + open.length, end)
}
export function parseSeatTable(source) {
const text = source.replace(/\r\n/g, '\n')
const constants = Object.fromEntries([...text.matchAll(/const ([A-Z_]+): &str = "([^"]+)";/g)].map((m) => [m[1], m[2]]))
const resolve = (literal, name) => literal !== undefined ? literal : constants[name]
const seatBlock = between(text, 'const PLUGIN_UI_SEATS: [&str; ', '];', 'seat list')
const declaredSeats = Number(seatBlock.slice(0, seatBlock.indexOf(']')))
const seatBody = seatBlock.slice(seatBlock.indexOf('= [') + 3)
const seats = [...seatBody.matchAll(/"([^"]+)"|([A-Z_]+)/g)].map((m) => resolve(m[1], m[2]))
if (seats.length !== declaredSeats || seats.some((seat) => seat === undefined)) throw new Error('the seat list does not match its declared length')
const tableBlock = between(text, 'const DSH_SEAT_TABLE: [(&str, DshSeat); ', '];\n', 'slot table')
const declaredSlots = Number(tableBlock.slice(0, tableBlock.indexOf(']')))
const tableBody = tableBlock.slice(tableBlock.indexOf('= [') + 3)
const slots = [...tableBody.matchAll(/\(\s*"([^"]+)",\s*DshSeat::(Seat|Unsupported)\(\s*(?:"([^"]*)"|([A-Z_]+))\s*\)\s*,?\s*\)/g)].map((m) => {
const value = resolve(m[3], m[4])
return m[2] === 'Seat' ? { slot: m[1], seat: value, supported: true } : { slot: m[1], seat: null, supported: false, reason: value }
})
if (slots.length !== declaredSlots) throw new Error(`the slot table declares ${declaredSlots} rows and ${slots.length} were read`)
for (const slot of slots) if (slot.supported && !seats.includes(slot.seat)) throw new Error(`slot ${slot.slot} maps to ${slot.seat}, which is not a seat`)
return { seats, slots }
}
export function parseRefusedEvents(source) {
const text = source.replace(/\r\n/g, '\n')
const body = between(text, 'const unsupportedDshLifecycle = new Set([', '])', 'refused events')
return [...body.matchAll(/'([^']+)'/g)].map((m) => m[1])
}
export function parseHookEvents(source) {
const text = source.replace(/\r\n/g, '\n')
const body = between(text, 'const EVENTS:Record<string,string>={', '}', 'hook events')
return [...body.matchAll(/([A-Za-z]+):'([a-z_]+)'/g)].map((m) => ({ event: m[1], point: m[2] }))
}
export function buildCatalog(paths = SOURCES) {
const compatibility = JSON.parse(read(paths.compatibility))
const provided = Object.entries(compatibility.provided_services).flatMap(([group, names]) => names.map((name) => ({ group, name })))
const known = new Set(Object.keys(SERVICES))
const missing = provided.filter(({ name }) => !known.has(name)).map(({ name }) => name)
if (missing.length) throw new Error(`compatibility.json provides services with no entry in the method table: ${missing.join(', ')}`)
const extra = [...known].filter((name) => !provided.some((entry) => entry.name === name))
if (extra.length) throw new Error(`the method table names services the host does not provide: ${extra.join(', ')}`)
const services = provided.map(({ group, name }) => {
const entry = SERVICES[name]
return {
key: name,
group,
summary: entry.summary,
availability: entry.availability,
availabilityText: AVAILABILITY[entry.availability],
methods: entry.methods,
}
})
const refusedEvents = parseRefusedEvents(read(paths.hostRoot))
const hookEvents = parseHookEvents(read(paths.shellHooks))
const events = [
...EVENTS_SUPPORTED,
...refusedEvents.map((name) => ({ name, status: 'refused', summary: REFUSED_EVENT_REASON })),
]
const rowPackages = Object.keys(compatibility.row_bridges)
const unknownRows = rowPackages.filter((name) => !ROW_PACKAGES[name])
const staleRows = Object.keys(ROW_PACKAGES).filter((name) => !rowPackages.includes(name))
if (unknownRows.length || staleRows.length) throw new Error(`row packages differ from compatibility.json: missing ${unknownRows.join(', ')}; stale ${staleRows.join(', ')}`)
const rows = rowPackages.map((name) => ({
id: `row:${name}`,
name,
bridge: compatibility.row_bridges[name],
summary: ROW_PACKAGES[name].summary,
limits: ROW_PACKAGES[name].limits,
example: ROW_PACKAGES[name].example,
...(name.includes('hooks-') ? { hookEvents } : {}),
schema: { $schema: 'https://json-schema.org/draft/2020-12/schema', title: name, ...ROW_PACKAGES[name].schema },
}))
const table = parseSeatTable(read(paths.seatTable))
const unknownSeatNotes = table.seats.filter((seat) => !SEAT_NOTES[seat])
const staleSeatNotes = Object.keys(SEAT_NOTES).filter((seat) => !table.seats.includes(seat))
if (unknownSeatNotes.length || staleSeatNotes.length) throw new Error(`seat notes differ from the Engine seat list: missing ${unknownSeatNotes.join(', ')}; stale ${staleSeatNotes.join(', ')}`)
const seats = table.seats.map((seat) => ({ seat, drawn: SEAT_NOTES[seat].drawn, where: SEAT_NOTES[seat].where }))
return {
version: 1,
targetDshVersion: compatibility.target_dsh_version,
host: {
services,
refusedServices: compatibility.refused_services,
events,
cordisInternalEvents: 'Cordis fires its own internal/* lifecycle events. They are not part of this host\'s supported contract and are not listed.',
config: rows,
},
client: {
seats,
slots: table.slots,
theme: THEME,
},
}
}
export function renderCatalog(catalog) {
return `${JSON.stringify(catalog, null, 2)}\n`
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const text = renderCatalog(buildCatalog())
if (process.argv.includes('--check')) {
const current = existsOrEmpty(SOURCES.catalog)
if (current !== text) {
process.stderr.write('inspect-catalog.json is out of date: run node scripts/gen-inspect-catalog.mjs\n')
process.exit(1)
}
} else {
writeFileSync(SOURCES.catalog, text)
}
}
function existsOrEmpty(path) {
try {
return read(path)
} catch {
return ''
}
}
@@ -1,918 +0,0 @@
{
"version": 1,
"targetDshVersion": "0.2.2-rc.1",
"host": {
"services": [
{
"key": "tools",
"group": "dsh",
"summary": "The tool registry: a plugin adds model-callable tools. The Engine owns calling, approval and results.",
"availability": "always",
"availabilityText": "Available to every plugin that injects it.",
"methods": [
{
"name": "register",
"signature": "register(definition: ToolDefinition): () => void",
"summary": "Register one tool built with defineTool from @deepseek-ai/dsh-tools. The definition must stay inside the supported JSON Schema subset (type, oneOf, properties, required, additionalProperties, items, enum, const). A second argument is refused. Returns an idempotent disposer."
},
{
"name": "get",
"signature": "get(name: string, agent: Agent): { name: string } | undefined",
"summary": "Whether the calling agent has a tool by this name. Answers only inside a live tool call, for that call's own agent."
}
]
},
{
"key": "commands",
"group": "dsh",
"summary": "Slash commands a person types. A handler runs only when the person types the command.",
"availability": "always",
"availabilityText": "Available to every plugin that injects it.",
"methods": [
{
"name": "register",
"signature": "register(definition: { name: string, description: string, argumentHint?: string, handler(call: { args: string, signal: AbortSignal }): CommandResult }): () => void",
"summary": "Register one slash command. Names start with a lower case letter, then lower case letters, digits, _ and -, at most 64 characters; at most 64 commands per plugin. A name that clashes with a built-in command gets the alias plugin-<plugin-slug>-<command>, and is refused if that alias is invalid or also taken. A name another plugin already registered is refused, so pick a plugin-specific name. Returns an idempotent disposer."
}
]
},
{
"key": "storage",
"group": "dsh",
"summary": "Owner-local JSON persistence under the data folder the Engine assigned. It holds no session history and no secrets.",
"availability": "always",
"availabilityText": "Available to every plugin that injects it.",
"methods": [
{
"name": "get",
"signature": "get(key: string): Promise<Json | undefined>",
"summary": "Read one stored value."
},
{
"name": "set",
"signature": "set(key: string, value: Json): Promise<void>",
"summary": "Store one JSON value, within the bounded key, value and owner limits."
},
{
"name": "delete",
"signature": "delete(key: string): Promise<void>",
"summary": "Remove one stored value."
}
]
},
{
"key": "skills",
"group": "dsh",
"summary": "Skill roots: reviewed folders of skills added to the Engine's skill catalog.",
"availability": "always",
"availabilityText": "Available to every plugin that injects it.",
"methods": [
{
"name": "registerRoot",
"signature": "registerRoot(definition: { path: string }): () => void",
"summary": "Add a bundle-relative folder of SKILL.md packages. At most 8 roots per plugin. Absolute paths, links, empty components, . and .. are refused."
}
]
},
{
"key": "sessions",
"group": "dsh",
"summary": "The session of the calling tool invocation.",
"availability": "tool-invocation",
"availabilityText": "Answers only inside a live tool call of the plugin.",
"methods": [
{
"name": "flush",
"signature": "flush(session: Session): Promise<boolean>",
"summary": "Ask the Engine to persist the committed prefix of the exact session of this tool call. Nothing else on sessions is available."
}
]
},
{
"key": "userQuestions",
"group": "dsh",
"summary": "Questions put to the person through the Engine's own question card.",
"availability": "tool-invocation",
"availabilityText": "Answers only inside a live tool call of the plugin.",
"methods": [
{
"name": "ask",
"signature": "ask(request: { agent: Agent, questions: Question[], signal?: AbortSignal }): Promise<Answers>",
"summary": "Ask the person typed questions during a live root tool call. Timed or continued questions (wait) are not supported."
}
]
},
{
"key": "subagents",
"group": "dsh",
"summary": "One independent child agent a root tool call may start.",
"availability": "tool-invocation",
"availabilityText": "Answers only inside a live tool call of the plugin.",
"methods": [
{
"name": "getProvider",
"signature": "getProvider(id: 'spawn'): Provider",
"summary": "Describe the one provider: a fresh child with no inherited conversation."
},
{
"name": "startActivation",
"signature": "startActivation(options: { provider: 'spawn', childId: string, request: { parent: Agent, prompt: TextBlock[], agentOptions?: { model?: string } }, signal?: AbortSignal, delivery?: 'caller' | 'parent' }): Promise<{ childId: string, messageId: string, result: Promise<ChildResult>, dispose(): Promise<void> }>",
"summary": "Start the child through the Engine's approval gate. Resolves to the activation handle { childId, messageId, result, dispose }. Prompts are text only; only the model option is accepted. Needs the live calling agent as parent and is not available inside a child."
},
{
"name": "result",
"signature": "activation.result: Promise<{ stopReason: string, output: { type: 'text', text: string }[], diagnostic?: string }>",
"summary": "A getter, not a method: read the property, do not call it. Resolves when the child finishes, with its stop reason and its text output blocks; diagnostic notes dropped non-text blocks."
},
{
"name": "dispose",
"signature": "activation.dispose(): Promise<void>",
"summary": "Stop that exact child and its own descendants."
}
]
},
{
"key": "agents",
"group": "dsh",
"summary": "Reserved. The host offers no methods on agents.",
"availability": "none",
"availabilityText": "No methods.",
"methods": []
},
{
"key": "systemPrompt",
"group": "dsh",
"summary": "The child agent's persona slot, reachable only inside an agent/created listener.",
"availability": "child-agent-hook",
"availabilityText": "Answers only inside an agent/created listener, on the child agent.",
"methods": [
{
"name": "section",
"signature": "agent.ctx.systemPrompt.section({ name: 'deployment:persona-prefix', order: agent.ctx.systemPrompt.getSectionOrder('DEPLOYMENT_PERSONA_PREFIX'), text: string }): () => void",
"summary": "Set the child's persona text, at most 32 KiB, once. name must be the string deployment:persona-prefix (the PERSONA_PREFIX_SECTION value) and order must be what getSectionOrder('DEPLOYMENT_PERSONA_PREFIX') returns, which is 0. Anything else is refused. Called anywhere else it fails. The Engine's own system prompt cannot be replaced."
}
]
},
{
"key": "logger",
"group": "cordis",
"summary": "Cordis logging. Every line becomes a host log notification attributed to the plugin.",
"availability": "always",
"availabilityText": "Available to every plugin that injects it.",
"methods": [
{
"name": "logger",
"signature": "ctx.logger(name?: string): Logger",
"summary": "A named logger with debug, info, warn and error. The host reports error and warn as such and everything else as info."
}
]
},
{
"key": "events",
"group": "cordis",
"summary": "Cordis event dispatch. Query the host Event provider for the events this host fires.",
"availability": "always",
"availabilityText": "Available to every plugin that injects it.",
"methods": [
{
"name": "on",
"signature": "ctx.on(name: string, listener: Function, options?: boolean | { prepend?: boolean, global?: boolean }): () => boolean",
"summary": "Listen to an event. The host refuses five lifecycle events and limits the options of two (see the Event provider)."
},
{
"name": "once",
"signature": "ctx.once(name: string, listener: Function, options?: boolean | { prepend?: boolean, global?: boolean }): () => boolean",
"summary": "Listen to an event once."
},
{
"name": "emit",
"signature": "ctx.emit(name: string, ...args: any[]): void",
"summary": "Dispatch an event to its listeners."
}
]
},
{
"key": "reflect",
"group": "cordis",
"summary": "Cordis service reflection. A plugin may provide its own services but not the Engine's.",
"availability": "always",
"availabilityText": "Available to every plugin that injects it.",
"methods": [
{
"name": "provide",
"signature": "ctx.reflect.provide(name: string, value?: any): () => void",
"summary": "Provide a service under a name. Names in refusedServices are refused: the Engine owns them."
},
{
"name": "get",
"signature": "ctx.reflect.get(name: string, strict?: boolean): any",
"summary": "Read a provided service."
}
]
},
{
"key": "registry",
"group": "cordis",
"summary": "Cordis plugin registry.",
"availability": "always",
"availabilityText": "Available to every plugin that injects it.",
"methods": [
{
"name": "get",
"signature": "ctx.registry.get(plugin: Plugin): Runtime | undefined",
"summary": "Look up the runtime record of a plugin."
},
{
"name": "resolve",
"signature": "ctx.registry.resolve(plugin: Plugin): Function | undefined",
"summary": "Resolve a plugin shape to its callback."
}
]
},
{
"key": "prompt",
"group": "codewhale",
"summary": "Attributed sections of the Engine's runtime prompt. A plugin cannot replace the system prompt.",
"availability": "always",
"availabilityText": "Available to every plugin that injects it.",
"methods": [
{
"name": "registerSection",
"signature": "registerSection(definition: { id: string, text: string, interpolate?: 'model-cwd' }): () => void",
"summary": "Contribute a plain-text section of at most 4 KiB; at most 128 sections per plugin. The id is lower case letters, digits, _ and -, starts with a letter, at most 64 characters. With interpolate set, only {{model}} and {{cwd}} are expanded."
}
]
},
{
"key": "avatars",
"group": "codewhale",
"summary": "Avatar packs for the terminal.",
"availability": "always",
"availabilityText": "Available to every plugin that injects it.",
"methods": [
{
"name": "registerPack",
"signature": "registerPack(definition: { path: string }): () => void",
"summary": "Register a reviewed bundle-relative avatar pack: a path ending in .json, at most 4 packs per plugin."
}
]
},
{
"key": "mcp",
"group": "codewhale",
"summary": "Proposals of MCP servers. The Engine owns connection, credentials and tool calls.",
"availability": "native-review",
"availabilityText": "Available to a reviewed native plugin.",
"methods": [
{
"name": "registerServer",
"signature": "registerServer(definition: { serverName: string, server: Record<string, unknown> }): () => void",
"summary": "Propose one server. serverName is a letter or digit followed by up to 31 letters, digits, _ or -. headers and env must stay empty and the url may carry no credentials, query or fragment. At most 64 servers per plugin; at most 64 KiB per definition."
}
]
},
{
"key": "shellHooks",
"group": "codewhale",
"summary": "Claude Code and Codex style command hooks, run by the Engine.",
"availability": "native-review",
"availabilityText": "Available to a reviewed native plugin.",
"methods": [
{
"name": "register",
"signature": "register(definition: { dialect: 'claude-code' | 'codex', point: string, matcher?: string, hook: { event: string, command: string, timeout_secs: number, background: false, continue_on_error: false, name: string } }): () => void",
"summary": "Register one command hook for a reviewed native plugin. Forced continuation, observer steering, non-command and asynchronous hooks are not supported."
}
]
},
{
"key": "remote",
"group": "codewhale",
"summary": "Remote pairing, served by the Engine.",
"availability": "review-grant",
"availabilityText": "Served by the Engine only when the plugin's review granted it.",
"methods": [
{
"name": "status",
"signature": "status(): Promise<Status>",
"summary": "Whether remote access is on."
},
{
"name": "pair",
"signature": "pair(options?: { label?: string }): Promise<Pairing>",
"summary": "Start a pairing."
},
{
"name": "clients",
"signature": "clients(): Promise<Client[]>",
"summary": "List paired clients."
},
{
"name": "revoke",
"signature": "revoke(id: string): Promise<void>",
"summary": "Revoke one grant by the id pair returned."
}
]
}
],
"refusedServices": [
"shellHooks",
"loader",
"approval",
"agents",
"subagents",
"sessions",
"llm",
"sandboxPolicy",
"credentials",
"fs",
"subprocess",
"systemPrompt",
"runtimeLoop",
"tools",
"commands",
"prompt",
"storage",
"skills",
"avatars",
"mcp",
"logger",
"userQuestions"
],
"events": [
{
"name": "tools/pre-execute",
"status": "supported",
"mode": "ordered listeners, native hooks first, then plugin listeners in registration order",
"signature": "ctx.on('tools/pre-execute', async (exec: { name: string, callId: string, arguments: object, signal: AbortSignal, workspace: string, mode: string, model: string }, next: () => Promise<Abstention>) => Verdict | undefined)",
"summary": "Programmable tool admission for a reviewed native plugin. The frozen call view has no session, agent, tool or approval handle. Return { kind: 'deny', reason }, { kind: 'ask', reason? }, { kind: 'revise', input }, { kind: 'annotate', text } or { kind: 'abstain' }; undefined abstains. A malformed answer, error or timeout fails the call closed. The listener batch has a five second deadline.",
"options": "prepend and global are refused."
},
{
"name": "agent/created",
"status": "supported",
"mode": "listeners of the owning plugin only",
"signature": "ctx.on('agent/created', async ({ agent }: { agent: { id: string, ctx: { systemPrompt: { getSectionOrder(name), section(section) }, tools: { restrict(filter: { deny: string[] }) } } } }) => void)",
"summary": "Fires for a child agent that this plugin's own subagents.startActivation creates, before the child starts. The listener may set the child's persona and deny tools to it; the window closes when the listeners return.",
"options": "global is refused."
},
{
"name": "agent/pre-step",
"status": "refused",
"summary": "Not projected by this host: no core projection exists, so a listener would never fire."
},
{
"name": "agent/turn-stopping",
"status": "refused",
"summary": "Not projected by this host: no core projection exists, so a listener would never fire."
},
{
"name": "tools/post-execute",
"status": "refused",
"summary": "Not projected by this host: no core projection exists, so a listener would never fire."
},
{
"name": "subagent/start",
"status": "refused",
"summary": "Not projected by this host: no core projection exists, so a listener would never fire."
},
{
"name": "subagent/end",
"status": "refused",
"summary": "Not projected by this host: no core projection exists, so a listener would never fire."
}
],
"cordisInternalEvents": "Cordis fires its own internal/* lifecycle events. They are not part of this host's supported contract and are not listed.",
"config": [
{
"id": "row:@deepseek-ai/dsh-mcp-client",
"name": "@deepseek-ai/dsh-mcp-client",
"bridge": "mcp",
"summary": "An MCP server row. The Engine proposes the server and owns the connection.",
"limits": [
"stdio rows name a command; streamable-http and sse rows name a url.",
"Reconnect and instruction bounds belong to the Engine and are not configurable here."
],
"example": {
"transport": "stdio",
"serverName": "docs",
"command": "node",
"args": [
"server.js"
],
"cwd": "."
},
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "@deepseek-ai/dsh-mcp-client",
"type": "object",
"additionalProperties": false,
"required": [
"transport",
"serverName"
],
"properties": {
"transport": {
"type": "string",
"enum": [
"stdio",
"streamable-http",
"sse"
]
},
"serverName": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$"
},
"command": {
"type": "string",
"description": "stdio only: the program to run."
},
"args": {
"type": "array",
"items": {
"type": "string"
},
"description": "stdio only."
},
"env": {
"type": "object",
"maxProperties": 0,
"description": "Must be empty or absent: credentials stay with the Engine."
},
"cwd": {
"type": "string",
"description": "stdio only: a bundle-relative directory, no .. components."
},
"url": {
"type": "string",
"description": "streamable-http and sse: the endpoint, with no credentials, query or fragment."
},
"headers": {
"type": "object",
"maxProperties": 0,
"description": "Must be empty or absent: credentials stay with the Engine."
},
"toolCallTimeoutMs": {
"type": "integer",
"minimum": 1000,
"maximum": 86400000,
"multipleOf": 1000
},
"failOnStartupError": {
"type": "boolean",
"const": false,
"description": "Only false: the Engine connects lazily and reports failures through /mcp."
}
}
}
},
{
"id": "row:@deepseek-ai/dsh-skill-filesystem",
"name": "@deepseek-ai/dsh-skill-filesystem",
"bridge": "skills",
"summary": "A skill folder row, limited to reviewed roots inside the bundle.",
"limits": [
"Both includeDefaultRoots and watch must be written out as false."
],
"example": {
"includeDefaultRoots": false,
"watch": false,
"customSkillDirs": [
"skills"
]
},
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "@deepseek-ai/dsh-skill-filesystem",
"type": "object",
"additionalProperties": false,
"required": [
"includeDefaultRoots",
"watch",
"customSkillDirs"
],
"properties": {
"providerName": {
"type": "string",
"maxLength": 64
},
"includeDefaultRoots": {
"type": "boolean",
"const": false,
"description": "Must be false: ambient roots are not read."
},
"watch": {
"type": "boolean",
"const": false,
"description": "Must be false: there is no file watcher."
},
"customSkillDirs": {
"type": "array",
"minItems": 1,
"maxItems": 8,
"items": {
"type": "string"
},
"description": "Bundle-relative skill roots, no duplicates."
}
}
}
},
{
"id": "row:@deepseek-ai/dsh-hooks-claude-code",
"name": "@deepseek-ai/dsh-hooks-claude-code",
"bridge": "hooks-claude-code",
"summary": "Claude Code style command hooks read from a reviewed file in the bundle.",
"limits": [
"projectDir is refused: each process uses the current workspace.",
"Only command hooks run; non-command and asynchronous hooks fail the row.",
"At most 128 commands per row."
],
"example": {
"configPath": "hooks/hooks.json"
},
"hookEvents": [
{
"event": "SessionStart",
"point": "session_start"
},
{
"event": "UserPromptSubmit",
"point": "message_submit"
},
{
"event": "PreToolUse",
"point": "tool_call_before"
},
{
"event": "PostToolUse",
"point": "tool_call_after"
},
{
"event": "Stop",
"point": "turn_end"
},
{
"event": "SubagentStart",
"point": "subagent_spawn"
},
{
"event": "SubagentStop",
"point": "subagent_complete"
}
],
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "@deepseek-ai/dsh-hooks-claude-code",
"type": "object",
"additionalProperties": true,
"required": [
"configPath"
],
"properties": {
"configPath": {
"type": "string",
"description": "A regular file inside the bundle, at most 1 MiB, unchanged since review."
},
"pluginRoot": {
"type": "string",
"description": "Absent, \".\", or this reviewed bundle root."
},
"defaultTimeoutMs": {
"type": "integer",
"minimum": 1000,
"maximum": 86400000,
"multipleOf": 1000,
"description": "Whole seconds, used when a hook sets none; default 600000."
}
}
}
},
{
"id": "row:@deepseek-ai/dsh-hooks-codex",
"name": "@deepseek-ai/dsh-hooks-codex",
"bridge": "hooks-codex",
"summary": "Codex style command hooks read from a reviewed file in the bundle.",
"limits": [
"projectDir is refused: each process uses the current workspace.",
"Only command hooks run; non-command and asynchronous hooks fail the row.",
"At most 128 commands per row."
],
"example": {
"configPath": "hooks/hooks.json"
},
"hookEvents": [
{
"event": "SessionStart",
"point": "session_start"
},
{
"event": "UserPromptSubmit",
"point": "message_submit"
},
{
"event": "PreToolUse",
"point": "tool_call_before"
},
{
"event": "PostToolUse",
"point": "tool_call_after"
},
{
"event": "Stop",
"point": "turn_end"
},
{
"event": "SubagentStart",
"point": "subagent_spawn"
},
{
"event": "SubagentStop",
"point": "subagent_complete"
}
],
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "@deepseek-ai/dsh-hooks-codex",
"type": "object",
"additionalProperties": true,
"required": [
"configPath"
],
"properties": {
"configPath": {
"type": "string",
"description": "A regular file inside the bundle, at most 1 MiB, unchanged since review."
},
"pluginRoot": {
"type": "string",
"description": "Absent, \".\", or this reviewed bundle root."
},
"defaultTimeoutMs": {
"type": "integer",
"minimum": 1000,
"maximum": 86400000,
"multipleOf": 1000,
"description": "Whole seconds, used when a hook sets none; default 600000."
}
}
}
},
{
"id": "row:@deepseek-ai/dsh-persona",
"name": "@deepseek-ai/dsh-persona",
"bridge": "persona",
"summary": "Text added before and after the Engine's own prompt for this plugin's entry.",
"limits": [
"Needs an exact Engine-selected entry scope."
],
"example": {
"prefix": "Answer in short sentences."
},
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "@deepseek-ai/dsh-persona",
"type": "object",
"additionalProperties": false,
"required": [
"prefix"
],
"properties": {
"prefix": {
"type": "string",
"description": "At most 4096 UTF-8 bytes, no control characters. Only {{model}} and {{cwd}} are expanded."
},
"suffix": {
"type": "string",
"description": "Same limits as prefix."
},
"complete": {
"type": "boolean",
"const": false,
"description": "Replacing the whole prompt belongs to the Engine."
},
"includeRuntimeContext": {
"type": "boolean",
"const": true,
"description": "Suppressing the runtime context belongs to the Engine."
}
}
}
}
]
},
"client": {
"seats": [
{
"seat": "customize.plugins.detail",
"drawn": true,
"where": "The plugin's own page under Customize, Plugins."
},
{
"seat": "conversation.composer.dock",
"drawn": false,
"where": "Under the composer. The desktop app does not place it yet."
},
{
"seat": "conversation.chat.node",
"drawn": false,
"where": "Inside the conversation. The desktop app does not place it yet."
},
{
"seat": "workpane.panel",
"drawn": false,
"where": "The work pane. The desktop app does not place it yet."
},
{
"seat": "sidebar.section",
"drawn": false,
"where": "The sidebar. The desktop app does not place it yet."
},
{
"seat": "command.row",
"drawn": false,
"where": "The command list. The desktop app does not place it yet."
}
],
"slots": [
{
"slot": "plugins.bundle.config",
"seat": "customize.plugins.detail",
"supported": true
},
{
"slot": "plugins.row.config",
"seat": "customize.plugins.detail",
"supported": true
},
{
"slot": "plugins.tab",
"seat": "customize.plugins.detail",
"supported": true
},
{
"slot": "plugins.detail.section",
"seat": "customize.plugins.detail",
"supported": true
},
{
"slot": "settings.section",
"seat": "customize.plugins.detail",
"supported": true
},
{
"slot": "settings.plugin.item",
"seat": "customize.plugins.detail",
"supported": true
},
{
"slot": "settings.general.item",
"seat": "customize.plugins.detail",
"supported": true
},
{
"slot": "settings.plugins.tab",
"seat": "customize.plugins.detail",
"supported": true
},
{
"slot": "conversation.chat.node",
"seat": "conversation.chat.node",
"supported": true
},
{
"slot": "conversation.chat.turnTail",
"seat": "conversation.chat.node",
"supported": true
},
{
"slot": "conversation.chat.assistant-actions",
"seat": "conversation.chat.node",
"supported": true
},
{
"slot": "conversation.chat.text-interaction",
"seat": "conversation.chat.node",
"supported": true
},
{
"slot": "conversation.user.text.node",
"seat": "conversation.chat.node",
"supported": true
},
{
"slot": "tool.call.toolview",
"seat": "conversation.chat.node",
"supported": true
},
{
"slot": "conversation.view",
"seat": "conversation.chat.node",
"supported": true
},
{
"slot": "conversation.input.dock",
"seat": "conversation.composer.dock",
"supported": true
},
{
"slot": "conversation.input.overlay",
"seat": "conversation.composer.dock",
"supported": true
},
{
"slot": "conversation.input.right",
"seat": "conversation.composer.dock",
"supported": true
},
{
"slot": "conversation.composer.dock",
"seat": "conversation.composer.dock",
"supported": true
},
{
"slot": "sidebar.right.pane.tab",
"seat": "workpane.panel",
"supported": true
},
{
"slot": "main.conversation",
"seat": "workpane.panel",
"supported": true
},
{
"slot": "main.page-menu",
"seat": "workpane.panel",
"supported": true
},
{
"slot": "sidebar.panellist",
"seat": "sidebar.section",
"supported": true
},
{
"slot": "sidebar.workspaces",
"seat": "sidebar.section",
"supported": true
},
{
"slot": "sidebar.footer.action",
"seat": "sidebar.section",
"supported": true
},
{
"slot": "sidebar.session.row.leading",
"seat": "sidebar.section",
"supported": true
},
{
"slot": "sidebar.workspaces.session.row.action",
"seat": "sidebar.section",
"supported": true
},
{
"slot": "conversation.session.header.actions",
"seat": null,
"supported": false,
"reason": "no ADE seat yet"
},
{
"slot": "conversation.session.header.utilities",
"seat": null,
"supported": false,
"reason": "no ADE seat yet"
},
{
"slot": "contextMenu.icon",
"seat": null,
"supported": false,
"reason": "no ADE seat yet"
}
],
"theme": {
"tokens": [],
"colorSchemes": [
"light",
"dark"
],
"scheme": "The frame sets data-theme and color-scheme on its document to the app's current scheme and follows changes.",
"api": [
{
"name": "ctx.theme.getTheme",
"signature": "getTheme(): { active: { colorScheme: 'light' | 'dark' } }",
"summary": "The scheme in use now."
},
{
"name": "ctx.theme.overrideTokens",
"signature": "overrideTokens(source: string, tokens: Record<string, { light: string, dark: string }>): () => void",
"summary": "Set custom properties on the frame's document for each scheme. source is at most 256 characters; each name starts with -- and is at most 128 characters; each value is at most 512 characters. Returns a disposer."
}
],
"notes": [
"The frame defines no named design tokens of its own. There are no --dsw-* tokens to read: a plugin that wants tokens defines them with overrideTokens and uses them in its own styles.",
"The frame's background is transparent and its margin is zero, so the plugin sits on the app's own surface."
]
}
}
}
@@ -1,245 +0,0 @@
import { isJson } from '../json.ts'
import { assertSupportedJsonSchema, validateJsonSchemaValue } from './dsh-tools-compat.js'
import catalogData from './inspect-catalog.json' with { type: 'json' }
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
export type InspectPlatform = 'host' | 'client'
export interface InspectMethod {
name: string
description: string
inputSchema: JsonValue
outputSchema: JsonValue
}
export interface InspectProviderView {
platform: InspectPlatform
id: string
description: string
methods: InspectMethod[]
}
export interface InstalledRow {
id: string
name: string
state: string
}
export interface ClientState {
connected: boolean
drawnSeats?: readonly string[]
}
export interface InspectSources {
tools?: (agent: unknown, signal: AbortSignal) => Promise<readonly JsonValue[]> | readonly JsonValue[]
installed?: (signal: AbortSignal) => Promise<readonly InstalledRow[]> | readonly InstalledRow[]
client?: () => ClientState
}
export interface CordisInspect {
list(): InspectProviderView[]
query(platform: string, provider: string, method: string, input: JsonValue | undefined, agent?: unknown, signal?: AbortSignal): Promise<JsonValue>
}
interface Provider {
platform: InspectPlatform
id: string
description: string
methods: InspectMethod[]
run(method: string, input: Record<string, JsonValue>, agent: unknown, signal: AbortSignal): Promise<any> | any
}
const catalog = catalogData as any
const EMPTY_INPUT: JsonValue = { type: 'object', properties: {}, additionalProperties: false }
const ANY_OUTPUT: JsonValue = { description: 'JSON data owned by this inspect provider.' }
const NO_CLIENT = 'No client is connected. Plugin screens are drawn by the desktop app; in a terminal nothing is drawn, so every slot reports drawn: false.'
const exactInput = (field: string, description: string): JsonValue => ({
type: 'object',
properties: { [field]: { type: 'string', description } },
additionalProperties: false,
})
const method = (name: string, description: string, inputSchema: JsonValue = EMPTY_INPUT, outputSchema: JsonValue = ANY_OUTPUT): InspectMethod =>
({ name, description, inputSchema, outputSchema })
const copy = <T>(value: T): T => JSON.parse(JSON.stringify(value))
function serviceView(service: any): JsonValue {
return {
key: service.key,
group: service.group,
summary: service.summary,
availability: service.availability,
availabilityText: service.availabilityText,
methods: service.methods,
}
}
function serviceProvider(): Provider {
return {
platform: 'host', id: 'Service',
description: 'The services this host gives plugins, with their methods, and the services a plugin may not provide. A name that is not listed does not exist here.',
methods: [method('listService', 'Without input: the directory of services with one-line summaries and method signatures. With service: that service\'s exact contract, with availability. These are services a plugin injects, not DSH\'s full catalogue.',
exactInput('service', 'Exact service key. Omit it for the directory.'))],
run(_name, input) {
const key = input.service
if (key === undefined) {
return {
services: catalog.host.services.map((s: any) => ({ key: s.key, group: s.group, summary: s.summary, availability: s.availability, signatures: s.methods.map((m: any) => m.signature) })),
refusedServices: catalog.host.refusedServices,
note: 'refusedServices are names a plugin may not provide: the Engine owns them. Service methods named by the host Event and Config providers are separate.',
targetDshVersion: catalog.targetDshVersion,
}
}
const found = catalog.host.services.find((s: any) => s.key === key)
if (!found) throw new Error(`Host service "${String(key)}" is not provided by this host. Call without service for the directory.`)
return { service: serviceView(found), targetDshVersion: catalog.targetDshVersion }
},
}
}
function eventProvider(): Provider {
return {
platform: 'host', id: 'Event',
description: 'The events a plugin may listen to in this host, and the ones the host refuses.',
methods: [method('listEvents', 'Without input: the directory of events with status supported or refused. With event: its exact contract.',
exactInput('event', 'Exact event name. Omit it for the directory.'))],
run(_name, input) {
const key = input.event
if (key === undefined) {
return {
events: catalog.host.events.map((e: any) => ({ name: e.name, status: e.status, summary: e.summary })),
note: catalog.host.cordisInternalEvents,
}
}
const found = catalog.host.events.find((e: any) => e.name === key)
if (!found) throw new Error(`Host event "${String(key)}" is not known to this host. Call without event for the directory.`)
return { event: found }
},
}
}
const CONFIG_INPUT: JsonValue = {
type: 'object',
properties: {
entry: { type: 'string', description: 'Exact entry id from the directory; returns that entry\'s Config schema.' },
name: { type: 'string', description: 'Exact package or plugin name; limits the directory to it.' },
offset: { type: 'integer', description: 'Zero-based directory offset; defaults to 0.' },
limit: { type: 'integer', description: 'Directory page size, from 1 to 100; defaults to 25.' },
},
additionalProperties: false,
}
function configProvider(sources: InspectSources): Provider {
return {
platform: 'host', id: 'Config',
description: 'Config a composition row may carry: the packages a row may name, each with the config it accepts, and the plugins installed on this host with their state.',
methods: [method('listConfigs', 'Without entry: a page of the directory (row packages first, then installed plugins) with total and nextOffset. With entry: one row package\'s exact JSON Schema, limits and a valid example. Installed plugins are listed with their state; their own config is not described here.', CONFIG_INPUT)],
async run(_name, input, _agent, signal) {
const rows: any[] = catalog.host.config.map((r: any) => ({ id: r.id, name: r.name, kind: 'row-package', status: 'schema', bridge: r.bridge, summary: r.summary }))
const installed = sources.installed ? await sources.installed(signal) : []
for (const row of installed) rows.push({ id: `plugin:${row.id}`, name: row.name, kind: 'installed-plugin', status: row.state })
if (input.entry !== undefined) {
const pkg = catalog.host.config.find((r: any) => r.id === input.entry)
if (pkg) return { entry: pkg.id, name: pkg.name, kind: 'row-package', bridge: pkg.bridge, summary: pkg.summary, limits: pkg.limits, example: pkg.example, ...pkg.hookEvents ? { hookEvents: pkg.hookEvents } : {}, schema: pkg.schema }
const live = rows.find((r) => r.id === input.entry)
if (live) return { entry: live.id, name: live.name, kind: live.kind, status: live.status, schema: null, note: 'An installed plugin\'s own Config is not described by this host.' }
throw new Error(`Host Config entry "${String(input.entry)}" is not listed. Call without entry for the directory.`)
}
const filtered = input.name === undefined ? rows : rows.filter((r) => r.name === input.name)
const offset = typeof input.offset === 'number' ? input.offset : 0
const limit = typeof input.limit === 'number' ? input.limit : 25
if (offset < 0 || limit < 1 || limit > 100) throw new Error('Host Config directory needs offset >= 0 and 1 <= limit <= 100')
const page = filtered.slice(offset, offset + limit)
const next = offset + page.length
return { entries: page, total: filtered.length, ...next < filtered.length ? { nextOffset: next } : {} }
},
}
}
function toolProvider(sources: InspectSources): Provider | undefined {
const source = sources.tools
if (!source) return undefined
return {
platform: 'host', id: 'Tool',
description: 'Tools visible to the requesting agent.',
methods: [method('listTools', 'Return every tool schema currently callable by this agent.')],
async run(_name, _input, agent, signal) {
return { tools: [...await source(agent, signal)] }
},
}
}
const SUBTREE_INPUT = exactInput('root', 'Exact slot name to select. Omit it for every slot.')
function slotsProvider(sources: InspectSources): Provider {
return {
platform: 'client', id: 'Slots',
description: 'The DSH slot names this host accepts, the seat each maps to, and whether the connected client draws that seat.',
methods: [method('listSubTree', 'Return every slot with its seat and drawn state; with root, also that slot selected. Slots are static names here, not a live tree, and their props are not described by this host: a plugin registers on a slot, the Engine serves it on the mapped seat, and the client draws only the seats it places.', SUBTREE_INPUT)],
run(_name, input) {
const state = sources.client?.() ?? { connected: false }
const drawn = new Set(state.connected ? state.drawnSeats ?? catalog.client.seats.filter((s: any) => s.drawn).map((s: any) => s.seat) : [])
const slots = catalog.client.slots.map((s: any) => ({
type: 'slot', name: s.slot, seat: s.seat, supported: s.supported,
drawn: s.supported && drawn.has(s.seat),
...s.reason ? { reason: s.reason } : {},
}))
const seats = catalog.client.seats.map((s: any) => ({ seat: s.seat, drawn: drawn.has(s.seat), where: s.where }))
const out: Record<string, JsonValue> = {
client: state.connected ? { connected: true } : { connected: false, message: NO_CLIENT },
seats, trees: slots, referencedTypes: [],
}
if (input.root !== undefined) {
const selected = slots.find((s: any) => s.name === input.root)
out.requestedRoot = { name: input.root, available: selected !== undefined }
if (selected) out.selected = selected
}
return out
},
}
}
function themeProvider(): Provider {
return {
platform: 'client', id: 'Theme',
description: 'What the plugin frame provides for theming.',
methods: [method('listTokens', 'Return the named tokens the frame provides (none), the colour schemes, and the theme calls a plugin has.')],
run() {
return { tokens: catalog.client.theme.tokens, colorSchemes: catalog.client.theme.colorSchemes, scheme: catalog.client.theme.scheme, api: catalog.client.theme.api, notes: catalog.client.theme.notes, referencedTypes: [] }
},
}
}
export function createCordisInspect(sources: InspectSources = {}): CordisInspect {
const providers: Provider[] = [serviceProvider(), eventProvider(), configProvider(sources), toolProvider(sources), slotsProvider(sources), themeProvider()]
.filter((p): p is Provider => p !== undefined)
for (const provider of providers) {
if (provider.id.trim() === '' || provider.description.trim() === '') throw new Error('Cordis inspect provider needs an id and a description')
for (const m of provider.methods) {
assertSupportedJsonSchema(m.inputSchema)
assertSupportedJsonSchema(m.outputSchema)
}
}
const label = (platform: InspectPlatform) => platform === 'host' ? 'Host' : 'Client'
return {
list() {
return providers.map((p) => ({ platform: p.platform, id: p.id, description: p.description, methods: copy(p.methods) }))
},
async query(platform, providerId, methodName, input, agent, signal = new AbortController().signal) {
if (platform !== 'host' && platform !== 'client') throw new Error(`Cordis inspect platform must be "host" or "client", not "${String(platform)}"`)
const provider = providers.find((p) => p.platform === platform && p.id === providerId)
if (!provider) throw new Error(`${label(platform)} Cordis inspect provider "${providerId}" is not registered`)
const found = provider.methods.find((m) => m.name === methodName)
if (!found) throw new Error(`Cordis inspect provider "${provider.id}" has no method "${methodName}"`)
const violations = validateJsonSchemaValue(found.inputSchema, input ?? {}, 'input') as string[]
if (violations.length > 0) throw new Error(`${label(platform)} Cordis inspect ${provider.id}.${found.name} rejected input: ${violations.join('; ')}`)
signal.throwIfAborted()
const data = await provider.run(methodName, (input ?? {}) as Record<string, JsonValue>, agent, signal)
signal.throwIfAborted()
if (!isJson(data)) throw new Error(`${label(platform)} Cordis inspect ${provider.id}.${found.name} returned a non-JSON value`)
return copy(data)
},
}
}
@@ -1,213 +0,0 @@
import assert from 'node:assert/strict'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { build } from 'esbuild'
import { dirname, join } from 'node:path'
import { after, test } from 'node:test'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { buildCatalog, parseSeatTable, renderCatalog } from '../scripts/gen-inspect-catalog.mjs'
import { createCordisInspect } from '../src/dsh/inspect.ts'
const here = dirname(fileURLToPath(import.meta.url))
const src = (path) => join(here, '..', 'src', path)
const text = (path) => readFileSync(path, 'utf8').replace(/\r\n/g, '\n')
const compatibility = JSON.parse(text(src('dsh/compatibility.json')))
const catalog = JSON.parse(text(src('dsh/inspect-catalog.json')))
const seatSource = text(join(here, '..', '..', '..', 'app-server', 'src', 'runtime_api', 'plugins.rs'))
const rootSource = text(src('root.ts'))
const inspect = createCordisInspect()
const ask = (platform, provider, method, input) => inspect.query(platform, provider, method, input)
test('the committed catalog is what the generator writes now', () => {
assert.equal(text(src('dsh/inspect-catalog.json')), renderCatalog(buildCatalog()))
})
test('list names the providers DSH calls and leaves out Tool until a source exists', async () => {
const ids = inspect.list().map((p) => `${p.platform}.${p.id}.${p.methods.map((m) => m.name).join(',')}`)
assert.deepEqual(ids, ['host.Service.listService', 'host.Event.listEvents', 'host.Config.listConfigs', 'client.Slots.listSubTree', 'client.Theme.listTokens'])
const withTools = createCordisInspect({ tools: () => [{ name: 'read_file' }] })
assert.ok(withTools.list().some((p) => p.id === 'Tool'))
assert.deepEqual(await withTools.query('host', 'Tool', 'listTools', undefined, {}), { tools: [{ name: 'read_file' }] })
})
test('errors use DSH registry wording', async () => {
await assert.rejects(ask('host', 'Nope', 'x'), /Host Cordis inspect provider "Nope" is not registered/)
await assert.rejects(ask('client', 'Nope', 'x'), /Client Cordis inspect provider "Nope" is not registered/)
await assert.rejects(ask('host', 'Service', 'nope'), /Cordis inspect provider "Service" has no method "nope"/)
await assert.rejects(ask('host', 'Service', 'listService', { service: 3 }), /Host Cordis inspect Service.listService rejected input/)
await assert.rejects(ask('host', 'Tool', 'listTools'), /not registered/)
await assert.rejects(ask('page', 'Service', 'listService'), /platform must be/)
})
test('every service the catalog names is provided, and no provided service is missing', () => {
const provided = Object.values(compatibility.provided_services).flat().sort()
assert.deepEqual(catalog.host.services.map((s) => s.key).sort(), provided)
assert.deepEqual(catalog.host.refusedServices, compatibility.refused_services)
})
test('methods match the compatibility tables where it has them', () => {
const tables = { sessions: 'session_methods', userQuestions: 'user_question_methods', subagents: 'subagent_methods', agents: 'agents_methods', systemPrompt: 'system_prompt_methods' }
for (const [service, table] of Object.entries(tables)) {
const named = catalog.host.services.find((s) => s.key === service).methods.map((m) => m.name).sort()
assert.deepEqual(named, Object.keys(compatibility[table]).sort(), service)
}
})
const classBody = (source, name) => {
const start = source.indexOf(`class ${name} extends Service`)
assert.ok(start >= 0, `class ${name}`)
const rest = source.slice(start + 1)
const next = rest.search(/\n\s*(export )?(class |function )/)
return next < 0 ? rest : rest.slice(0, next)
}
const defines = (body, name) => new RegExp(`^\\s*(async\\s+)?${name}\\s*\\(`, 'm').test(body)
test('every curated method is defined on the class that serves it', () => {
const served = {
tools: ['root.ts', 'ToolsShim'], commands: ['shims/commands.ts', 'CommandsShim'], storage: ['root.ts', 'StorageShim'],
skills: ['shims/skills.ts', 'SkillsShim'], prompt: ['shims/prompt.ts', 'PromptShim'], avatars: ['shims/avatars.ts', 'AvatarsShim'],
mcp: ['shims/mcp.ts', 'McpShim'], shellHooks: ['shims/shell-hooks.ts', 'ShellHooks'], remote: ['dsh/remote.ts', 'RemoteService'],
sessions: ['dsh/session-prompt.ts', 'SessionsService'], userQuestions: ['root.ts', 'UserQuestionsShim'],
subagents: ['dsh/agents.ts', 'SubagentsShim'], systemPrompt: ['dsh/session-prompt.ts', 'SystemPromptService'],
}
const cordis = { events: ['events', ['on', 'once', 'emit']], reflect: ['reflect', ['provide', 'get']], registry: ['registry', ['get', 'resolve']] }
const agentsSource = text(src('dsh/agents.ts'))
for (const service of catalog.host.services) {
if (service.key === 'logger' || service.key === 'agents') continue
if (cordis[service.key]) {
const [file, names] = cordis[service.key]
const body = text(join(here, '..', 'node_modules', '@deepseek-ai', 'cordis', 'lib', 'types', `${file}.d.ts`))
assert.deepEqual(service.methods.map((m) => m.name).sort(), [...names].sort(), service.key)
for (const name of names) assert.ok(defines(body, name), `${service.key}.${name}`)
continue
}
const [file, cls] = served[service.key]
const body = classBody(text(src(file)), cls)
for (const m of service.methods) {
if (service.key === 'subagents' && m.name === 'result') assert.match(agentsSource, /^\s*get result\(\)/m, 'subagents.result is a getter')
else if (service.key === 'subagents' && m.name === 'dispose') assert.match(agentsSource, /^\s*dispose\(\)/m, 'subagents.dispose')
else assert.ok(defines(body, m.name), `${service.key}.${m.name}`)
}
}
assert.match(agentsSource, /^\s*section\(section: unknown\)/m)
assert.match(agentsSource, /^\s*getSectionOrder\(name: unknown\)/m)
})
test('the catalog states the contracts the host actually enforces', () => {
const agentsSource = text(src('dsh/agents.ts'))
const personaName = text(src('dsh/dsh-message-compat.js')).match(/PERSONA_PREFIX_SECTION = "([^"]+)"/)[1]
const section = catalog.host.services.find((s) => s.key === 'systemPrompt').methods[0]
assert.ok(section.signature.includes(`name: '${personaName}'`))
assert.ok(section.signature.includes("getSectionOrder('DEPLOYMENT_PERSONA_PREFIX')"))
assert.match(agentsSource, /PERSONA_ORDER = 0/)
const subagents = catalog.host.services.find((s) => s.key === 'subagents').methods
const result = subagents.find((m) => m.name === 'result')
assert.match(result.signature, /^activation\.result: Promise<\{ stopReason: string, output: \{ type: 'text', text: string \}\[\]/)
assert.ok(!result.signature.includes('result()'))
assert.match(subagents.find((m) => m.name === 'startActivation').signature, /Promise<\{ childId: string, messageId: string, result: Promise</)
})
test('an output that is not JSON is refused as DSH refuses it', async () => {
const bad = createCordisInspect({ tools: () => [{ name: 'x', extra: undefined }] })
await assert.rejects(bad.query('host', 'Tool', 'listTools', undefined, {}), /returned a non-JSON value/)
})
test('every slot and its seat equal the Engine seat table', () => {
const block = seatSource.slice(seatSource.indexOf('const DSH_SEAT_TABLE'), seatSource.indexOf('];', seatSource.indexOf('const DSH_SEAT_TABLE')))
const names = [...block.matchAll(/\(\s*"([^"]+)",\s*DshSeat::/g)].map((m) => m[1])
assert.equal(names.length, 30)
assert.deepEqual(catalog.client.slots.map((s) => s.slot), names)
const unsupported = [...block.matchAll(/\(\s*"([^"]+)",\s*DshSeat::Unsupported/g)].map((m) => m[1])
assert.deepEqual(catalog.client.slots.filter((s) => !s.supported).map((s) => s.slot), unsupported)
const seats = [...seatSource.slice(seatSource.indexOf('const PLUGIN_UI_SEATS'), seatSource.indexOf('];', seatSource.indexOf('const PLUGIN_UI_SEATS'))).matchAll(/"([^"]+)"/g)].map((m) => m[1])
for (const slot of catalog.client.slots.filter((s) => s.supported)) assert.ok(slot.seat === 'customize.plugins.detail' || seats.includes(slot.seat), slot.slot)
assert.equal(catalog.client.seats.length, 6)
assert.deepEqual(parseSeatTable(seatSource.replace(/\n/g, '\r\n')), parseSeatTable(seatSource))
})
test('the refused events equal the list in root.ts', () => {
const body = rootSource.slice(rootSource.indexOf('unsupportedDshLifecycle = new Set(['), rootSource.indexOf('])', rootSource.indexOf('unsupportedDshLifecycle = new Set([')))
const refused = [...body.matchAll(/'([^']+)'/g)].map((m) => m[1])
assert.equal(refused.length, 5)
assert.deepEqual(catalog.host.events.filter((e) => e.status === 'refused').map((e) => e.name), refused)
for (const name of catalog.host.events.filter((e) => e.status === 'supported').map((e) => e.name)) assert.ok(rootSource.includes(`'${name}'`) || text(src('dsh/agents.ts')).includes(`'${name}'`), name)
})
test('the Service provider answers a directory, one contract, and refuses a name the host lacks', async () => {
const directory = await ask('host', 'Service', 'listService')
assert.equal(directory.services.length, 18)
assert.ok(!directory.services.some((s) => s.key === 'fs' || s.key === 'llm'))
const one = await ask('host', 'Service', 'listService', { service: 'prompt' })
assert.equal(one.service.methods[0].name, 'registerSection')
await assert.rejects(ask('host', 'Service', 'listService', { service: 'llm' }), /not provided by this host/)
})
test('the Event provider separates supported from refused', async () => {
const { events } = await ask('host', 'Event', 'listEvents')
assert.deepEqual(events.map((e) => e.status), ['supported', 'supported', 'refused', 'refused', 'refused', 'refused', 'refused'])
assert.equal((await ask('host', 'Event', 'listEvents', { event: 'tools/pre-execute' })).event.name, 'tools/pre-execute')
await assert.rejects(ask('host', 'Event', 'listEvents', { event: 'subagent/start-x' }), /not known/)
})
test('the Config provider lists exactly the row packages, pages, and shows installed rows', async () => {
const page = await ask('host', 'Config', 'listConfigs')
assert.deepEqual(page.entries.map((e) => e.name).sort(), Object.keys(compatibility.row_bridges).sort())
assert.equal(page.total, 5)
const paged = await ask('host', 'Config', 'listConfigs', { limit: 2 })
assert.equal(paged.entries.length, 2)
assert.equal(paged.nextOffset, 2)
const schema = await ask('host', 'Config', 'listConfigs', { entry: 'row:@deepseek-ai/dsh-persona' })
assert.deepEqual(schema.schema.required, ['prefix'])
await assert.rejects(ask('host', 'Config', 'listConfigs', { limit: 500 }), /limit/)
await assert.rejects(ask('host', 'Config', 'listConfigs', { entry: 'row:nope' }), /not listed/)
const live = createCordisInspect({ installed: () => [{ id: 'abc', name: 'hello', state: 'active' }] })
const after = await live.query('host', 'Config', 'listConfigs', { name: 'hello' })
assert.deepEqual(after.entries, [{ id: 'plugin:abc', name: 'hello', kind: 'installed-plugin', status: 'active' }])
})
test('each row example passes the bridge that reads it', async () => {
const dir = mkdtempSync(join(here, '..', 'node_modules', '.cw-inspect-'))
after(() => rmSync(dir, { recursive: true, force: true }))
const bridges = join(dir, 'bridges.mjs')
await build({
stdin: { contents: "export { normalizePersonaConfig } from './src/dsh/persona.ts'\nexport { reviewedMcpModule } from './src/dsh/mcp.ts'\nexport { reviewedSkillModule } from './src/dsh/skill-filesystem.ts'", resolveDir: join(here, '..'), loader: 'ts' },
outfile: bridges, bundle: true, platform: 'node', format: 'esm', packages: 'external', absWorkingDir: join(here, '..'), logLevel: 'silent',
})
const { normalizePersonaConfig, reviewedMcpModule, reviewedSkillModule } = await import(pathToFileURL(bridges).href)
const example = (name) => catalog.host.config.find((r) => r.name === name).example
assert.doesNotThrow(() => normalizePersonaConfig(example('@deepseek-ai/dsh-persona')))
assert.throws(() => normalizePersonaConfig({ prefix: 'x', complete: true }))
const servers = []
reviewedMcpModule().apply({ mcp: { registerServer: (d) => servers.push(d) } }, example('@deepseek-ai/dsh-mcp-client'))
assert.equal(servers[0].serverName, 'docs')
const roots = []
reviewedSkillModule().apply({ skills: { registerRoot: (d) => roots.push(d) } }, example('@deepseek-ai/dsh-skill-filesystem'))
assert.equal(roots.length, 1)
})
test('Slots says no client is connected in a terminal and marks no slot drawn', async () => {
const out = await ask('client', 'Slots', 'listSubTree')
assert.equal(out.client.connected, false)
assert.match(out.client.message, /No client is connected/)
assert.equal(out.trees.length, 30)
assert.ok(out.trees.every((s) => s.drawn === false))
assert.deepEqual((await ask('client', 'Slots', 'listSubTree', { root: 'conversation.composer.dock' })).selected.seat, 'conversation.composer.dock')
assert.equal((await ask('client', 'Slots', 'listSubTree', { root: 'nope' })).requestedRoot.available, false)
})
test('Slots marks only the drawn seats when a client is connected', async () => {
const connected = createCordisInspect({ client: () => ({ connected: true }) })
const out = await connected.query('client', 'Slots', 'listSubTree')
const drawn = out.trees.filter((s) => s.drawn).map((s) => s.name)
assert.deepEqual(drawn, ['plugins.bundle.config', 'plugins.row.config', 'plugins.tab', 'plugins.detail.section', 'settings.section', 'settings.plugin.item', 'settings.general.item', 'settings.plugins.tab'])
const all = createCordisInspect({ client: () => ({ connected: true, drawnSeats: ['conversation.composer.dock'] }) })
const composer = (await all.query('client', 'Slots', 'listSubTree')).trees.filter((s) => s.drawn).map((s) => s.name)
assert.ok(composer.includes('conversation.input.dock') && !composer.includes('plugins.tab'))
assert.ok((await ask('client', 'Slots', 'listSubTree')).trees.filter((s) => !s.supported).every((s) => s.seat === null && s.reason))
})
test('Theme reports that the frame defines no named tokens', async () => {
const out = await ask('client', 'Theme', 'listTokens')
assert.deepEqual(out.tokens, [])
assert.deepEqual(out.colorSchemes, ['light', 'dark'])
assert.ok(out.api.some((a) => a.name === 'ctx.theme.overrideTokens'))
})
+2 -85
View File
@@ -44,9 +44,8 @@ use super::CodewhaleClient;
use super::prepared::WireDialect;
use super::role_placement::{RolePlacement, role_placement};
use super::wire::{
ToolNameWire, check_history_tool_names, documented_tool_name_limit, extract_sse_data_value,
from_api_tool_name, next_sse_line, over_limit_tool_name_message, push_sse_event_data,
to_api_tool_name, tool_name_exceeds_limit, tool_name_limit,
extract_sse_data_value, from_api_tool_name, next_sse_line, push_sse_event_data,
to_api_tool_name,
};
/// Maximum `cache_control` breakpoints Anthropic accepts per request.
@@ -104,11 +103,6 @@ impl CodewhaleClient {
repair_dangling_tool_uses(&mut messages);
body["messages"] = Value::Array(messages);
let tool_name_limit = tool_name_limit(self.api_provider, ToolNameWire::AnthropicMessages);
check_history_tool_names(
request,
documented_tool_name_limit(self.api_provider, ToolNameWire::AnthropicMessages),
)?;
let mut omitted_tool_names = Vec::new();
let mut omitted_logical_names = Vec::new();
if let Some(tools) = request.tools.as_ref()
@@ -119,11 +113,6 @@ impl CodewhaleClient {
};
let mut wire_tools = Vec::new();
for tool in tools {
if tool_name_exceeds_limit(&tool.name, tool_name_limit) {
omitted_logical_names.push(tool.name.clone());
omitted_tool_names.push(to_api_tool_name(&tool.name));
continue;
}
match project_tool_schema(route_projection, &tool.input_schema, tool.strict) {
ToolSchemaProjection::Exact {
parameters,
@@ -166,9 +155,6 @@ impl CodewhaleClient {
if let Some(name) = forced_tool_name(tool_choice)
&& omitted_logical_names.iter().any(|omitted| omitted == name)
{
if tool_name_exceeds_limit(name, tool_name_limit) {
anyhow::bail!(over_limit_tool_name_message(name, tool_name_limit));
}
anyhow::bail!(
"cannot force an omitted tool because its input schema is incompatible with this route"
);
@@ -1319,75 +1305,6 @@ mod tests {
}
}
#[test]
fn over_long_encoded_tool_names_are_withheld_at_128() {
let client = anthropic_test_client(None);
let tool = |name: &str| Tool {
tool_type: None,
name: name.to_string(),
description: "d".to_string(),
input_schema: json!({"type": "object", "additionalProperties": false}),
allowed_callers: None,
defer_loading: None,
input_examples: None,
strict: None,
cache_control: None,
};
let mid = "mcp__tracker__projects.v2.issues.list-by-assignee";
let long = format!(
"mcp__tracker__{}.v2.issues.list-by-assignee",
"a".repeat(70)
);
assert!(to_api_tool_name(mid).len() > 64 && to_api_tool_name(mid).len() <= 128);
assert!(to_api_tool_name(&long).len() > 128);
let mut request = request_with("claude-sonnet-4-6", None, None, None);
request.tools = Some(vec![tool("read_file"), tool(mid), tool(&long)]);
let (body, omitted) = client
.build_anthropic_wire_body(&request, true)
.expect("body builds");
let sent: Vec<&str> = body["tools"]
.as_array()
.expect("tools")
.iter()
.filter_map(|t| t["name"].as_str())
.collect();
assert_eq!(sent, vec!["read_file".to_string(), to_api_tool_name(mid)]);
assert_eq!(omitted, vec![to_api_tool_name(&long)]);
request.tool_choice = Some(json!({"type": "tool", "name": long}));
let error = client
.build_anthropic_wire_body(&request, true)
.expect_err("forcing a withheld tool is refused");
assert!(error.to_string().contains("at most 128"), "{error}");
}
#[test]
fn a_100_character_encoded_name_is_offered_on_an_anthropic_wire_route_without_a_documented_limit()
{
let tool = |name: &str| Tool {
tool_type: None,
name: name.to_string(),
description: "d".to_string(),
input_schema: json!({"type": "object", "additionalProperties": false}),
allowed_callers: None,
defer_loading: None,
input_examples: None,
strict: None,
cache_control: None,
};
let name = "a".repeat(100);
assert_eq!(to_api_tool_name(&name).len(), 100);
let client = minimax_test_client();
let mut request = request_with("MiniMax-M2", None, None, None);
request.tools = Some(vec![tool(&name)]);
let (body, omitted) = client
.build_anthropic_wire_body(&request, true)
.expect("body builds");
assert_eq!(body["tools"][0]["name"], name);
assert!(omitted.is_empty());
}
fn request_with(
model: &str,
reasoning_effort: Option<&str>,
+5 -251
View File
@@ -41,10 +41,8 @@ use super::reasoning_effort::apply_reasoning_effort;
use super::role_placement::{RolePlacement, role_placement};
use super::wire::{
ERROR_BODY_MAX_BYTES, SSE_BACKPRESSURE_HIGH_WATERMARK, SSE_BACKPRESSURE_SLEEP_MS,
SSE_MAX_LINES_PER_CHUNK, ToolNameWire, acquire_stream_buffer, bounded_error_text,
check_history_tool_names, documented_tool_name_limit, from_api_tool_name,
over_limit_tool_name_message, parse_usage, release_stream_buffer, system_to_instructions,
to_api_tool_name, tool_name_exceeds_limit, tool_name_limit,
SSE_MAX_LINES_PER_CHUNK, acquire_stream_buffer, bounded_error_text, from_api_tool_name,
parse_usage, release_stream_buffer, system_to_instructions, to_api_tool_name,
};
use super::wire::{extract_sse_data_value, flush_sse_line, push_sse_event_data, take_sse_line};
use codewhale_config::route::RouteLimits;
@@ -1210,20 +1208,11 @@ pub fn build_chat_wire_body_with_window(
body["top_p"] = json!(top_p);
}
let mut omitted_tool_names = Vec::new();
let tool_name_limit = tool_name_limit(provider, ToolNameWire::OpenAiCompatible);
check_history_tool_names(
request,
documented_tool_name_limit(provider, ToolNameWire::OpenAiCompatible),
)?;
if let Some(tools) = request.tools.as_ref() {
let route_projection =
chat_route_tool_projection(provider, base_url, strict_tools_disabled);
let mut chat_tools = Vec::new();
for tool in tools {
if tool_name_exceeds_limit(&tool.name, tool_name_limit) {
omitted_tool_names.push(to_api_tool_name(&tool.name));
continue;
}
match project_tool_schema(route_projection, &tool.input_schema, tool.strict) {
ToolSchemaProjection::Exact {
parameters,
@@ -1258,9 +1247,6 @@ pub fn build_chat_wire_body_with_window(
.iter()
.any(|omitted| omitted == name || *omitted == to_api_tool_name(name))
{
if tool_name_exceeds_limit(name, tool_name_limit) {
bail!(over_limit_tool_name_message(name, tool_name_limit));
}
bail!(
"cannot force an omitted tool because its input schema is incompatible with this route"
);
@@ -3269,25 +3255,11 @@ fn deepseek_base_url_supports_strict_tools(base_url: &str) -> bool {
fn map_tool_choice_for_chat(choice: &Value) -> Option<Value> {
if let Some(choice_str) = choice.as_str() {
return Some(match forced_tool_name(choice) {
Some(name) => json!({
"type": "function",
"function": { "name": to_api_tool_name(name) }
}),
None => json!(choice_str),
});
return Some(json!(choice_str));
}
let Some(choice_type) = choice.get("type").and_then(Value::as_str) else {
return Some(choice.clone());
};
if choice_type == "function"
&& let Some(name) = forced_tool_name(choice)
{
return Some(json!({
"type": "function",
"function": { "name": to_api_tool_name(name) }
}));
}
match choice_type {
"auto" | "none" => Some(json!(choice_type)),
@@ -6692,13 +6664,9 @@ mod image_block_wire_tests {
//! most of them at once. The shape is fixed by OpenAI's spec: a `user`
//! message whose `content` is an array of parts, with the image as
//! `{"type":"image_url","image_url":{"url":…}}`.
use super::{
ChatWireBody, ProviderKind, build_chat_messages, build_chat_wire_body,
map_tool_choice_for_chat, to_api_tool_name,
};
use super::{ProviderKind, build_chat_messages, build_chat_wire_body};
use codewhale_models::Role;
use codewhale_models::{ContentBlock, ImageUrlContent, Message, MessageRequest, Tool};
use serde_json::{Value, json};
use codewhale_models::{ContentBlock, ImageUrlContent, Message, MessageRequest};
const DATA_URL: &str = "data:image/png;base64,QUJD";
@@ -7005,220 +6973,6 @@ mod image_block_wire_tests {
}
}
fn named_tool(name: &str) -> Tool {
Tool {
tool_type: None,
name: name.to_string(),
description: "d".to_string(),
input_schema: json!({"type": "object", "additionalProperties": false}),
allowed_callers: None,
defer_loading: None,
input_examples: None,
strict: None,
cache_control: None,
}
}
#[test]
fn function_object_tool_choice_encodes_the_tool_name() {
let encoded = to_api_tool_name("web.run");
assert_eq!(encoded, "web-x00002E-run");
let expected = json!({"type": "function", "function": {"name": encoded}});
for choice in [
json!({"type": "function", "function": {"name": "web.run"}}),
json!({"type": "function", "name": "web.run"}),
json!({"type": "tool", "name": "web.run"}),
json!("web.run"),
] {
assert_eq!(
map_tool_choice_for_chat(&choice),
Some(expected.clone()),
"{choice}"
);
}
assert_eq!(
map_tool_choice_for_chat(&json!("required")),
Some(json!("required"))
);
let mut request = request_with_image();
request.tools = Some(vec![named_tool("read_file"), named_tool("web.run")]);
request.tool_choice = Some(json!({"type": "function", "function": {"name": "web.run"}}));
let body = build_chat_wire_body(
&request,
ProviderKind::Openai,
"https://api.openai.com/v1",
false,
None,
)
.expect("wire body");
assert_eq!(body.body["tool_choice"], expected);
let declared: Vec<&str> = body.body["tools"]
.as_array()
.expect("tools")
.iter()
.filter_map(|tool| tool.pointer("/function/name").and_then(Value::as_str))
.collect();
assert!(declared.contains(&"web-x00002E-run"));
}
#[test]
fn over_long_encoded_tool_names_are_withheld_by_provider_limit() {
let mid = "mcp__tracker__projects.v2.issues.list-by-assignee";
let long = format!(
"mcp__tracker__{}.v2.issues.list-by-assignee",
"a".repeat(70)
);
let mid_len = to_api_tool_name(mid).len();
assert!(mid_len > 64 && mid_len <= 128, "{mid_len}");
assert!(to_api_tool_name(&long).len() > 128);
let build = |provider, base_url: &str, choice: Option<Value>| {
let mut request = request_with_image();
request.tools = Some(vec![
named_tool("read_file"),
named_tool(mid),
named_tool(&long),
]);
request.tool_choice = choice;
build_chat_wire_body(&request, provider, base_url, false, None)
};
let declared = |body: &ChatWireBody| -> Vec<String> {
body.body["tools"]
.as_array()
.expect("tools")
.iter()
.filter_map(|tool| tool.pointer("/function/name").and_then(Value::as_str))
.map(str::to_string)
.collect()
};
let openai = build(ProviderKind::Openai, "https://api.openai.com/v1", None).expect("body");
assert_eq!(declared(&openai), vec!["read_file".to_string()]);
assert_eq!(
openai.omitted_tool_names,
vec![to_api_tool_name(mid), to_api_tool_name(&long)]
);
let deepseek =
build(ProviderKind::Deepseek, "https://api.deepseek.com", None).expect("body");
assert_eq!(
declared(&deepseek),
vec!["read_file".to_string(), to_api_tool_name(mid)]
);
assert_eq!(deepseek.omitted_tool_names, vec![to_api_tool_name(&long)]);
let forced = json!({"type": "function", "function": {"name": mid}});
let error = build(
ProviderKind::Openai,
"https://api.openai.com/v1",
Some(forced),
)
.err()
.expect("forcing a withheld tool is refused");
let message = error.to_string();
assert!(
message.contains(&format!("{mid_len} characters")),
"{message}"
);
assert!(message.contains("at most 64"), "{message}");
}
#[test]
fn over_long_tool_name_in_history_is_refused_with_the_limit() {
let long = "mcp__tracker__projects.v2.issues.list-by-assignee";
let mut request = request_with_image();
request.messages.push(Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: "call_1".to_string(),
name: long.to_string(),
input: json!({}),
caller: None,
thought_signature: None,
}],
});
let error = build_chat_wire_body(
&request,
ProviderKind::Openai,
"https://api.openai.com/v1",
false,
None,
)
.err()
.expect("history name over the limit is refused");
let message = error.to_string();
assert!(
message.contains("at most 64") && message.contains(long),
"{message}"
);
build_chat_wire_body(
&request,
ProviderKind::Deepseek,
"https://api.deepseek.com",
false,
None,
)
.expect("the same history fits a 128-character route");
}
#[test]
fn history_name_of_65_to_128_characters_is_sent_unchanged_on_undocumented_routes() {
let long = "mcp__tracker__projects.v2.issues.list-by-assignee";
assert!(to_api_tool_name(long).len() > 64);
let mut request = request_with_image();
request.messages.push(Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: "call_1".to_string(),
name: long.to_string(),
input: json!({}),
caller: None,
thought_signature: None,
}],
});
for (provider, base_url) in [
(ProviderKind::Ollama, "http://localhost:11434/v1"),
(ProviderKind::Custom, "http://localhost:8000/v1"),
(ProviderKind::Minimax, "https://api.minimax.io/v1"),
] {
build_chat_wire_body(&request, provider, base_url, false, None)
.unwrap_or_else(|error| panic!("{provider:?}: {error}"));
}
let error = build_chat_wire_body(
&request,
ProviderKind::Openai,
"https://api.openai.com/v1",
false,
None,
)
.err()
.expect("documented 64-character route refuses the history name");
assert!(
error.to_string().contains("earlier in this session"),
"{error}"
);
}
#[test]
fn a_100_character_encoded_name_is_withheld_on_a_chat_wire_route() {
let name = "a".repeat(100);
let mut request = request_with_image();
request.tools = Some(vec![named_tool("read_file"), named_tool(&name)]);
let body = build_chat_wire_body(
&request,
ProviderKind::Minimax,
"https://api.minimax.io/v1",
false,
None,
)
.expect("wire body");
assert_eq!(body.omitted_tool_names, vec![name]);
assert_eq!(body.body["tools"].as_array().expect("tools").len(), 1);
}
fn request_with_image() -> MessageRequest {
MessageRequest {
model: "gpt-4o".to_string(),
+2 -22
View File
@@ -35,10 +35,8 @@ use super::chat::REASONING_REPLAY_PLACEHOLDER;
use super::prepared::WireDialect;
use super::role_placement::{RolePlacement, role_placement};
use super::wire::{
ERROR_BODY_MAX_BYTES, TOOL_NAME_LIMIT_64, ToolNameWire, bounded_error_text,
check_history_tool_names, documented_tool_name_limit, from_api_tool_name,
over_limit_tool_name_message, system_to_instructions, to_api_tool_name,
tool_name_exceeds_limit, tool_name_limit,
ERROR_BODY_MAX_BYTES, bounded_error_text, from_api_tool_name, system_to_instructions,
to_api_tool_name,
};
use super::wire::{extract_sse_data_value, next_sse_line, push_sse_event_data};
use crate::llm_client::LlmError;
@@ -137,7 +135,6 @@ pub(super) fn build_responses_wire_body(
.filter(|text| !text.trim().is_empty())
.unwrap_or_else(|| "You are a helpful assistant.".to_string());
let provider_kind = ProviderKind::parse(context.provider_id);
let mut input = convert_messages_to_responses_input(request, profile, context);
if profile.request.instructions == InstructionsPlacement::LeadingSystemInput {
input.insert(
@@ -153,26 +150,12 @@ pub(super) fn build_responses_wire_body(
}
body["input"] = json!(input);
let tool_name_limit = provider_kind.map_or(TOOL_NAME_LIMIT_64, |provider| {
tool_name_limit(provider, ToolNameWire::OpenAiCompatible)
});
check_history_tool_names(
request,
provider_kind.and_then(|provider| {
documented_tool_name_limit(provider, ToolNameWire::OpenAiCompatible)
}),
)?;
let mut omitted_tool_names = Vec::new();
let mut omitted_logical_names = Vec::new();
// Convert tools to Responses function tools.
if let Some(tools) = request.tools.as_ref() {
let mut responses_tools = Vec::new();
for tool in tools {
if tool_name_exceeds_limit(&tool.name, tool_name_limit) {
omitted_logical_names.push(tool.name.clone());
omitted_tool_names.push(to_api_tool_name(&tool.name));
continue;
}
match project_tool_schema(
RouteToolProjection::RelaxedNonStrict,
&tool.input_schema,
@@ -202,9 +185,6 @@ pub(super) fn build_responses_wire_body(
if let Some(name) = request.tool_choice.as_ref().and_then(forced_tool_name)
&& omitted_logical_names.iter().any(|omitted| omitted == name)
{
if tool_name_exceeds_limit(name, tool_name_limit) {
anyhow::bail!(over_limit_tool_name_message(name, tool_name_limit));
}
anyhow::bail!(
"cannot force an omitted tool because its input schema is incompatible with this route"
);
@@ -755,43 +755,6 @@ async fn responses_stream_inserts_boundary_between_reasoning_summary_parts() {
assert_eq!(thinking, "partA\n\npartB");
}
#[test]
fn over_long_encoded_tool_names_are_withheld_from_responses_at_64() {
let tool = |name: &str| Tool {
tool_type: None,
name: name.to_string(),
description: "d".to_string(),
input_schema: json!({"type": "object", "additionalProperties": false}),
allowed_callers: None,
defer_loading: None,
input_examples: None,
strict: None,
cache_control: None,
};
let long = "mcp__tracker__projects.v2.issues.list-by-assignee";
assert!(to_api_tool_name(long).len() > 64);
let mut request = minimal_responses_request();
request.tools = Some(vec![tool("read_file"), tool(long)]);
let (body, omitted) = build_responses_wire_body(
&request,
profile_for_provider(ProviderKind::Openai),
ResponsesReplayContext {
provider_id: ProviderKind::Openai.as_str(),
wire_model_id: request.model.as_str(),
reasoning_api_id: None,
},
)
.expect("body builds");
let sent: Vec<&str> = body["tools"]
.as_array()
.expect("tools")
.iter()
.filter_map(|t| t["name"].as_str())
.collect();
assert_eq!(sent, vec!["read_file"]);
assert_eq!(omitted, vec![to_api_tool_name(long)]);
}
#[test]
fn codex_reasoning_effort_uses_responses_labels() {
assert_eq!(codex_responses_reasoning_effort("max"), Some("max"));
+3 -4
View File
@@ -1449,10 +1449,9 @@ fn tool_name_scenario() {
}
// from tool_name_decode_bare_hex_no_trailing_dash
{
let exact = "foo_x00002Ebar";
assert_eq!(from_api_tool_name(exact), exact);
let mangled = "foo_.x00002Ebar";
assert_eq!(from_api_tool_name(mangled), "foo_..bar");
let mangled = "foo_x00002Ebar";
let decoded = from_api_tool_name(mangled);
assert_eq!(decoded, "foo_.bar");
}
// from tool_name_bare_hex_preserves_alnum
{
+7 -175
View File
@@ -6,8 +6,7 @@
use anyhow::Result;
use serde_json::Value;
use codewhale_config::ProviderKind;
use codewhale_models::{ContentBlock, MessageRequest, ServerToolUsage, SystemPrompt, Usage};
use codewhale_models::{ServerToolUsage, SystemPrompt, Usage};
use std::sync::{Mutex as StdMutex, OnceLock};
pub fn saturating_u32(value: u64) -> u32 {
@@ -316,7 +315,7 @@ pub fn to_api_tool_name(name: &str) -> String {
out
}
fn decode_delimited_tool_name(name: &str) -> String {
pub fn from_api_tool_name(name: &str) -> String {
let mut out = String::new();
let mut iter = name.chars().peekable();
while let Some(ch) = iter.next() {
@@ -358,87 +357,12 @@ fn decode_delimited_tool_name(name: &str) -> String {
}
out.push('-');
}
out
}
pub fn from_api_tool_name(name: &str) -> String {
let exact = decode_delimited_tool_name(name);
if to_api_tool_name(&exact) == name {
return exact;
}
decode_bare_hex_escapes(&exact)
}
pub const TOOL_NAME_LIMIT_64: usize = 64;
pub const TOOL_NAME_LIMIT_128: usize = 128;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolNameWire {
AnthropicMessages,
OpenAiCompatible,
}
pub fn documented_tool_name_limit(provider: ProviderKind, wire: ToolNameWire) -> Option<usize> {
match (provider, wire) {
(ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic, _) => Some(TOOL_NAME_LIMIT_128),
(ProviderKind::Anthropic, ToolNameWire::AnthropicMessages) => Some(TOOL_NAME_LIMIT_128),
(ProviderKind::Openai | ProviderKind::OpenaiCodex, ToolNameWire::OpenAiCompatible) => {
Some(TOOL_NAME_LIMIT_64)
}
_ => None,
}
}
pub fn tool_name_limit(provider: ProviderKind, wire: ToolNameWire) -> usize {
if let Some(limit) = documented_tool_name_limit(provider, wire) {
return limit;
}
match (provider, wire) {
(_, ToolNameWire::AnthropicMessages) => TOOL_NAME_LIMIT_128,
(
ProviderKind::Ollama
| ProviderKind::OllamaCloud
| ProviderKind::Vllm
| ProviderKind::Sglang
| ProviderKind::Custom,
_,
) => TOOL_NAME_LIMIT_128,
(_, ToolNameWire::OpenAiCompatible) => TOOL_NAME_LIMIT_64,
}
}
pub fn check_history_tool_names(request: &MessageRequest, documented: Option<usize>) -> Result<()> {
let Some(limit) = documented else {
return Ok(());
};
for message in &request.messages {
for block in &message.content {
if let ContentBlock::ToolUse { name, .. } = block
&& tool_name_exceeds_limit(name, limit)
{
anyhow::bail!(over_limit_history_tool_name_message(name, limit));
}
}
}
Ok(())
}
pub fn tool_name_exceeds_limit(name: &str, limit: usize) -> bool {
to_api_tool_name(name).len() > limit
}
pub fn over_limit_tool_name_message(name: &str, limit: usize) -> String {
format!(
"tool name `{name}` encodes to {} characters but this provider accepts at most {limit}; rename the tool (shorter name, fewer dots, hyphens or other special characters) or remove it",
to_api_tool_name(name).len()
)
}
pub fn over_limit_history_tool_name_message(name: &str, limit: usize) -> String {
format!(
"tool `{name}` was called earlier in this session and its encoded name is {} characters, but this provider accepts at most {limit}; switch to a route with a larger limit or start a new session",
to_api_tool_name(name).len()
)
// Second pass: decode bare hex escapes (e.g. `x00002E`) that the model
// may produce when it mangles the `-x00002E-` delimiter form. Only
// decode when the resulting character is one that `to_api_tool_name`
// would have encoded (not alphanumeric, not `_`, not `-`).
decode_bare_hex_escapes(&out)
}
pub const SSE_BACKPRESSURE_HIGH_WATERMARK: usize = 1024 * 1024; // 1 MB
@@ -541,98 +465,6 @@ mod tests {
use super::*;
use serde_json::json;
#[test]
fn tool_name_encoding_is_collision_free_for_hostile_names() {
let names = [
"web.run",
"web-x00002E-run",
"web--x00002E--run",
"webx00002Erun",
"web_x00002Erun",
"web_.run",
"a-b",
"a--b",
"a_b",
"a.b",
"ax00002Eb",
"a-x00002E-b",
"mcp__srv__tool.name",
"mcp__srv__tool-x00002E-name",
];
let mut seen = std::collections::HashMap::new();
for name in names {
let encoded = to_api_tool_name(name);
assert!(
encoded
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'),
"{encoded}"
);
if let Some(previous) = seen.insert(encoded.clone(), name) {
panic!("{previous} and {name} both encode to {encoded}");
}
assert_eq!(from_api_tool_name(&encoded), name, "round trip of {name}");
}
}
#[test]
fn tool_name_limit_follows_the_wire_unless_the_provider_documents_one() {
use ToolNameWire::{AnthropicMessages, OpenAiCompatible};
assert_eq!(
tool_name_limit(ProviderKind::Anthropic, AnthropicMessages),
128
);
assert_eq!(
tool_name_limit(ProviderKind::MinimaxAnthropic, AnthropicMessages),
128
);
assert_eq!(
tool_name_limit(
ProviderKind::ModelstudioTokenPlanAnthropic,
AnthropicMessages
),
128
);
assert_eq!(
tool_name_limit(ProviderKind::Deepseek, OpenAiCompatible),
128
);
assert_eq!(
tool_name_limit(ProviderKind::DeepseekAnthropic, AnthropicMessages),
128
);
assert_eq!(tool_name_limit(ProviderKind::Openai, OpenAiCompatible), 64);
assert_eq!(tool_name_limit(ProviderKind::Minimax, OpenAiCompatible), 64);
assert_eq!(tool_name_limit(ProviderKind::Ollama, OpenAiCompatible), 128);
assert_eq!(
documented_tool_name_limit(ProviderKind::Ollama, OpenAiCompatible),
None
);
assert_eq!(
documented_tool_name_limit(ProviderKind::Custom, OpenAiCompatible),
None
);
}
#[test]
fn tool_name_limit_counts_the_encoded_form() {
let dotted = "mcp__tracker__projects.v2.issues.list-by-assignee";
assert!(dotted.len() <= 64);
assert!(tool_name_exceeds_limit(dotted, TOOL_NAME_LIMIT_64));
assert!(!tool_name_exceeds_limit(dotted, TOOL_NAME_LIMIT_128));
let plain = "a".repeat(64);
assert!(!tool_name_exceeds_limit(&plain, TOOL_NAME_LIMIT_64));
assert!(tool_name_exceeds_limit(
&format!("{plain}a"),
TOOL_NAME_LIMIT_64
));
let message = over_limit_tool_name_message(dotted, TOOL_NAME_LIMIT_64);
assert!(
message.contains("at most 64") && message.contains(dotted),
"{message}"
);
}
#[test]
fn sse_line_limit_rejects_oversized_pending_complete_and_final_lines() {
let mut decoder = SseLineDecoder::new();
@@ -2152,7 +2152,10 @@ fn git_push_refspec_is_protected(refspec: &str) -> bool {
// Tag-like names (`v1`, `v0.9.1`): git resolves branch-vs-tag on the
// server, so treat them as publishes.
let mut chars = lower.chars();
chars.next() == Some('v') && chars.next().is_some_and(|ch| ch.is_ascii_digit())
if chars.next() == Some('v') && chars.next().is_some_and(|ch| ch.is_ascii_digit()) {
return true;
}
false
}
fn git_tag_tokens_are_publish_like(tokens: &[&str]) -> bool {
+1 -10
View File
@@ -1044,7 +1044,7 @@ pub fn tool_projection_warning_message(
omitted_tool_count: usize,
) -> String {
format!(
"Warning: {provider} omitted tools for this request: {}. A tool is omitted when its input schema is incompatible with this route or when its encoded name is longer than the provider's tool-name limit (64 characters on OpenAI-style routes, 128 on Anthropic-style routes and DeepSeek); a name ending in … is cut for display. Shorten the tool's name (dots, hyphens and other special characters each add characters) or remove it.",
"Warning: {provider} omitted incompatible tools for this request: {}",
tool_projection_warning_tool_list(omitted_tool_names, omitted_tool_count)
)
}
@@ -1251,15 +1251,6 @@ mod tool_projection_warning_tests {
bounded_tool_projection_warning_names,
};
#[test]
fn projection_warning_message_names_the_tool_and_the_name_limit() {
let names = vec!["web-x00002E-run".to_string()];
let message = tool_projection_warning_message("OpenAI", &names, 1);
assert!(message.contains("web-x00002E-run"), "{message}");
assert!(message.contains("64 characters"), "{message}");
assert!(message.contains("128 on Anthropic-style"), "{message}");
}
#[test]
fn projection_warning_names_are_count_and_length_bounded() {
let names: Vec<String> = (0..12)
@@ -203,13 +203,10 @@ impl Drop for TestOverlay {
fn overlay_granted() -> bool {
#[cfg(test)]
{
TEST_OVERLAY.with(std::cell::Cell::get)
}
#[cfg(not(test))]
{
false
if TEST_OVERLAY.with(std::cell::Cell::get) {
return true;
}
false
}
fn caller(call: &BrokerCall, tool_name: &'static str) -> Result<ToolContext, BrokerRefusal> {
-13
View File
@@ -187,19 +187,6 @@ pub const AGENTS: &[ExternalAgent] = &[
adapter_binary: None,
adapter_cli_env: None,
},
ExternalAgent {
id: "qwen-code",
name: "Qwen Code",
binary: "qwen",
acp_args: Some(&["--acp"]),
website: "https://github.com/QwenLM/qwen-code",
min_version: Some("0.6.1"),
upgrade: Some("npm i -g @qwen-code/qwen-code@latest"),
login: None,
adapter: None,
adapter_binary: None,
adapter_cli_env: None,
},
];
const FIXTURE_AGENT: ExternalAgent = ExternalAgent {
+8 -72
View File
@@ -1727,10 +1727,9 @@ fn ssh_client_env() -> BTreeMap<String, String> {
.collect()
}
const MANAGED_CONFIG_ENV: [&str; 2] = [
"CODEWHALE_MANAGED_CONFIG_PATH",
"DEEPSEEK_MANAGED_CONFIG_PATH",
];
fn process_base_env() -> BTreeMap<String, String> {
base_env_from(std::env::vars_os())
}
/// The worker's inherited environment: the repository's scrubbed child-env
/// allowlist (`child_env`), so proxy routing, CA bundles, temp directories,
@@ -1743,22 +1742,11 @@ where
K: AsRef<std::ffi::OsStr>,
V: AsRef<std::ffi::OsStr>,
{
let parent: Vec<(std::ffi::OsString, std::ffi::OsString)> = parent
.into_iter()
.map(|(key, value)| (key.as_ref().to_os_string(), value.as_ref().to_os_string()))
.collect();
let mut env: BTreeMap<String, String> =
codewhale_runtime::child_env::sanitized_runtime_env_from(parent.iter().cloned())
codewhale_runtime::child_env::sanitized_runtime_env_from(parent)
.into_iter()
.filter_map(|(key, value)| Some((key.into_string().ok()?, value.into_string().ok()?)))
.collect();
for (key, value) in &parent {
if MANAGED_CONFIG_ENV.iter().any(|name| key == name)
&& let (Some(key), Some(value)) = (key.to_str(), value.to_str())
{
env.insert(key.to_string(), value.to_string());
}
}
force_worker_telemetry_off(&mut env);
env
}
@@ -1766,7 +1754,7 @@ where
/// Fleet workers are an implementation detail of the parent session, not
/// sessions of their own — the parent already accounts for the dispatch.
///
/// The spawn path `env_clear()`s and rebuilds from [`base_env_from`], so an
/// The spawn path `env_clear()`s and rebuilds from [`process_base_env`], so an
/// operator's opt-out would otherwise never reach a worker at all. Hard-off
/// here means a worker can never emit, can never inherit an ambient "on", and
/// can never write telemetry state into the operator's home.
@@ -1784,24 +1772,8 @@ fn worker_env(
request_env: &BTreeMap<String, String>,
allowlist: &BTreeSet<String>,
) -> FleetHostResult<BTreeMap<String, String>> {
worker_env_from(std::env::vars_os(), request_env, allowlist)
}
fn worker_env_from<K, V>(
parent: impl IntoIterator<Item = (K, V)>,
request_env: &BTreeMap<String, String>,
allowlist: &BTreeSet<String>,
) -> FleetHostResult<BTreeMap<String, String>>
where
K: AsRef<std::ffi::OsStr>,
V: AsRef<std::ffi::OsStr>,
{
let mut env = base_env_from(parent);
let mut requested = filtered_env(request_env, allowlist)?;
for key in MANAGED_CONFIG_ENV {
requested.remove(key);
}
env.extend(requested);
let mut env = process_base_env();
env.extend(filtered_env(request_env, allowlist)?);
force_worker_telemetry_off(&mut env);
Ok(env)
}
@@ -2798,47 +2770,11 @@ mod tests {
assert_eq!(config.codewhale_binary, "/usr/local/bin/codewhale");
}
#[test]
fn worker_env_keeps_the_parent_managed_config_path_and_a_request_cannot_replace_it() {
let parent = [
("CODEWHALE_MANAGED_CONFIG_PATH", "/etc/example/managed.toml"),
(
"DEEPSEEK_MANAGED_CONFIG_PATH",
"/etc/deepseek/managed_config.toml",
),
("USER", "fleet"),
];
let request_env = BTreeMap::from([
(
"CODEWHALE_MANAGED_CONFIG_PATH".to_string(),
"/nonexistent/managed.toml".to_string(),
),
(
"DEEPSEEK_MANAGED_CONFIG_PATH".to_string(),
"/nonexistent/managed.toml".to_string(),
),
]);
let allowlist: BTreeSet<String> = request_env.keys().cloned().collect();
let env = worker_env_from(parent, &request_env, &allowlist).expect("worker env");
for (key, value) in &parent[..2] {
assert_eq!(env.get(*key).map(String::as_str), Some(*value), "{key}");
}
let env =
worker_env_from([("USER", "fleet")], &request_env, &allowlist).expect("worker env");
for key in MANAGED_CONFIG_ENV {
assert!(
!env.contains_key(key),
"{key} must come only from the parent"
);
}
}
#[test]
fn worker_env_forces_telemetry_off_even_when_the_allowlist_says_otherwise() {
// The spawn path env_clear()s and rebuilds from this map, so anything
// absent here simply does not exist inside the worker.
let base = base_env_from(std::env::vars_os());
let base = process_base_env();
assert_eq!(
base.get("CODEWHALE_TELEMETRY").map(String::as_str),
Some("false")
+5 -1
View File
@@ -1748,7 +1748,11 @@ impl FleetManager {
return Err(start_error);
}
};
Ok(started)
if !started {
return Ok(false);
}
Ok(true)
}
fn start_leased_workers(
-44
View File
@@ -1669,10 +1669,6 @@ 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
@@ -3129,30 +3125,6 @@ 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> {
@@ -3230,22 +3202,6 @@ 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,21 +177,6 @@ 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,80 +3314,6 @@ 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()));
@@ -6844,60 +6770,6 @@ 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]
+3 -5
View File
@@ -4988,16 +4988,14 @@ mod tests {
Arc::new(MockExecutor),
)
.await?;
settle_queue_fixture(&manager).await?;
inspect_settled_workers(&manager).await?;
sleep(STORE_IDLE_POLL_INTERVAL + Duration::from_millis(300)).await;
manager.fingerprint_reads.store(0, Ordering::Relaxed);
manager.store_loads.store(0, Ordering::Relaxed);
let started = Instant::now();
sleep(Duration::from_secs(3)).await;
let window = Duration::from_secs(3);
sleep(window).await;
let reads = manager.fingerprint_reads.load(Ordering::Relaxed);
let loads = manager.store_loads.load(Ordering::Relaxed);
let window = started.elapsed();
let ticks = (window.as_millis() / STORE_SETTLED_NAP.as_millis()) as usize + 1;
assert!(
reads <= workers * ticks,
@@ -133,9 +133,14 @@ impl WriteScopeClaim {
return true;
}
}
self.contracts
if self
.contracts
.iter()
.any(|contract| other.contracts.iter().any(|other| other == contract))
{
return true;
}
false
}
#[must_use]
+9 -9
View File
@@ -235,14 +235,14 @@
"TelemetryNoticeActionChoose": "choose",
"TelemetryNoticeActionConfirm": "confirm",
"TelemetryNoticeActionExit": "exit",
"TelemetryNoticeReceiptEnabled": "Usage reporting is on. Conversations and code are never collected. Change this in /settings; schema: docs/TELEMETRY.md",
"TelemetryNoticeReceiptDisabled": "Usage reporting is off. You will not be asked again.",
"TelemetryNoticeReceiptEnabledUnsaved": "Usage reporting is on for this session. Codewhale could not save this notice, so it will appear again. Change this in /settings; schema: docs/TELEMETRY.md",
"TelemetryNoticeReceiptDisabledUnsaved": "Usage reporting is off for this session. Codewhale could not save the choice, so it will ask again next launch.",
"TelemetryPreferenceEnabledNextLaunch": "Usage reporting will be on next launch. Conversations and code are never collected.",
"TelemetryPreferenceDisabled": "Usage reporting is off. Local telemetry state was erased.",
"TelemetryPreferenceDisabledWithWarning": "Usage reporting is off, but part of the cleanup needs attention: {detail}",
"TelemetryPreferenceDisabledForSession": "Usage reporting is off for this session, but the preference was not saved and may return next launch: {detail}",
"TelemetryNoticeReceiptEnabled": "Anonymous usage counts are on. Conversations and code are never collected. Change this in /settings; schema: docs/TELEMETRY.md",
"TelemetryNoticeReceiptDisabled": "Anonymous usage counting is off. You will not be asked again.",
"TelemetryNoticeReceiptEnabledUnsaved": "Anonymous usage counts are on for this session. Codewhale could not save this notice, so it will appear again. Change this in /settings; schema: docs/TELEMETRY.md",
"TelemetryNoticeReceiptDisabledUnsaved": "Anonymous usage counting is off for this session. Codewhale could not save the choice, so it will ask again next launch.",
"TelemetryPreferenceEnabledNextLaunch": "Anonymous usage counts will be on next launch. Conversations and code are never collected.",
"TelemetryPreferenceDisabled": "Anonymous usage counts are off. Local telemetry state was erased.",
"TelemetryPreferenceDisabledWithWarning": "Anonymous usage counts are off, but part of the cleanup needs attention: {detail}",
"TelemetryPreferenceDisabledForSession": "Anonymous usage counts are off for this session, but the preference was not saved and may return next launch: {detail}",
"TelemetryPreferenceSaveFailed": "The telemetry preference could not be changed safely: {detail}",
"StatusPickerTitle": " Status line ",
"StatusPickerInstruction": "Choose what the footer shows:",
@@ -373,7 +373,7 @@
"ConfigLabelDefaultMode": "Startup mode",
"ConfigLabelAllowShell": "Shell access",
"ConfigLabelManagedAllowShell": "Shell access (managed)",
"ConfigLabelTelemetry": "Usage reporting",
"ConfigLabelTelemetry": "Anonymous usage counts",
"ConfigHintTelemetry": "Aggregate version, session, feature and error counts to Codewhale and PostHog; never content or IP. docs/TELEMETRY.md",
"ConfigValueTelemetryOn": "On",
"ConfigValueTelemetryOff": "Off",
+6 -51
View File
@@ -255,12 +255,11 @@ 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_outbound, dispatcher_pending).await;
dispatcher_task(rx_inbound, tx_diag, 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() {
@@ -537,19 +536,15 @@ 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);
Ok(frame)
tx.send(frame)
.await
.map_err(|_| anyhow!("LSP outbound channel closed"))?;
Ok(())
}
/// Background task that drains the outbound queue and writes each frame to
@@ -643,7 +638,6 @@ 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 {
@@ -660,19 +654,6 @@ 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;
@@ -799,31 +780,6 @@ 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");
@@ -1353,12 +1309,11 @@ 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, outbound, pending.clone()));
let dispatch = tokio::spawn(dispatcher_task(rx, diag, pending.clone()));
producer.write_all(&frame).await.unwrap();
assert!(
timeout(Duration::from_secs(1), receiver)
+47 -84
View File
@@ -3446,10 +3446,15 @@ impl Config {
return Some("project runtime configuration");
}
match managed_config_file(self.managed_config_path.as_deref()) {
Err(_) => return Some("an unreadable managed runtime configuration"),
Ok(None) => {}
Ok(Some(path)) => match load_single_config_file(&path) {
let managed_path = self
.managed_config_path
.as_deref()
.map(expand_path)
.or_else(default_managed_config_path);
if let Some(path) = managed_path
&& path.exists()
{
match load_single_config_file(&path) {
Ok(managed)
if managed.approval_policy.is_some()
|| managed.sandbox_mode.is_some()
@@ -3459,7 +3464,7 @@ impl Config {
}
Err(_) => return Some("an unreadable managed runtime configuration"),
Ok(_) => {}
},
}
}
let env_controls_runtime = || {
@@ -3545,16 +3550,21 @@ impl Config {
}
}
match managed_config_file(self.managed_config_path.as_deref()) {
Err(_) => return ApprovalPolicyControl::Ambiguous,
Ok(None) => {}
Ok(Some(path)) => match load_single_config_file(&path) {
let managed_path = self
.managed_config_path
.as_deref()
.map(expand_path)
.or_else(default_managed_config_path);
if let Some(path) = managed_path
&& path.exists()
{
match load_single_config_file(&path) {
Ok(managed) if managed.approval_policy.is_some() => {
return ApprovalPolicyControl::ManagedConfig;
}
Err(_) => return ApprovalPolicyControl::Ambiguous,
Ok(_) => {}
},
}
}
if approval_policy_env_is_set() {
@@ -3620,16 +3630,21 @@ impl Config {
return ShellAccessControl::ProjectConfig;
}
match managed_config_file(self.managed_config_path.as_deref()) {
Err(_) => return ShellAccessControl::Ambiguous,
Ok(None) => {}
Ok(Some(path)) => match load_single_config_file(&path) {
let managed_path = self
.managed_config_path
.as_deref()
.map(expand_path)
.or_else(default_managed_config_path);
if let Some(path) = managed_path
&& path.exists()
{
match load_single_config_file(&path) {
Ok(managed) if managed.allow_shell.is_some() => {
return ShellAccessControl::ManagedConfig;
}
Err(_) => return ShellAccessControl::Ambiguous,
Ok(_) => {}
},
}
}
if allow_shell_env_is_set() {
@@ -8917,10 +8932,9 @@ fn apply_env_overrides_unlocked(config: &mut Config, policy: ConfigEnvironmentPo
{
config.sandbox_api_key = Some(value);
}
if let Ok(value) = codewhale_env_var(
"CODEWHALE_MANAGED_CONFIG_PATH",
"DEEPSEEK_MANAGED_CONFIG_PATH",
) {
if let Ok(value) = std::env::var("CODEWHALE_MANAGED_CONFIG_PATH")
.or_else(|_| std::env::var("DEEPSEEK_MANAGED_CONFIG_PATH"))
{
config.managed_config_path = Some(value);
}
if policy.permits_secret_bearing_values()
@@ -10704,25 +10718,26 @@ fn warn_on_misplaced_top_level_keys(raw: &str) -> Option<String> {
}
fn apply_managed_overrides(config: &mut Config) -> Result<()> {
let Some(path) = managed_config_file(config.managed_config_path.as_deref())? else {
let path = managed_path_from_env()
.or_else(|| config.managed_config_path.as_deref().map(expand_path))
.or_else(default_managed_config_path);
let Some(path) = path else {
return Ok(());
};
let mut managed = load_single_config_file(&path).with_context(|| {
format!(
"the managed configuration {} cannot be loaded, and Codewhale does not start without a managed configuration it was told to use",
codewhale_config::quote_os_path(&path)
)
})?;
if !path.exists() {
return Ok(());
}
let mut managed = load_single_config_file(&path)?;
strip_external_credential_consent(&mut managed);
managed.tool_launcher = managed_tool_launcher(&path)?;
let prior_route = config
.active_provider_identity()
.map_err(|error| managed_route_error(config, &path, error))?;
.map_err(anyhow::Error::msg)?;
let mut merged = merge_config(config.clone(), managed.clone());
apply_layer_root_model(&mut merged, &managed);
let merged_route = merged
.active_provider_identity()
.map_err(|error| managed_route_error(&merged, &path, error))?;
.map_err(anyhow::Error::msg)?;
if managed.provider.is_some()
|| managed.default_text_model.is_some()
|| managed.legacy_model.is_some()
@@ -10873,62 +10888,10 @@ fn managed_tool_launcher(path: &Path) -> Result<Option<Vec<String>>> {
}
fn managed_path_from_env() -> Option<PathBuf> {
codewhale_env_var(
"CODEWHALE_MANAGED_CONFIG_PATH",
"DEEPSEEK_MANAGED_CONFIG_PATH",
)
.ok()
.map(|value| expand_path(&value))
}
fn managed_config_file(configured: Option<&str>) -> Result<Option<PathBuf>> {
if let Some(path) = managed_path_from_env() {
return Ok(Some(path));
}
let default = default_managed_config_path().filter(|path| {
fs::symlink_metadata(path).map_or_else(
|error| error.kind() != std::io::ErrorKind::NotFound,
|_| true,
)
});
let Some(configured) = configured
.filter(|value| !value.trim().is_empty())
.map(expand_path)
else {
return Ok(default);
};
if let Some(default) = default
&& default != configured
{
anyhow::bail!(
"`managed_config_path` names {}, but the default managed configuration {} exists, and a config file cannot replace it; name another managed file with CODEWHALE_MANAGED_CONFIG_PATH instead",
codewhale_config::quote_os_path(&configured),
codewhale_config::quote_os_path(&default)
);
}
Ok(Some(configured))
}
fn managed_route_error(config: &Config, path: &Path, error: String) -> anyhow::Error {
let selected = config.provider.as_deref().unwrap_or_default();
let undeclared = !selected.is_empty()
&& ProviderKind::parse(selected).is_none()
&& config
.providers
.as_ref()
.and_then(|providers| providers.custom_provider_config(selected))
.is_none();
if undeclared {
anyhow::anyhow!(
"provider '{selected}' has no `[providers.{selected}]` table in the configuration, and the managed configuration {} applies: a provider that only a plugin declares cannot be used under a managed configuration. Declare `[providers.{selected}]` in the config file, or select a built-in provider",
codewhale_config::quote_os_path(path)
)
} else {
anyhow::anyhow!(
"{error} (while applying the managed configuration {})",
codewhale_config::quote_os_path(path)
)
}
std::env::var("CODEWHALE_MANAGED_CONFIG_PATH")
.or_else(|_| std::env::var("DEEPSEEK_MANAGED_CONFIG_PATH"))
.ok()
.map(|value| expand_path(&value))
}
fn managed_path_is_named_by_host(path: &Path) -> bool {
+5 -4
View File
@@ -588,11 +588,12 @@ pub fn session_custom_provider_config(name: &str, kind: &str, base_url: &str) ->
// provider is contacted: the assertions are about which host string a route
// resolves to, and about the classifications derived from it.
/// A managed-config guard pointing at an empty managed file, so an
/// A managed-config guard pointing at a path that does not exist, so an
/// operator-installed managed file on the developer's machine cannot leak into
/// these route assertions.
pub fn no_managed_config(root: &std::path::Path) -> EnvVarGuard {
let path = root.join("empty-managed.toml");
std::fs::write(&path, "").expect("write empty managed config");
EnvVarGuard::set("CODEWHALE_MANAGED_CONFIG_PATH", path)
EnvVarGuard::set(
"DEEPSEEK_MANAGED_CONFIG_PATH",
root.join("absent-managed.toml"),
)
}
+16 -56
View File
@@ -3108,8 +3108,7 @@ fn policy_control_waits_for_foreign_test_env_overrides_to_restore() {
let temp = tempfile::tempdir().expect("tempdir");
let config_path = temp.path().join("config.toml");
let workspace = temp.path().join("workspace");
let managed_config_path = temp.path().join("empty-managed.toml");
fs::write(&managed_config_path, "").expect("empty managed config");
let managed_config_path = temp.path().join("missing-managed.toml");
let requirements_path = temp.path().join("missing-requirements.toml");
let (started_tx, started_rx) = mpsc::channel();
let (tx, rx) = mpsc::channel();
@@ -6477,7 +6476,10 @@ fn deepseek_model_env_overrides_default_text_model() -> Result<()> {
fn retired_deepseek_aliases_from_env_are_migrated_before_runtime() -> Result<()> {
let _lock = lock_test_env();
let temp_root = tempfile::tempdir()?;
let _managed_config = no_managed_config(temp_root.path());
let _managed_config = crate::test_support::EnvVarGuard::set(
"DEEPSEEK_MANAGED_CONFIG_PATH",
temp_root.path().join("missing-managed.toml"),
);
for (provider, alias, expected_effort) in [
("deepseek", "deepseek-chat", "off"),
@@ -12196,7 +12198,10 @@ fn tool_launcher_is_refused_outside_the_managed_layer() -> Result<()> {
let _lock = lock_test_env();
let temp_root = tempfile::tempdir()?;
let _guard = EnvGuard::new(temp_root.path());
let _managed = no_managed_config(temp_root.path());
let _managed = EnvVarGuard::set(
"CODEWHALE_MANAGED_CONFIG_PATH",
temp_root.path().join("absent-managed.toml"),
);
let config_path = temp_root.path().join("config.toml");
for document in [
"tool_launcher = [\"/usr/bin/sudo\", \"-n\", \"-u\", \"agent\", \"--\"]\n",
@@ -12367,62 +12372,17 @@ fn the_managed_file_named_by_the_host_survives_a_provider_that_resolves_late() -
ConfigEnvironmentPolicy::Runtime,
&mut late_provider,
);
let error = loaded.expect_err("a plugin provider does not load under a managed file");
assert!(
format!("{error:#}").contains("a provider that only a plugin declares"),
"{user_file}: {error:#}"
);
if let Ok(config) = loaded {
assert_eq!(
config.tool_launcher.as_deref(),
Some(["/usr/bin/sudo", "-n"].map(str::to_string).as_slice()),
"{user_file}"
);
}
}
Ok(())
}
#[test]
fn a_named_managed_file_that_cannot_be_read_is_a_load_error() -> Result<()> {
let _lock = lock_test_env();
let temp_root = tempfile::tempdir()?;
let _guard = EnvGuard::new(temp_root.path());
let _legacy_managed = EnvVarGuard::remove("DEEPSEEK_MANAGED_CONFIG_PATH");
let config_path = temp_root.path().join("config.toml");
fs::write(&config_path, "")?;
let missing = temp_root.path().join("missing-managed.toml");
for named in [missing.clone(), temp_root.path().to_path_buf()] {
let _managed = EnvVarGuard::set("CODEWHALE_MANAGED_CONFIG_PATH", &named);
let error = Config::load(Some(config_path.clone()), None).expect_err("closed");
assert!(
format!("{error:#}").contains("does not start without a managed configuration"),
"{error:#}"
);
}
let _managed = EnvVarGuard::remove("CODEWHALE_MANAGED_CONFIG_PATH");
fs::write(
&config_path,
format!(
"managed_config_path = {}\n",
toml::Value::String(missing.display().to_string())
),
)?;
let error = Config::load(Some(config_path.clone()), None).expect_err("closed");
assert!(
format!("{error:#}").contains("does not start without a managed configuration"),
"{error:#}"
);
fs::write(&config_path, "")?;
let empty = temp_root.path().join("empty-managed.toml");
fs::write(&empty, "")?;
let _blank = EnvVarGuard::set("CODEWHALE_MANAGED_CONFIG_PATH", "");
let _legacy = EnvVarGuard::set("DEEPSEEK_MANAGED_CONFIG_PATH", &missing);
let error = Config::load(Some(config_path.clone()), None).expect_err("blank defers");
assert!(
format!("{error:#}").contains("missing-managed.toml"),
"{error:#}"
);
let _legacy = EnvVarGuard::set("DEEPSEEK_MANAGED_CONFIG_PATH", &empty);
Config::load(Some(config_path), None)?;
Ok(())
}
#[cfg(all(target_os = "linux", not(target_env = "ohos")))]
#[test]
fn tool_launcher_is_refused_from_a_managed_file_the_user_config_names() -> Result<()> {
-16
View File
@@ -1371,9 +1371,6 @@ fn relativize_candidate(
.map(fs::Metadata::is_dir)
.unwrap_or_else(|| candidate.ends_with('/'));
if rel_path.as_os_str().is_empty() {
return None;
}
let rel_string = path_to_string(&rel_path)?;
Some((rel_string, exists, is_dir))
}
@@ -1658,19 +1655,6 @@ mod tests {
assert!(!entry.is_dir);
}
#[test]
fn workspace_root_is_not_an_active_path() {
let tmp = TempDir::new().expect("tempdir");
let root = tmp.path().canonicalize().expect("canonical root");
fs::write(root.join("笔记 📝.md"), "旧").expect("write");
let mut ws = WorkingSet::default();
let mention = format!("<file path=\"{}/笔记 📝.md\">", root.display());
ws.observe_user_message(&mention, &root);
assert!(!ws.entries.contains_key(""), "{:?}", ws.entries.keys());
}
#[test]
fn observe_tool_call_extracts_paths_from_input() {
let tmp = TempDir::new().expect("tempdir");
@@ -15,7 +15,7 @@
"in_model_catalogue": "true when the skill renders as an ambient catalogue line",
"shadowed_aliases": "aliases that collide with another canonical bundled name; the canonical skill wins resolution"
},
"generation": "22",
"generation": "21",
"skills": [
{
"name": "skill-creator",
@@ -1,86 +0,0 @@
---
name: plugin-creator
description: Scaffold a local Codewhale plugin bundle with a versioned manifest, namespaced Skills, and an explicit trust review.
---
# Plugin Creator
Use this skill when a user wants a local Codewhale plugin bundle. Trusted and
enabled bundles may add declarative Skills, commands, agents, hooks, and MCP
servers (stdio and remote) through the existing engines. LSP, filesystem
roots, and lifecycle mutation are inventory-only. Native extensions (host
code) are inventory-only unless the user has turned on the experimental
`[features] extension_host` flag.
## Workflow
1. Pick a Codewhale-owned location:
- User bundle: `~/.codewhale/plugins/<plugin-name>/`
- Workspace bundle: `<workspace>/.codewhale/plugins/<plugin-name>/`
2. Normalize the bundle name to lowercase hyphen-case.
3. Create `plugin.json` (Agent Plugins v1.0.0; a legacy `plugin.toml` stays
readable, but new bundles use `plugin.json`):
```json
{
"$schema": "https://agent-plugins.org/schemas/plugin.json",
"name": "my-plugin",
"version": "0.1.0",
"description": "What this bundle provides"
}
```
4. Put each Skill under `skills/<skill-name>/SKILL.md`; Codewhale finds
`skills/` automatically and exposes each as `my-plugin:<skill-name>`,
never as an unqualified command.
5. Add MCP servers in a sibling `mcp.json` only when the bundle needs an
existing MCP engine. Keep stdio commands and paths inside the bundle. Map local
environment values only as exact `${SOURCE_ENV}` references. For remote MCP,
use HTTPS (or loopback HTTP), forbid URL user information/query/fragment,
use only environment-backed headers or bearer tokens, and declare the exact
normalized endpoint host set in `capabilities.network_hosts` under
`extensions["net.codewhale"]`. Never place credentials in the manifest.
6. Commands (`commands/*.md`), agents (`agents/*.toml`), and hooks
(`hooks/*.toml`), declared under `extensions["net.codewhale"]`, activate
under the current policy — workspace bundles win same-name collisions over
user and built-in bundles. LSP, filesystem roots, and lifecycle mutation
are inventory-only: declare them only when inventorying future work. A
`native` entry runs only under the experimental extension host; there it
must be one `.mjs`, `.js` or `.mts` ES module file, `/plugin validate` rejects
anything else, and its tools always use `Required` approval, never a
plugin's read-only hint. Full Access, Bypass, or an exact session grant
for the reviewed build can satisfy that gate without a prompt. A bundle
that declares only unsupported surfaces cannot be enabled.
7. Validate and review without executing bundle content:
- `/plugin validate <plugin-name>`
- `/plugin show <plugin-name>`
- stop and present these results; the person runs `/plugin enable <plugin-name>`
to open the content/capability review, reviews it, runs the exact
`/plugin trust ...` confirmation shown, then enables the bundle
8. Verify `/skills inspect` reports plugin provenance and `/plugin list`
reports the expected trust and activation state. Trust stages the reviewed
content but does not activate it. After enablement, follow the host's
reload notice: use `/reload` or a new session to apply changes to a live
session's pinned skills and tools.
Every user and workspace bundle starts untrusted and disabled. Reuse the
existing `/plugin marketplace`, install, update, review and reload surfaces;
do not add a parallel installer, registry or automatic trust flow. Catalog
membership alone never installs, trusts or enables a plugin.
## Experimental host code
Only scaffold host code when the person explicitly uses the experimental
extension-host feature. Start from the tested `hello-extension` example and
`docs/EXTENSIONS.md` in the Codewhale repository. A typed `.mts` entry may use
Node's erasable TypeScript syntax; bundle dependencies locally. Register tools
with a plugin-specific prefix and an object input schema, propagate
`exec.signal`, and use `ctx.effect` for bounded asynchronous cleanup. The
current execution context exposes `signal`, `callId` and `args`; it does not
expose the calling workspace path. Do not change the shared process cwd.
Stop after install, validate and show; never automate the trust token. A
person reviews, trusts and enables the bundle. `/plugin show <name>` reports
owner state, live tools and recent attributed diagnostics. Recovery may create
fresh registrations, but never replays an interrupted tool call. Explain the
shared-process and current platform sandbox limits without claiming isolation.
@@ -1,6 +1,6 @@
---
name: plugin-creator
description: Scaffold a local Codewhale plugin bundle with a versioned manifest, namespaced Skills, and a trust review that goes through the session's approval.
description: Scaffold a local Codewhale plugin bundle with a versioned manifest, namespaced Skills, and an explicit trust review.
---
# Plugin Creator
@@ -54,24 +54,9 @@ code) are inventory-only unless the user has turned on the experimental
7. Validate and review without executing bundle content:
- `/plugin validate <plugin-name>`
- `/plugin show <plugin-name>`
- present these results; the person runs `/plugin enable <plugin-name>`
- stop and present these results; the person runs `/plugin enable <plugin-name>`
to open the content/capability review, reviews it, runs the exact
`/plugin trust ...` confirmation shown, then enables the bundle
- exception: in Full Access, for a plugin you wrote yourself, you may
trust and enable it without asking the person. Write it in an ordinary
folder inside the current project (for example
`<workspace>/plugins/<plugin-name>/`), not under `.codewhale/plugins/`
as in step 1, because `codewhale install` copies it into place and
refuses a name that is already in use. Then run, in this order:
`codewhale install <folder>`, `codewhale plugins trust <name>` (it prints
the review and a review token), `codewhale plugins trust <name>
--review-token <token> --yes` with the token from your own run, and
`codewhale plugins enable <name>`. These are shell commands, so the
session's approval applies to them like any other (Ask asks the person,
Auto-Review's reviewer judges, Full Access runs them). The trust is
recorded as approved by the agent, for this project only. A bundle that
was not installed this way from inside the project is refused. Never do
this for a plugin you did not write.
8. Verify `/skills inspect` reports plugin provenance and `/plugin list`
reports the expected trust and activation state. Trust stages the reviewed
content but does not activate it. After enablement, follow the host's
@@ -80,18 +65,22 @@ code) are inventory-only unless the user has turned on the experimental
Every user and workspace bundle starts untrusted and disabled. Reuse the
existing `/plugin marketplace`, install, update, review and reload surfaces;
do not add a parallel installer or registry. Catalog membership alone never
installs, trusts or enables a plugin.
do not add a parallel installer, registry or automatic trust flow. Catalog
membership alone never installs, trusts or enables a plugin.
## Plugins with code or a screen
## Experimental host code
For a plugin that runs code, adds tools of its own, or shows a screen, use the
`cordis-plugin-development` skill when it is offered: it teaches the format
that also loads in DeepSeek Harness. Host code runs only under the
experimental `[features] extension_host` flag; start from the tested
`hello-extension` example and `docs/EXTENSIONS.md` in the Codewhale repository,
add a `plugin.json` beside the package for Codewhale's own kinds, and never
change the shared process cwd. A person reviews and turns on a plugin as step 7
says. `/plugin show <name>` reports owner state, live tools and recent
attributed diagnostics. Explain the shared-process and current platform sandbox
limits without claiming isolation.
Only scaffold host code when the person explicitly uses the experimental
extension-host feature. Start from the tested `hello-extension` example and
`docs/EXTENSIONS.md` in the Codewhale repository. A typed `.mts` entry may use
Node's erasable TypeScript syntax; bundle dependencies locally. Register tools
with a plugin-specific prefix and an object input schema, propagate
`exec.signal`, and use `ctx.effect` for bounded asynchronous cleanup. The
current execution context exposes `signal`, `callId` and `args`; it does not
expose the calling workspace path. Do not change the shared process cwd.
Stop after install, validate and show; never automate the trust token. A
person reviews, trusts and enables the bundle. `/plugin show <name>` reports
owner state, live tools and recent attributed diagnostics. Recovery may create
fresh registrations, but never replays an interrupted tool call. Explain the
shared-process and current platform sandbox limits without claiming isolation.
+1 -5
View File
@@ -42,7 +42,7 @@ use sha2::{Digest, Sha256};
/// person; the exact generation-16 body allows a safe upgrade.
/// Generation 18 recognizes previously shipped bodies by SHA-256 as well as by
/// retained bytes, so an untouched copy from any earlier generation upgrades.
const BUNDLED_SKILL_VERSION: &str = "22";
const BUNDLED_SKILL_VERSION: &str = "21";
mod dsh_creator;
use dsh_creator::{BundledFile, CREATOR_SKILLS};
@@ -176,10 +176,6 @@ const SUPERSEDED_BODIES: &[(&str, &str)] = &[
"plugin-creator",
include_str!("../assets/skills/plugin-creator/SKILL.generation-16.md"),
),
(
"plugin-creator",
include_str!("../assets/skills/plugin-creator/SKILL.generation-19.md"),
),
(
"help",
include_str!("../assets/skills/help/SKILL.generation-14.md"),
@@ -70,7 +70,6 @@ plugin-creator 935a64e72137a2420910a3365f8d4000038987fa0753648e9751dd79eae5cc30
plugin-creator a22e9b25e4ba8eac43007899547bf4471e79a18d3b55f60df4cf073271529104
plugin-creator b150b79446d876a2cd572aacd7be7712fd9914c340b51969a29186f85db4e18a
plugin-creator c20a792055a4b5601e58b2fb721cd896a34f572a4acdabd1a706d46ce77eb639
plugin-creator c761d6774b751a985ffed2894f595d8f871430b0a05a8e94bb7245399d811de7
plugin-creator ce156ea9c6e2896b9c6c0da08f3d78c27bb793f685bcd2afbbffd712a5f64387
plugin-creator e91aa6dfde62abd9548dda7a43bc097a82aa19385335117e86cb204edc9e294d
presentations 2b6d20a00280f3de7814ebd8870e6e1af08c11bd115603b265fc31b9004f9d35
+2 -10
View File
@@ -874,8 +874,7 @@ fn generation_17_refreshes_previous_plugin_creator_bodies() {
.find(|skill| skill.name == "plugin-creator")
.unwrap();
assert!(skill.body.contains("Create `plugin.json`"));
assert!(skill.body.contains("cordis-plugin-development"));
assert!(!skill.body.contains("never automate the trust token"));
assert!(skill.body.contains("## Experimental host code"));
for (generation, old) in [
(
"15",
@@ -885,16 +884,9 @@ fn generation_17_refreshes_previous_plugin_creator_bodies() {
"16",
include_str!("../../assets/skills/plugin-creator/SKILL.generation-16.md"),
),
(
"19",
include_str!("../../assets/skills/plugin-creator/SKILL.generation-19.md"),
),
] {
assert!(is_superseded_shipped_body("plugin-creator", old));
assert_eq!(
old.contains("## Experimental host code"),
generation == "19"
);
assert!(!old.contains("## Experimental host code"));
for customized in [false, true] {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(skill_dir(&tmp, "plugin-creator")).unwrap();
+1 -36
View File
@@ -1457,7 +1457,6 @@ impl SnapshotRepo {
.arg(&self.git_dir)
.arg("--work-tree")
.arg(&self.work_tree)
.args(["-c", "core.quotePath=false"])
.args(args);
let output = run_bounded_git(&mut cmd, subcommand)?;
if !output.status.success() {
@@ -1851,7 +1850,7 @@ impl SnapshotRepo {
.duration_since(UNIX_EPOCH)
.map_err(|e| io_other(format!("clock error: {e}")))?
.as_secs() as i64;
let cutoff = now.saturating_sub(i64::try_from(max_age.as_secs()).unwrap_or(i64::MAX));
let cutoff = now - max_age.as_secs() as i64;
let snapshots = self.list(usize::MAX)?;
if snapshots.is_empty() {
@@ -3949,22 +3948,6 @@ mod tests {
assert_eq!(list[0].label, "turn:1");
}
#[test]
fn prune_with_an_age_beyond_i64_keeps_every_snapshot() {
let tmp = tempdir().unwrap();
let (repo, _home) = make_repo(tmp.path());
std::fs::write(repo.work_tree().join("f.txt"), "v0").unwrap();
repo.snapshot("turn:0").unwrap();
std::thread::sleep(Duration::from_millis(1100));
let removed = repo
.prune_older_than(Duration::from_secs(u64::MAX))
.unwrap();
assert_eq!(removed, 0);
assert_eq!(repo.list(10).unwrap().len(), 1);
}
/// The 2026-08-04 regression: with a cut in the MIDDLE of history,
/// `prune_older_than` used to `update-ref HEAD <oldest survivor>`, which
/// orphaned (and gc destroyed) the NEWEST snapshots while keeping the
@@ -4909,24 +4892,6 @@ mod tests {
assert_eq!(none.patch, "");
}
#[test]
fn work_tree_changes_since_names_non_ascii_paths_as_written() {
let tmp = tempdir().unwrap();
let (repo, _home) = make_repo(tmp.path());
let ws = repo.work_tree().to_path_buf();
std::fs::write(ws.join("笔记 📝.md"), b"old\n").unwrap();
let before = repo.snapshot("pre-turn:1").expect("snapshot");
git_output(&repo, &["config", "core.quotePath", "true"]);
std::fs::write(ws.join("笔记 📝.md"), b"new\n").unwrap();
let changes = repo
.work_tree_changes_since(&before, 1 << 20)
.expect("changes");
assert_eq!(changes.names, "笔记 📝.md\n");
assert!(changes.stat.contains("笔记 📝.md"), "{}", changes.stat);
assert!(changes.patch.contains("b/笔记 📝.md"), "{}", changes.patch);
}
/// A tree the repo no longer holds — the receipt outlived the object —
/// is reported as absent rather than as a diff failure, so a caller can
/// tell pruning from a broken repo without reading git's stderr.
+2 -37
View File
@@ -11,7 +11,7 @@ mod runtime_aliases;
use std::collections::HashMap;
use std::fs::{self, OpenOptions};
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::sync::{Arc, LazyLock, Mutex, MutexGuard};
@@ -1453,7 +1453,6 @@ impl StateStore {
self.with_session_index_lock(|| {
let mut file = OpenOptions::new()
.create(true)
.read(true)
.append(true)
.open(&self.session_index_path)
.with_context(|| {
@@ -1462,14 +1461,7 @@ impl StateStore {
self.session_index_path.display()
)
})?;
let mut last = *b"\n";
if file.metadata()?.len() > 0 {
file.seek(SeekFrom::End(-1))?;
file.read_exact(&mut last)?;
}
let separator = if last[0] == b'\n' { "" } else { "\n" };
writeln!(file, "{separator}{encoded}")
.context("failed to append session index entry")?;
writeln!(file, "{encoded}").context("failed to append session index entry")?;
// Durability: without this a crash mid-write can leave a torn
// final line. Reads tolerate one (see `session_index_map`), but
// not losing the entry beats recovering from having lost it.
@@ -2930,33 +2922,6 @@ mod tests {
);
}
#[test]
fn session_index_append_after_an_unterminated_torn_line_is_kept() {
let store = temp_state_store("session-index-unterminated");
store
.append_thread_name("thread-1", Some("first".to_string()), 1, None)
.expect("append first entry");
{
let mut file = OpenOptions::new()
.append(true)
.open(&store.session_index_path)
.expect("open session index");
write!(file, "{{\"thread_id\":\"thread-2\",\"thread_na").expect("write torn line");
}
store
.append_thread_name("thread-3", Some("third".to_string()), 3, None)
.expect("append third entry");
assert_eq!(
store
.find_thread_name_by_id("thread-3")
.expect("lookup thread-3")
.as_deref(),
Some("third"),
);
}
/// Hook fired by compaction between writing the snapshot and renaming it
/// over the live index — the window a concurrent append can be lost in.
/// Only the store whose path a test registered is affected, so tests
+1 -17
View File
@@ -7,23 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- **Renaming sessions with Chinese titles:** the session picker no longer rejects a Chinese, Japanese, Korean or emoji title of more than about 33 characters as "Title must be 1–100 characters". It now counts characters, as the message says and as `/rename` and the Runtime API already did, so any title up to 100 characters is accepted. Thanks [@luochen211](https://github.com/luochen211) (PR 7322).
- **Non-ASCII file names:** `/diff`, the `@git` and `@diff` mentions and the Runtime API workspace diff now show paths with Chinese, emoji and other non-ASCII characters as written instead of as quoted octal escapes, and the turn context no longer lists the workspace root as an empty "Active paths" entry. Thanks [@luochen211](https://github.com/luochen211) (PR 7317).
- **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).
- **MCP and language servers that send requests mid-call:** a tool call no longer fails with "neither a result nor an error" when an MCP server sends a `ping` or other request that reuses the call's id while the call is running, and a language server's `workspace/configuration` or similar request no longer ends a pending LSP request. Codewhale now answers `ping`, answers other server requests with "Method not found" (it never advertised them), keeps waiting for the real reply, and a refused answer over Streamable HTTP no longer drops the connection to legacy SSE. Thanks [@luochen211](https://github.com/luochen211) (PR 7319).
- **Qwen Code as an external agent:** Codewhale now finds an installed Qwen Code CLI (`qwen`, 0.6.1 or newer) and can run it as a session over ACP with `qwen --acp`. `GET /v1/agents/external` lists it, an older install is reported as needing `npm i -g @qwen-code/qwen-code@latest`, and sign-in stays inside Qwen Code (`/auth`). Thanks [@MannXo](https://github.com/MannXo) (#7293).
- **README and contributor credit:** the repository front page now opens the way the website does: the line "The model can be anyone's. The agent should be yours.", one sentence on what Codewhale is, the install command and one picture, with reference detail moved into the linked guides; the 18 translated READMEs follow the same structure and say they are machine-drafted. The contributor wall and the website now count the same 433 people (407 GitHub accounts and the commit authors whose email is not linked to an account), where the website had counted accounts only. `DEPENDENCY_SECURITY.md` moved into `docs/dependency-maintenance.md`, and `.github/SUPPORT.md` says where to ask for help.
- **Website Plugins page:** The website's Plugins page now describes what the released version does, and a labelled section says what 0.10.3 adds: one Plugins place, Standard and Creator mode, and the Permissions control.
- **First-time guide on the website:** a new docs page, "Use Codewhale for the first time" (`/docs/first-time`, English and Simplified Chinese), walks someone who has never used a terminal through opening one, installing, choosing the folder Codewhale works in and when that choice matters, opening a terminal in a folder from Finder or File Explorer, connecting a model, a first task, and stopping, undoing and quitting. The install page gains a short "New to the terminal?" block for macOS and Windows that links to it, and `llms.txt` carries the same steps.
- **Plugin docs and plugin-creator:** Docs now say everything you add is a plugin. The bundled plugin-creator skill no longer tells an agent never to trust or enable a plugin (in Full Access it may, for one it wrote and installed from its project), points code and screen plugins at cordis-plugin-development, and an untouched copy from 0.10.2 now upgrades. The Chinese extension and Runtime API docs now say editor ACP sessions get plugin tools and configured MCP servers.
- **Model thinking:** New installs show model thinking by default: it streams live in the transcript and collapses under a "Thought for …" header when the answer starts. Existing installs keep their saved setting; turn it on under Model reasoning in chat in /config.
- **Footer clock:** The footer clock and phase now say "thinking", matching the transcript.
- **Paused stream:** A paused stream now shows the thinking it has already received instead of waiting for the next token.
- **Sign-in notice:** The "Account not loaded" notice now appears once per sign-in in the footer, and Esc dismisses it; it no longer lands in the transcript.
- **Usage reporting:** Installers now run their closing diagnostic with usage reporting and update checks off and print how to turn usage reporting off. `codewhale doctor` now says a saved opt-out comes from the config file, not the environment, and help and English notices call the feature "usage reporting".
- **Long tool names:** Tools whose encoded name is longer than the route's limit are now left out with a visible warning instead of failing every request with a 400. The limit is 64 characters on OpenAI and on other OpenAI-compatible providers that document no larger limit, and 128 on Anthropic-style routes, DeepSeek, Ollama, Ollama Cloud, vLLM, SGLang and custom routes. Forcing such a tool gives an error that names the tool and the limit, and a session that already called one on a route with a documented limit (Anthropic, OpenAI, Codex, DeepSeek) is refused with a message saying to switch route or start a new session. A forced tool_choice in any chat form now names the encoded tool, and a tool whose real name looks like an encoded escape can no longer be mistaken for another tool.
- **Start after a hard kill:** The Engine now also starts after a hard kill when its home was spelled through a symbolic link (for example under /tmp on macOS) or, on macOS and Windows, in a different letter case: a dead owner's record for the deleted data directory is discarded instead of refusing every start.
- **Context window labels:** the model picker and fleet capability badges now show `1.5M` and `2.1M` for 1,500,000- and 2,100,000-token windows instead of `1.50M` and `2.10M`. Thanks [@Aboysky](https://github.com/Aboysky) (PR 7295).
- **REPL output limit:** REPL output is now cut to its byte limit, so Chinese, Japanese, Korean or emoji output no longer comes back up to three times longer than the limit, and the cut never splits a character. Thanks [@Aboysky](https://github.com/Aboysky) (PR 7296).
- **Git status with renamed files:** the Git and Changes views no longer fail with "git status failed" or show a phantom row when a file is renamed on the worktree side (for example a moved file marked with `git add -N`), including when the original name starts with non-ASCII text such as Chinese. Thanks [@luochen211](https://github.com/luochen211) (PR 7310).
@@ -38,7 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Simplified Chinese punctuation and /status:** in the zh-Hans interface, the `/router` setup and test screens, label prompts and usage lines now use full-width punctuation (`:;,()`) instead of ASCII marks next to Chinese text, and `/status` values start in one column under Chinese labels instead of starting at a different column for each label. Thanks [@luochen211](https://github.com/luochen211) (PR 7314).
- **Git tool on older git:** the Git tool's `review_scope` action no longer passes `--no-lazy-fetch`, which git older than 2.44 rejects, so it failed on every call there. It sets `GIT_NO_LAZY_FETCH=1` instead, which keeps the same protection on newer git and is ignored by older git.
- **Plugins from a shell:** `codewhale plugins trust <name>` prints the same capability review as `/plugin trust` with its bundle review token and, in a terminal, asks once. `--review-token <token> --yes` trusts without a prompt while that token still matches the installed content and capabilities; the token ties trust to the exact content, does not show that anyone read the review, and is not the host-service review token. A `--yes` trust is recorded as the command line and grants no host service. Every command the agent runs (shell, persistent terminal, verifier and test runs, the Python kernel, MCP servers it starts) now carries `CODEWHALE_AGENT_PROJECT` with the session's project, while hooks and configured MCP servers do not; with it, a trust or enable is recorded as the agent's, for that project only, even when a prompt is answered through a terminal, and the agent may trust only a plugin it installed from a folder inside the project. `plugins enable`, `disable` and `revoke` work the same way, each with `--json`, and `plugins status` shows who approved. The agent's shell commands that run them go through the session's approval like any other shell command, so a Full Access agent can trust and enable a plugin it wrote; the mark is a label the agent could remove in Full Access. (CW-7891)
- **Sandbox:** a managed configuration can set `tool_launcher` (Linux) to run every sandboxed tool command as another user, in front of bubblewrap; commands then run inside the sandbox or not at all, masks are computed for that user, `/tmp` is private per command, and `GET /v1/runtime/info` reports `sandbox.kind` and `sandbox.tool_launcher`. Without the key nothing changes. A managed configuration named by `CODEWHALE_MANAGED_CONFIG_PATH` or `managed_config_path` is applied on every load, and one that is missing or cannot be read stops the load instead of loading with no managed layer; a blank `CODEWHALE_MANAGED_CONFIG_PATH` is now ignored instead of switching the managed layer off, and a config file's `managed_config_path` can no longer point away from an existing file at the default managed path. Fleet local workers inherit the managed-config variable, and `codewhale serve --mcp` does not expose shell tools when the managed configuration it loads sets `tool_launcher`. A configuration whose provider is declared only by a plugin fails to load under a managed configuration, with an error that says to declare the provider table or pick a built-in provider.
- **Sandbox:** a managed configuration can set `tool_launcher` (Linux) to run every sandboxed tool command as another user, in front of bubblewrap; commands then run inside the sandbox or not at all, masks are computed for that user, `/tmp` is private per command, and `GET /v1/runtime/info` reports `sandbox.kind` and `sandbox.tool_launcher`. Without the key nothing changes. A managed configuration named by `CODEWHALE_MANAGED_CONFIG_PATH` is now applied on every load; a configuration whose provider comes from a plugin no longer loads with the managed file skipped, it fails to load instead.
- **Receipts:** a terminal session's receipt (`/receipts`, `codewhale receipts`) now shows the provider's token counts and the same cost report `/cost` prints, saying "unknown" and why when the route's price is not known, never a guess. A Runtime thread's receipt shows token counts only; its cost line follows in a later release. `codewhale metrics` now counts usage saved in terminal sessions instead of reporting none. Sessions saved by 0.10.2 carry only a combined token total.
- **ACP server on the official SDK:** The ACP server (`codewhale serve --acp`) now runs on the official Agent Client Protocol SDK. Its messages follow the official v1 types, and it no longer advertises the non-standard `modelSelection` capability or session `models` field. Session list entries carry `createdAt` and `messageCount` under `_meta.codewhale`, terminal sign-in methods are advertised only to clients that enabled terminal authentication, a notification is never answered, a permission request always carries text, and session list and configuration requests are served while a turn runs, response ids are echoed unchanged for every client (the Zed-only string rewrite is gone), and `initialize` without `protocolVersion` is refused with `-32602`.
- **External agents over ACP:** Codewhale now drives external agents through the official Agent Client Protocol SDK. Request ids on the wire are strings, and an agent's error is treated as a sign-in problem only when it carries the protocol's auth-required code.
@@ -157,8 +157,7 @@ fn session_diff(app: &App) -> std::io::Result<Option<DebugDiffObservation>> {
/// The workspace repository's own uncommitted changes: what `/diff` shows
/// before the session has a restore point to compare against.
fn workspace_git_diff(workspace: &std::path::Path) -> DebugDiffObservation {
let git =
|args: &[&str]| Git::output(&[&["-c", "core.quotePath=false"], args].concat(), workspace);
let git = |args: &[&str]| Git::output(args, workspace);
// Outside a repository `git diff` prints nothing on stdout, which would
// read as "no changes".
match git(&["rev-parse", "--is-inside-work-tree"]) {
+1 -30
View File
@@ -3216,7 +3216,6 @@ async fn run_async_main_dispatch(
workspace,
skills_dir,
skills_config,
config.tool_launcher.is_some(),
)
.await
} else if http_selected {
@@ -7868,7 +7867,7 @@ fn doctor_control_socket_posture_line(config: &Config) -> String {
/// Report the durable opt-out and environment preference without arming or
/// touching telemetry state. Notice presentation is not a permission gate.
fn doctor_runtime_telemetry(config: &Config) -> (bool, &'static str) {
let (on, source) = codewhale_config::dispatched_telemetry_consent(config.telemetry);
let (on, source) = codewhale_config::resolved_telemetry_consent(config.telemetry);
if !on {
return (false, source.as_str());
}
@@ -16559,8 +16558,6 @@ mod doctor_setup_state_tests {
let _telemetry_alias_env = crate::test_support::EnvVarGuard::remove("DEEPSEEK_TELEMETRY");
let _telemetry_floor =
crate::test_support::EnvVarGuard::remove("CODEWHALE_TELEMETRY_FLOOR");
let _telemetry_source =
crate::test_support::EnvVarGuard::remove("CODEWHALE_TELEMETRY_SOURCE");
let workspace = tmp.path().join("workspace");
fs::create_dir_all(&workspace).expect("workspace");
@@ -16611,32 +16608,6 @@ mod doctor_setup_state_tests {
let report = doctor_setup_report_json(&config, &workspace);
assert_eq!(report["runtime_posture"]["telemetry"]["value"], false);
assert_eq!(report["runtime_posture"]["telemetry"]["source"], "config");
{
let _handed_down =
crate::test_support::EnvVarGuard::set("CODEWHALE_TELEMETRY", "false");
let _floor = crate::test_support::EnvVarGuard::set("CODEWHALE_TELEMETRY_FLOOR", "0");
let _source =
crate::test_support::EnvVarGuard::set("CODEWHALE_TELEMETRY_SOURCE", "config");
let line = doctor_runtime_posture_line(&config, &workspace);
assert!(
line.contains("telemetry=off (config)"),
"a saved opt-out the dispatcher handed down is not the environment: {line}"
);
let report = doctor_setup_report_json(&config, &workspace);
assert_eq!(report["runtime_posture"]["telemetry"]["source"], "config");
}
{
let _by_hand = crate::test_support::EnvVarGuard::set("CODEWHALE_TELEMETRY", "0");
let _floor = crate::test_support::EnvVarGuard::set("CODEWHALE_TELEMETRY_FLOOR", "1");
let _source =
crate::test_support::EnvVarGuard::set("CODEWHALE_TELEMETRY_SOURCE", "env");
let line = doctor_runtime_posture_line(&config, &workspace);
assert!(
line.contains("telemetry=off (env)"),
"a kill switch the person set is the environment: {line}"
);
}
}
#[test]
+2 -2
View File
@@ -284,8 +284,8 @@ impl ContextMenuView {
if start > 0 {
rows.push(MenuRow::MoreAbove(start));
}
for (idx, entry) in self.entries.iter().enumerate().take(end).skip(start) {
if idx > start && entry.section_start {
for idx in start..end {
if idx > start && self.entries[idx].section_start {
rows.push(MenuRow::Divider);
}
rows.push(MenuRow::Entry(idx));
-19
View File
@@ -403,25 +403,6 @@ mod tests {
assert!(!payload.truncated);
}
#[test]
fn mentions_name_non_ascii_paths_as_written() {
let dir = tempfile::tempdir().unwrap();
init_repo(dir.path());
Command::new("git")
.args(["config", "core.quotePath", "true"])
.current_dir(dir.path())
.output()
.unwrap();
std::fs::write(dir.path().join("笔记 📝.md"), "旧\n").unwrap();
commit_all(dir.path(), "initial");
std::fs::write(dir.path().join("笔记 📝.md"), "新\n").unwrap();
let status = resolve_git_mention(GitMentionKind::Status, dir.path());
let diff = resolve_git_mention(GitMentionKind::Diff, dir.path());
assert!(status.block.contains("笔记 📝.md"), "{}", status.block);
assert!(diff.block.contains("b/笔记 📝.md"), "{}", diff.block);
}
#[test]
fn diff_covers_staged_and_unstaged_changes() {
let dir = tempfile::tempdir().unwrap();
+3 -3
View File
@@ -416,15 +416,15 @@ mod tests {
#[test]
fn a_notice_sheds_whole_clauses_and_never_dangles() {
const NOTICE: &str = "Usage reporting is on. Conversations and code are never collected. Change this in /settings; schema: docs/TELEMETRY.md";
const NOTICE: &str = "Anonymous usage counts are on. Conversations and code are never collected. Change this in /settings; schema: docs/TELEMETRY.md";
assert_eq!(fit_notice(NOTICE, 200, false).as_deref(), Some(NOTICE));
assert_eq!(
fit_notice(NOTICE, 80, false).as_deref(),
Some("Usage reporting is on. Conversations and code are never collected.")
Some("Anonymous usage counts are on. Conversations and code are never collected.")
);
assert_eq!(
fit_notice(NOTICE, 40, false).as_deref(),
Some("Usage reporting is on.")
Some("Anonymous usage counts are on.")
);
assert_eq!(fit_notice(" ", 40, false), None);
}
+3 -25
View File
@@ -22,8 +22,8 @@ use crate::tui::views::{
};
use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
use codewhale_engine::session_manager::{
MAX_SESSION_TITLE_CHARS, SavedSession, SessionListFilter, SessionManager, SessionMetadata,
extract_title, extract_user_prompt, strip_thinking_tags,
SavedSession, SessionListFilter, SessionManager, SessionMetadata, extract_title,
extract_user_prompt, strip_thinking_tags,
};
use codewhale_engine::session_projection::{MAX_PROJECTED_SESSIONS, SessionQuery, SessionSortMode};
use codewhale_localization::{Locale, MessageId, tr};
@@ -787,7 +787,7 @@ impl SessionPickerView {
self.status = Some(tr(self.locale, MessageId::SessionsNoSelection).into_owned());
return ViewAction::None;
};
if new_title.is_empty() || new_title.chars().count() > MAX_SESSION_TITLE_CHARS {
if new_title.is_empty() || new_title.len() > 100 {
self.status = Some(tr(self.locale, MessageId::SessionsTitleLength).into_owned());
return ViewAction::None;
}
@@ -2045,28 +2045,6 @@ mod tests {
);
}
#[test]
fn rename_selected_limits_title_by_characters() {
let _lock = crate::test_support::lock_test_env();
let tmp = tempfile::tempdir().expect("tempdir");
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path());
let manager = SessionManager::default_location().expect("session manager");
let mut saved = saved_session_with_messages(vec![text_message("user", "hello")]);
saved.metadata.id = "session-cjk".to_string();
manager.save_session(&saved).expect("save session");
let mut view = picker_with(vec![saved.metadata.clone()], None);
let title = "中文会话标题".repeat(6);
assert!(matches!(
view.rename_selected(&title),
ViewAction::Emit(ViewEvent::SessionRenamed { .. })
));
assert_eq!(
manager.load_session("session-cjk").unwrap().metadata.title,
title
);
}
#[test]
fn archive_toggle_persists_hides_the_row_and_emits_an_archive_receipt() {
let _lock = crate::test_support::lock_test_env();
+4 -4
View File
@@ -1441,8 +1441,8 @@ fn prose_cells_fill_full_width_on_ultrawide_by_default() {
let mut cache = TranscriptViewCache::new();
cache.ensure_filtered(&refs, &revisions, 220, options, &HashMap::new(), None, None);
for (idx, cell) in cache.per_cell.iter().enumerate().take(3) {
let width = max_line_width(&cell.lines);
for idx in 0..3 {
let width = max_line_width(&cache.per_cell[idx].lines);
assert!(
width > RETIRED_RAIL_MEASURE,
"prose cell {idx} wrapped to {width} columns — still on the retired \
@@ -1495,8 +1495,8 @@ fn transcript_prose_measure_caps_prose_but_not_tools() {
let mut cache = TranscriptViewCache::new();
cache.ensure_filtered(&refs, &revisions, 220, options, &HashMap::new(), None, None);
for (idx, cell) in cache.per_cell.iter().enumerate().take(3) {
let width = max_line_width(&cell.lines);
for idx in 0..3 {
let width = max_line_width(&cache.per_cell[idx].lines);
assert!(
width > 105,
"prose cell {idx} wrapped to {width} columns — still on the retired \
+1 -1
View File
@@ -1110,7 +1110,7 @@ mod screen_mode_tests {
viewport: ratatui::Viewport::Inline(3),
},
)
.map_err(|err| match err {})
.map_err(|err| io::Error::other(err.to_string()))
},
)
.expect("a successful probe must switch");
+4 -3
View File
@@ -8950,9 +8950,10 @@ fn setup_runtime_presets_preserve_environment_authority_and_state() {
"DEEPSEEK_SANDBOX_MODE",
]
.map(EnvVarGuard::remove);
let empty_managed = home._tmp.path().join("empty-managed.toml");
std::fs::write(&empty_managed, "").expect("empty managed config");
let _managed = EnvVarGuard::set("CODEWHALE_MANAGED_CONFIG_PATH", &empty_managed);
let _managed = EnvVarGuard::set(
"CODEWHALE_MANAGED_CONFIG_PATH",
home._tmp.path().join("absent-managed.toml"),
);
let _requirements = EnvVarGuard::set(
"CODEWHALE_REQUIREMENTS_PATH",
home._tmp.path().join("absent-requirements.toml"),
+1 -1
View File
@@ -2693,7 +2693,7 @@ reasoning contract, and all four membership ids omit generic sampling fields.
audit logging is enabled. Future PreToolUse/PostToolUse hooks can add
observer input around this layer, but the configured auto-review policy is
evaluated before a tool call is allowed to proceed.
- `managed_config_path` (string, optional): managed config file loaded after user/env config. `CODEWHALE_MANAGED_CONFIG_PATH` outranks it. A named file that is missing or unreadable is a load error, and it cannot replace an existing file at the default managed path.
- `managed_config_path` (string, optional): managed config file loaded after user/env config.
- `requirements_path` (string, optional): requirements file used to enforce allowed approval/sandbox values.
- `max_subagents` (int, optional): defaults to `64` and is clamped to `1..=128`.
- `subagents.*` (optional compatibility table): manual per-role model pins
-1
View File
@@ -44,7 +44,6 @@ changelog or release notes, and this contributor record.
**Contributions**
- **[MannXo](https://github.com/MannXo)** — added Qwen Code to the external agent registry so an installed Qwen Code CLI runs as a session over ACP ([#7293](https://github.com/codewhale-hq/Codewhale/pull/7293)).
- **[SamhandsomeLee](https://github.com/SamhandsomeLee)** — drove Responses request, URL and stream behavior from one checked dialect profile table ([#7261](https://github.com/codewhale-hq/Codewhale/pull/7261)).
- **[gaord](https://github.com/gaord)** — ran the read-only `/system`, `/tokens` and `/cost` builtins over the Runtime API through one gated terminal-host catalog ([#7277](https://github.com/codewhale-hq/Codewhale/pull/7277)).
+6 -13
View File
@@ -129,19 +129,12 @@ the `agent/created` hook fires only for children the same plugin starts.
## Plugin UI (`dsh.client`)
An imported DSH plugin's client UI is served, on the seats the Engine admits.
The importer retains the reviewed client closure; at serve time the Engine
re-verifies it byte for byte against the reviewed hashes, maps each declared
slot key to a seat, and loads one Engine-generated bootstrap built from those
bytes. A native client is admitted only on `customize.plugins.detail`. A slot
key with no seat answers `plugin_ui_seat_unsupported`; several keys on one seat
need the caller to name one (`plugin_ui_dsh_slot_required`). A closure that needs
a host module the frame does not provide, uses ES module syntax or is not valid
JSON answers `plugin_ui_unsupported`. Code Review 1.0.8's client decorates
global command candidates through DSH services that the current detail-slot
renderer does not provide, so it is not qualified.
The route that forwards a client's requests to a plugin web server answers
`plugin_route_not_served` (501): no plugin web server exists yet.
Imported DSH client UIs are not supported. The Engine refuses their client
descriptor and asset grants when the reviewed conversion report identifies a
DSH import; the plugin's supported Native and MCP contributions keep their
existing lifecycle. Code Review 1.0.8's client decorates global command
candidates through DSH services that the current detail-slot renderer does
not provide.
The selected Electron host's reviewed asset transport and isolated frame do
not establish compatibility with those services. Its real network containment
-11
View File
@@ -161,17 +161,6 @@ separately from the #6953 reporter-observed exports; the x64 profile now allows
468 pairs. The candidate still has to pass the import check against its final
executable bytes, and neither addition is a 14393 or installer run.
Release candidate run 43186389246 on source `f5a3dda975` then stopped the same
check on three more pairs, `GetSidIdentifierAuthority`, `GetSidSubAuthority` and
`GetSidSubAuthorityCount` from advapi32.dll, on both 64-bit targets. Probe run
43197611105 (Windows 11 ARM64 build 26200.9725) resolved all 447 imports of
that candidate's ARM64 executables, and the 3 pairs were added. For x64 the 3
pairs are documented Microsoft API contracts (Windows XP minimum on each
function's reference page), labelled apart from the #6953 reporter-observed
exports; the x64 profile now allows 471 pairs and the ARM64 profile 473. The
same limit applies: the candidate still has to pass the import check against
its final executable bytes, and neither addition is a 14393 or installer run.
At this snapshot, startup/installation of the final release bytes on an actual
1607 machine and the candidate's generated uninstaller were still unqualified.
The final frozen source, release import dumps, archive/native/npm installation,
-7
View File
@@ -1,13 +1,6 @@
# Hooks
> 阅读简体中文版:[zh_hans/HOOKS.md](zh_hans/HOOKS.md)
Everything you add to Codewhale is a plugin. A hook in `config.toml` or a
project `hooks.toml` is a plugin of the kind **Hook**, and a plugin bundle can
also carry hooks. Hooks are still managed under Extensions → Hooks in the
terminal and Customize › Hooks on the desktop; one Plugins list that shows every
kind together is planned for 0.10.3 and is not in this build. Configuration and
the `/hooks` commands are unchanged.
Hooks run a shell command when the Codewhale **TUI** reaches a lifecycle
point. They are plain processes: they receive context through environment
variables, some receive a JSON payload on stdin, and three of them can steer
-9
View File
@@ -5,15 +5,6 @@ In the terminal, `/mcp` (also `/mcps`) opens **Extensions → MCP**. Enter opens
> 阅读简体中文版:[zh_hans/MCP.md](zh_hans/MCP.md)
Everything you add to Codewhale is a plugin. An MCP server you configure is a
plugin of the kind **Connector**, and a plugin bundle can also carry connectors.
Connectors are still managed under Extensions → MCP in the terminal and
Customize › Connectors on the desktop; one Plugins list that shows every kind
together is planned for 0.10.3 and is not in this build. The configuration
files, sign-in and the `/mcp` commands are unchanged. "Connection" on this page
means the live link to a server; as a product noun it is the word for a model
provider or another agent.
Codewhale can load additional tools via MCP (Model Context Protocol). MCP servers can be local stdio processes that Codewhale starts, or remote URL-based servers that speak Streamable HTTP with legacy SSE fallback.
## Version scope
+7 -19
View File
@@ -12,25 +12,13 @@ format (`plugin.json`, compatible `kimi.plugin.json` or
the trust/enable lifecycle — this document covers how bits get onto disk in
the first place.
Everything you add to Codewhale is a plugin. A skill, an MCP connector and a hook
are each a plugin, or part of one, and each has a kind: Skill, Connector, Hook,
Command. A skill you set up by hand is a plugin of the kind Skill, a server you
configure is a plugin of the kind Connector, and a hook you configure is a plugin
of the kind Hook. They are still managed where they are today: Extensions →
Skills, MCP and Hooks in the terminal, and Customize › Skills, Connectors and
Hooks on the desktop. Their files, trust rules and commands (`/skills`, `/mcp`,
`/hooks`) do not change. One Plugins list that shows every kind together is
planned for 0.10.3 and is not in this build; today the kind labels appear on the
parts inside a plugin bundle's detail. "Connection" is the word for a model
provider or another agent; it is not a kind of plugin.
## Choose what to add
## Choose a plugin, skill, or MCP connection
| What you need | Use | Starting point |
| --- | --- | --- |
| Reusable instructions that work through your existing tools | A plugin that adds a skill | `/skills`, then `/skill <name>`; [Skills](SKILLS.md) |
| A reviewed bundle of workflows, tools, commands or agent profiles | A plugin bundle | `/plugin marketplace list`; [first-party catalog](https://github.com/codewhale-hq/codewhale-plugin-marketplace) |
| Tools or context from a particular local process or remote service | A plugin that adds an MCP connector | `/mcp`; [MCP setup](MCP.md) |
| Reusable instructions that work through your existing tools | A skill | `/skills`, then `/skill <name>`; [Skills](SKILLS.md) |
| A reviewed bundle of workflows, tools, commands or agent profiles | A plugin | `/plugin marketplace list`; [first-party catalog](https://github.com/codewhale-hq/codewhale-plugin-marketplace) |
| Tools or context from a particular local process or remote service | A direct MCP connection | `/mcp`; [MCP setup](MCP.md) |
A plugin can combine skills, Markdown commands, Fleet agent profiles, hooks,
MCP servers and AI-provider declarations. Tools come from an MCP server or an
@@ -38,12 +26,12 @@ experimental native extension; there is no generic application-UI component in
the bundle manifest. Computer Use's Mac helper and Chromewhale's browser
extension have their own setup after plugin review.
An MCP connector is one of two kinds: a local process over stdio, or a remote URL over
MCP has two connection kinds: a local process over stdio, or a remote URL over
Streamable HTTP. Legacy SSE is available when the server still supports it.
User-configured remote servers can use environment-backed credentials or the
host's OAuth login. Plugin-contributed remote servers require explicit
environment-backed credentials when authentication is needed; their interactive
OAuth path is not supported. Installing a plugin does not connect an
OAuth path is not supported. Installing a skill or plugin does not connect an
account or establish that a service works.
A local stdio server runs with your operating-system user's access. Requested
@@ -75,7 +63,7 @@ Native TypeScript extensions require `extension_host = true` under `[features]`
and fresh capability review. LSP, requested filesystem roots and lifecycle mutation
remain inventory-only. Current DSH adapters include work beyond the published
v0.10.2 CLI; third-party DSH plugins' complete workflows are not established by
an import succeeding. An imported DSH plugin's UI is served only on the seats the Engine admits ([details](DSH_PLUGIN_COMPAT.md#plugin-ui-dshclient)). Other applications' plugin
an import succeeding. DSH plugin UI is unsupported. Other applications' plugin
directories and credentials are not borrowed.
`/plugin suggest <task>` is a local, read-only companion: it ranks already
+1 -3
View File
@@ -882,8 +882,6 @@ foreground shell tools, plus reviewed plugin tools (`extension_host` feature) an
the tools of MCP servers configured in Codewhale (`mcp` feature), under the same
permission setting. Shell requires both editor terminal support and operator
`allow_shell`; a requested external sandbox that is unavailable removes shell.
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.
Built-in overrides remove the
@@ -3270,7 +3268,7 @@ refuses OpenAI Codex. An Engine without this route answers `404`.
Reports which external agent CLIs this computer already has, without starting a
session. It covers Claude, Codex, DeepSeek Harness, Antigravity (`agy acp`),
OpenCode, Kimi Code, Mistral Vibe, Qoder, CodeBuddy, Grok and Qwen Code. `state` is `installed` or `not_installed`; a binary is
OpenCode, Kimi Code, Mistral Vibe, Qoder, CodeBuddy and Grok. `state` is `installed` or `not_installed`; a binary is
looked up on `PATH` (absolute entries only; on Windows the spawnable PATHEXT suffixes `.com/.exe/.bat/.cmd` and `%APPDATA%\npm`) and in the usual per-user bin directories. For an installed
agent the route runs `--version` and, where the CLI speaks Agent Client Protocol
itself, one `initialize` request on stdin, concurrently. `acp` is `native` when
+22 -35
View File
@@ -145,27 +145,20 @@ The key is read only from the managed configuration file named by
Linux, and only when neither that file nor any directory above it can be
changed by the Engine's own user. A managed file at any other path (for
example one a user config points at through `managed_config_path`) is refused.
In a user `config.toml` or a profile the key is a load error. The managed
file must also set `sandbox_mode` itself; a sandboxed mode from a user config
does not count. Without the key, nothing on this page changes.
In a user `config.toml` or a profile the key is a load error. The managed file must also set `sandbox_mode` itself; a sandboxed mode
from a user config does not count. Without the key, nothing on this page
changes.
A host that relies on the launcher puts the managed file in a root-owned
place and sets `CODEWHALE_MANAGED_CONFIG_PATH` to it for the Engine's service.
The variable outranks a user config's `managed_config_path`, and a Fleet local
worker (a child `codewhale exec`) gets the parent's variable even though its
environment is otherwise cleaned; a worker request cannot replace it. Any
other `codewhale` process sees a file named only by the variable when it is
started with the variable. An MCP server entry that runs `codewhale serve
--mcp` is started with a cleaned environment and does not get it. A host that
relies on the launcher therefore also keeps the file at the default managed
path, or sets the variable in every such entry. A file named by the variable
or by `managed_config_path` that is missing or cannot be read is a load error,
not "no managed layer", and a user config cannot point `managed_config_path`
away from an existing file at the default managed path.
Only a missing file at the default path, with nothing naming another, means no
managed layer. A file in another ordinary user's home also passes the
"Engine's user cannot change it" check, so the file should be root-owned.
Check `sandbox.tool_launcher` in `GET /v1/runtime/info`: the route
A host that relies on the launcher puts the managed file at the default
managed path, owned by root, and also sets `CODEWHALE_MANAGED_CONFIG_PATH` to
that same path for the Engine's service. The variable stops a user config from
pointing `managed_config_path` at a missing file; the default path is needed
because a Fleet local worker (a child `codewhale exec`) is started with a
cleaned environment and does not get the variable; it reads the user config's
`managed_config_path` and then the default path, so a user config can still
point a worker at a missing file. A file in another ordinary user's home also
passes the "Engine's user cannot change it" check, so the file should be
root-owned. Check `sandbox.tool_launcher` in `GET /v1/runtime/info`: the route
reads the live configuration, but a check made once at start does not cover a
later reload, so ask again after a reload.
@@ -200,24 +193,18 @@ With a launcher configured:
Limits: only commands the sandbox manager prepares (shell commands, terminal
sessions, workspace runners) go through the launcher. In-process file tools,
MCP servers, hooks, LSP servers, the extension host, external ACP agents and
the Engine's own `git`, formatter and diagnostic helpers still run as the
Engine's user. A Fleet local worker is its own Engine, so its shell commands
go through the launcher only because it loads the same managed file.
`codewhale serve --mcp` does not run commands through the launcher. When the
managed file it loads sets a launcher, it does not expose `shell` or
`exec_shell`, whatever its own `expose_tools` lists; its file tools use the
built-in read deny list only. Model-written Python in the session REPL kernel
(` ```repl ` blocks and the `rlm` tool) runs in a local interpreter as the
Engine's user. With `sandbox_backend = "opensandbox"` a shell command goes to
the remote backend before the launcher is consulted, so a launcher host pins
MCP servers, hooks, LSP servers, the extension host, Fleet local workers,
external ACP agents and the Engine's own `git`, formatter and diagnostic
helpers still run as the Engine's user. `codewhale serve --mcp` builds its own
shell manager with no launcher and no deny list, so do not expose `exec_shell`
there. With `sandbox_backend = "opensandbox"` a shell command goes to the
remote backend before the launcher is consulted, so a launcher host pins
`sandbox_backend = "none"`, `prefer_bwrap = true` and `sandbox_network_access`
in the managed file. A `tool_launcher` in a project config stops an
interactive launch with an error; `serve` and `exec` do not read project
config, so there the key is ignored. Deny-list exceptions are not applied
under a launcher. Search rights come from mode bits and group membership, not
ACLs. Processes keep running after the Engine itself dies, until bubblewrap's
parent exits.
config, so there the key is ignored. Deny-list exceptions are not applied under a launcher. Search
rights come from mode bits and group membership, not ACLs. Processes keep
running after the Engine itself dies, until bubblewrap's parent exits.
## Windows: no advertised OS sandbox
-7
View File
@@ -5,13 +5,6 @@ In the terminal, `/skills` opens **Extensions → Skills**. Enter on a skill ope
> 阅读简体中文版:[zh_hans/SKILLS.md](zh_hans/SKILLS.md)
Everything you add to Codewhale is a plugin: a skill you set up by hand is a
plugin of the kind **Skill**, and a skill inside a plugin bundle belongs to that
plugin. Skills are still managed here, under Extensions → Skills and Customize ›
Skills; one Plugins list that shows every kind together is planned for 0.10.3 and
is not in this build. This page keeps the word "skill" for the kind; files, trust
and the `/skills` commands are unchanged.
Skills are reusable `SKILL.md` instruction packs. Codewhale discovers them from
several roots, but **only Codewhale-owned directories are writable**. The unified
`/skills manage` manager is the interactive surface for audit and mutation; slash
+7 -7
View File
@@ -141,8 +141,8 @@ owner = "crates/engine/src/tools/apply_patch.rs"
[[feature]]
id = "mcp-client"
name = "MCP connectors"
summary = "A plugin kind: connects MCP servers, exposes permitted tools and records connection status for the model."
name = "MCP client"
summary = "Connects MCP servers, exposes permitted tools and records connection status for the model."
status = "stable"
surfaces = { tui = "stable", api = "stable" }
since = "0.1.0"
@@ -532,8 +532,8 @@ owner = "crates/memory"
[[feature]]
id = "skills"
name = "Skill plugins"
summary = "A plugin kind: trusted instruction packages with bundle policy, invocation controls and configurable index limits."
name = "Skills"
summary = "Trusted instruction packages with bundle policy, invocation controls and shared, configurable index limits."
status = "stable"
surfaces = { tui = "stable", api = "stable" }
docs = "docs/SKILLS.md"
@@ -542,7 +542,7 @@ owner = "crates/skills/src"
[[feature]]
id = "plugins"
name = "Plugins"
summary = "Everything you add is a plugin: installs, reviews and trusts bundles, shows status, restores missing reviewed snapshots."
summary = "Installs, reviews and trusts plugin bundles, reports their status, and restores missing reviewed runtime snapshots."
status = "stable"
surfaces = { tui = "stable", api = "stable" }
docs = "docs/PLUGINS.md"
@@ -585,8 +585,8 @@ owner = "crates/net/src/import_claude.rs"
[[feature]]
id = "hooks"
name = "Hook plugins"
summary = "A plugin kind: runs user commands on session and tool events, with an execution receipt after shell tools."
name = "Hooks"
summary = "Runs user commands on session and tool events, with an execution receipt after shell tools."
status = "stable"
surfaces = { tui = "stable", api = "stable" }
docs = "docs/HOOKS.md"
+2 -7
View File
@@ -97,13 +97,8 @@ Stage 5 of the loop: [cw-orient](../cw-orient/SKILL.md) →
Co-authored-by: <maintainer or agent identity>
```
GitHub's contributor count takes authors and `Co-authored-by` trailers in
the final trailer block (no blank line between trailers) whose address is
linked to the account, and `check-contributor-credit.py` flags a harvested
handle who is not credited, a trailer GitHub cannot parse, and a noreply id
that belongs to someone else. It accepts credit only from a noreply address
whose id matches `.github/AUTHOR_MAP`, or from a raw email listed there, so
add the contributor's line to `AUTHOR_MAP` when it is missing.
GitHub's contributor list counts authors only, and
`check-contributor-credit.py` flags a harvested handle who is not credited.
Resolve the address from `.github/AUTHOR_MAP`, else
`gh api users/<handle> --jq '"\(.id)+\(.login)@users.noreply.github.com"'`.
A clean cherry-pick keeps the author by itself. This step is the single
-5
View File
@@ -127,11 +127,6 @@ DLL 的身份一致,[该 API 的文档前提是 Vista](https://learn.microsoft
x64 没有导出探测,因此这 12 对作为低于 14393 下限的微软文档 API 契约单独记录,与 #6953 报告者实测导出分开标注;x64 允许列表现为 468 对。
候选仍须以最终可执行文件通过导入检查,两项新增都不是 14393 或安装器运行。
源 `f5a3dda975` 的发布候选运行 43186389246 随后在两个 64 位目标上又停止于三对导入:advapi32.dll 的 `GetSidIdentifierAuthority`、`GetSidSubAuthority` 和 `GetSidSubAuthorityCount`。
探测运行 43197611105(Windows 11 ARM64 内部版本 26200.9725)解析了该候选 ARM64 程序的全部 447 个导入,新增的 3 对已加入基线。
x64 的这 3 对作为微软文档 API 契约记录(各函数参考页的最低客户端为 Windows XP),与 #6953 报告者实测导出分开标注;x64 允许列表现为 471 对,ARM64 为 473 对。
限制不变:候选仍须以最终可执行文件通过导入检查,两项新增都不是 14393 或安装器运行。
此快照中,最终发布文件在真实 1607 上启动/安装及候选程序生成的卸载器仍未验收。
最终冻结源码、发布导入记录、各安装途径、打包/原生应用崩溃恢复和最低 OS 回执应
进入发布验收记录,见[英文发布检查单](../RELEASE_CHECKLIST.md)。
+2 -7
View File
@@ -249,13 +249,8 @@ DSH 的 `allow` 被视为不干预,每个所有者提示一次;不能预先
和审批检查。理由及上下文沿用现有清理与大小限制。
该桥接作用于 Engine 会话的模型工具、受 gate 管理的 code-mode 调用及 `exec.core`
调用。编辑器通过 ACP(`codewhale serve --acp`)驱动的会话获得同一个扩展宿主,并受相同的
`[features]` 开关(`extension_host`、`mcp`)约束:已审查的插件工具(TypeScript 与 DSH)和
Codewhale 中配置的 MCP 服务器会作为 ACP 工具调用出现,并通过会话的权限设置询问;Plan 保持只读。
ACP 服务器的能力声明会说明这一点(`agentCapabilities._meta.codewhale`)。ACP 客户端在
`mcpServers` 中传入的服务器和已配置的 shell hooks 不会挂载。`prepend`/`global` 顺序
覆盖、包裹实际执行的中间件、DSH 运行时句柄和执行后结果改写均不支持。Native
`tool_call_after` 仍是只观察的契约。
调用。独立 ACP 执行路径没有 TypeScript 宿主 attachment。`prepend`/`global` 顺序
覆盖、包裹实际执行的中间件、DSH 运行时句柄和执行后结果改写均不支持。
## 提示词片段
-4
View File
@@ -3,10 +3,6 @@
> 英文原文:[HOOKS.md](../HOOKS.md)。
> 最后与英文同步日期(last synced with English revision):2026-09-29。
你添加到 Codewhale 的一切都叫插件。`config.toml` 或项目 `hooks.toml` 里的 hook 是 **Hook** 类插件;插件包也可以携带 hook。
hook 仍在终端的 Extensions → Hooks 和桌面端的 Customize › Hooks 管理;把所有种类放在一个插件列表里是
0.10.3 的计划,当前版本尚无。配置和 `/hooks` 命令都不变。
Hooks 会在 Codewhale **TUI** 到达生命周期节点时运行一条 shell 命令。它们是普通进程:通过环境变量接收上下文,其中一些会在 stdin 上收到 JSON 载荷,还有三个可以引导 Codewhale 接下来做什么。
本页是当前已实现内容的权威参考。与 `config.toml` 其余部分重叠的配置语法见 [CONFIGURATION.md](CONFIGURATION.md);本文件是逐事件的约定说明。
-5
View File
@@ -4,11 +4,6 @@
在终端中,`/mcp`(也可用 `/mcps`)会打开 **Extensions → MCP**。按 Enter 会对所选服务器执行恢复操作,或打开只读详情。清单为空时会提供服务器建议;浏览这些建议不会安装任何内容。`/mcp status`、`/mcp doctor`、`/mcp login`、`/mcp add` 等显式子命令的行为保持不变。
你添加到 Codewhale 的一切都叫插件。你配置的 MCP 服务器是 **Connector**(连接器)类插件;插件包也可以携带 connector。
connector 仍在终端的 Extensions → MCP 和桌面端的 Customize › Connectors 管理;把所有种类放在一个插件列表里是
0.10.3 的计划,当前版本尚无。配置文件、登录和 `/mcp` 命令都不变。
本页的“连接”(connection)指到服务器的实际链路;作为产品名词,Connection 专指模型提供方或另一个 agent。
Codewhale 可以通过 MCP(Model Context Protocol,模型上下文协议)加载额外工具。MCP 服务器可以是 Codewhale 启动的本地 stdio 进程,也可以是使用 Streamable HTTP(带传统 SSE 回退)的远程基于 URL 的服务器。
浏览说明:
-9
View File
@@ -3,15 +3,6 @@
> 英文原文:[PLUGINS.md](../PLUGINS.md)。
> 最后与英文同步日期(last synced with English revision):2026-09-26。
> 2026-11-07 定点更正:版本范围、目录与固定来源、信任确认、本地更新;未做全文重译。
> 2026-11-12 定点更正:一律称“插件”;未做全文重译。
你添加到 Codewhale 的一切都叫插件。skill、MCP connector(连接器)和 hook 各自是一个插件,
或是插件的一部分,并各有一个种类:Skill、Connector、Hook、Command。你手动设置的 skill 是 Skill 类插件,
你配置的服务器是 Connector 类插件,你配置的 hook 是 Hook 类插件。它们仍在原处管理:终端的
Extensions → Skills、MCP、Hooks,桌面端的 Customize › Skills、Connectors、Hooks;文件、
信任规则和命令(`/skills`、`/mcp`、`/hooks`)都不变。把所有种类放在一个插件列表里是 0.10.3 的计划,
当前版本尚无;目前种类标签只出现在插件包详情里的各个部分上。“Connection”(连接)一词专指模型提供方或
另一个 agent,不是插件的一种。
想做一个插件包,先从[编写你的第一个 Codewhale 插件](./PLUGIN_AUTHORING.md)
和里面可运行的 Skills 示例入手。
+4 -6
View File
@@ -652,15 +652,13 @@ Codewhale 0.10.2 的 ACP v1 支持不完整。`session/new` 和 `session/load`
会在创建、恢复或返回缓存会话之前拒绝非空 `mcpServers`,返回 `-32602`。
该字段可省略或为 `[]`;null 与其他非数组值无效。验证不会执行所提供的命令。
[ACP v1 要求 stdio MCP 支持](https://agentclientprotocol.com/protocol/v1/session-setup#mcp-servers);HTTP/SSE 能力为 false 并不代表完整合规。
需要这类集成请用完整 Runtime API 及其现有 MCP 管理。下列工具限制来自 Codewhale host profile,
需要 MCP 的集成请用完整 Runtime API。下列工具限制来自 Codewhale host profile,
不是 ACP 协议本身的限制。
受信任的本地 ACP profile 将 Core 收窄到文件/搜索/git/patch,以及获准的前台
shell 工具,外加已审查的插件工具(`extension_host` 功能)和 Codewhale 中配置的
MCP 服务器的工具(`mcp` 功能),均受同一权限设置约束;这一点见于 0.10.2 之后的 main,0.10.2 本身不含。Shell 同时要求编辑器声明
terminal 支持、操作者允许 `allow_shell`;指定的外部沙箱不可用时不提供 shell。此接口
不提供客户端传入的 MCP 服务器、动态工具、任务、PTY、后台 shell、解释器、子智能体
或 RLM 生命周期。内置工具覆盖会移除整个兼容别名族。
shell 工具。Shell 同时要求编辑器声明 terminal 支持、操作者允许 `allow_shell`;
指定的外部沙箱不可用时不提供 shell。此接口不提供 MCP、动态工具、任务、PTY、
后台 shell、解释器、子智能体或 RLM 生命周期。内置工具覆盖会移除整个兼容别名族。
最终派发再次校验 profile;伪造别名、hook 改写或目录中缺失的工具不能绕过限制。
Full Access 下 Plan 仍只读。Full Access 与普通审批姿态是服务端拥有的只读选项,
编辑器不能放宽。`approval_policy = "never"` 不是 ACP 审批姿态,服务端会拒绝
-4
View File
@@ -6,10 +6,6 @@
在终端中,`/skills` 会打开 **Extensions → Skills**。在某个 skill 上按 Enter 会打开专用管理器(也可直接用 `/skills manage` 打开),用于现有 skill 的安装、更新、移除和信任操作。`/skill <name>` 仍然用于激活 skill,显式的检查、远程、同步和建议子命令也仍然可用。
你添加到 Codewhale 的一切都叫插件:你手动设置的 skill 是 **Skill** 类插件;
插件包里的 skill 属于该插件。skill 仍在 Extensions → Skills 和 Customize › Skills 管理;把所有种类放在一个
插件列表里是 0.10.3 的计划,当前版本尚无。本页用“skill”指这一种类;文件、信任和 `/skills` 命令都不变。
Skills 是可复用的 `SKILL.md` 指令包。Codewhale 从多个根目录发现它们,但**只有 Codewhale 拥有的目录可写**。统一的 `/skills manage` 管理器是审计与变更的交互界面;斜杠别名共享同一条写入路径。
关于 Claude Code 插件边界,参见 [CLAUDE_PLUGIN_COMPAT.md](./CLAUDE_PLUGIN_COMPAT.md)。
+10 -9
View File
@@ -161,6 +161,15 @@ dependencies = [
"windows-link",
]
[[package]]
name = "codewhale-app-server-protocol"
version = "0.10.3"
dependencies = [
"chrono",
"serde",
"serde_json",
]
[[package]]
name = "codewhale-pet-tui"
version = "0.1.0"
@@ -170,20 +179,12 @@ dependencies = [
"unicode-width",
]
[[package]]
name = "codewhale-app-server-protocol"
version = "0.10.2"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "codewhale-protocol"
version = "0.10.3"
dependencies = [
"codewhale-app-server-protocol",
"chrono",
"codewhale-app-server-protocol",
"serde",
"serde_json",
"uuid",
-3
View File
@@ -12,9 +12,6 @@
"crates/app-server-protocol/src/bin/export.rs": {
"std_fs": 3
},
"crates/app-server/src/daemon_client.rs": {
"path_canonicalize": 1
},
"crates/app-server/src/mcp_server.rs": {
"std_fs": 1
},
+20 -91
View File
@@ -17,7 +17,7 @@ live in the commit bodies, so no network call is needed.
Usage: scripts/check-contributor-credit.py [<since-rev>]
"""
from __future__ import annotations
import functools, re, subprocess, sys
import re, subprocess, sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
@@ -35,7 +35,6 @@ def sh(*args: str) -> str:
return subprocess.run(args, cwd=ROOT, capture_output=True, text=True, check=True).stdout
@functools.cache
def author_map() -> dict[str, str]:
"""alias -> github handle, from .github/AUTHOR_MAP."""
out: dict[str, str] = {}
@@ -53,43 +52,19 @@ def author_map() -> dict[str, str]:
return out
NOREPLY = re.compile(r"\d+\+([^@<>\s]+)@users\.noreply\.github\.com")
def not_a_person(name: str, email: str) -> bool:
low, addr = name.strip().lower(), email.lower()
return any(low.startswith(skip) for skip in SKIP) or "[bot]" in addr \
or addr.endswith(("[email protected]", "[email protected]"))
def maintainer(name: str, email: str) -> bool:
handle = author_map().get(email.lower()) or author_map().get(name.strip().lower()) or name.strip()
return any(h.lower().startswith(skip) for skip in SKIP for h in (name.strip(), handle))
def restoring(subject: str, name: str, email: str) -> bool:
return subject.startswith("chore(credit):") and maintainer(name, email)
def noreply_ids() -> dict[str, str]:
out: dict[str, str] = {}
path = ROOT / ".github" / "AUTHOR_MAP"
if path.exists():
for login_id, login in re.findall(r"(\d+)\+([^@<>\s]+)@users\.noreply\.github\.com", path.read_text()):
out[login.lower()] = login_id
return out
def expected(since: str) -> dict[str, str]:
"""handle -> why we think they are owed credit."""
amap = author_map()
found: dict[str, str] = {}
def add(name: str, email: str, why: str) -> None:
low = name.strip().lower()
# Model co-authorship ("Claude Opus 5 (1M context)") and bot addresses
# are not people owed credit; match on prefix since the model name and
# its context window change release to release.
if not_a_person(name, email):
if any(low.startswith(skip) for skip in SKIP):
return
if "[bot]" in email.lower() or email.lower().endswith(("[email protected]", "[email protected]")):
return
handle = amap.get(email.lower()) or amap.get(name.strip().lower())
if not handle:
@@ -99,17 +74,16 @@ def expected(since: str) -> dict[str, str]:
return
found.setdefault(handle, why)
log = sh("git", "log", f"{since}..HEAD", "--format=%an%x1f%ae%x1f%s%x1f%b%x00")
work = [parts for parts in (entry.lstrip("\n").split("\x1f", 3) for entry in log.split("\0"))
if len(parts) == 4 and not restoring(parts[2], parts[0], parts[1])]
for author, email, _, _ in work:
add(author, email, "commit author")
for _, _, _, body in work:
for name, addr in re.findall(r"(?im)^co-authored-by:\s*(.+?)\s*<([^>]+)>", body):
add(name, addr, "co-author trailer")
for pr, handle in re.findall(r"(?i)harvested from PR #(\d+) by @([A-Za-z0-9-]+)", body):
if handle.lower() not in SKIP:
found.setdefault(handle, f"harvested PR #{pr}")
for line in sh("git", "log", f"{since}..HEAD", "--format=%an\t%ae").splitlines():
name, _, email = line.partition("\t")
add(name, email, "commit author")
body = sh("git", "log", f"{since}..HEAD", "--format=%b")
for name, email in re.findall(r"(?im)^co-authored-by:\s*(.+?)\s*<([^>]+)>", body):
add(name, email, "co-author trailer")
for pr, handle in re.findall(r"(?i)harvested from PR #(\d+) by @([A-Za-z0-9-]+)", body):
if handle.lower() not in SKIP:
found.setdefault(handle, f"harvested PR #{pr}")
return found
@@ -134,50 +108,17 @@ def main() -> int:
print(f"Contributor credit since {since}: {len(owed)} contributor(s) found.")
amap = author_map()
ids = noreply_ids()
lost = []
broken: list[tuple[str, str]] = []
credited: set[str] = set()
def login(name: str, addr: str) -> str:
nm = NOREPLY.search(addr)
return (amap.get(addr.lower()) or amap.get(name.strip().lower()) or (nm.group(1) if nm else name.strip())).lower()
def who(name: str, addr: str) -> str:
nm = re.search(r"(\d+)\+([^@<>\s]+)@users\.noreply\.github\.com", addr)
if not nm:
return amap.get(addr.lower(), "").lower() if "@" in addr else ""
known = ids.get(nm.group(2).lower())
if known is not None and known != nm.group(1):
broken.append((nm.group(2).lower(), f"uses {addr}, but @{nm.group(2)} is id {known} "
f"in AUTHOR_MAP; GitHub credits whoever owns id {nm.group(1)}"))
return nm.group(2).lower() if known == nm.group(1) else ""
log = sh("git", "log", f"{since}..HEAD",
"--format=%h%x1f%an%x1f%ae%x1f%(trailers:key=Co-authored-by,unfold)%x1f%B%x00")
for entry in log.split("\0"):
parts = entry.strip().split("\x1f", 4)
if len(parts) < 5:
for entry in sh("git", "log", f"{since}..HEAD", "--format=%h\t%an\t%ae\t%B%x00").split("\0"):
parts = entry.strip().split("\t", 3)
if len(parts) < 4:
continue
sha, name, email, parsed, body = parts
sha, name, email, body = parts
m = re.search(r"\d+\+([^@<>\s]+)@users\.noreply\.github\.com", email)
author = (amap.get(email.lower()) or amap.get(name.lower()) or (m.group(1) if m else name)).lower()
trailers = re.findall(r"(?im)^co-authored-by:\s*(.+?)\s*<([^>]+)>", parsed)
mark = len(broken)
credited.add(who(name, email))
credited.update(who(n, e) for n, e in trailers)
broken[mark:] = [(login, f"{sha} {why}") for login, why in broken[mark:]]
for co_name, co_email in re.findall(r"(?im)^\s*co-authored-by:\s*(.+?)\s*<([^>]+)>", body):
if (co_name, co_email) not in trailers and not not_a_person(co_name, co_email):
broken.append((login(co_name, co_email),
f"{sha} has 'Co-authored-by: {co_name}' outside the final trailer block; GitHub ignores it"))
if restoring(body.split("\n", 1)[0], name, email):
continue
for handle in re.findall(r"(?i)harvested from PR #\d+ by @([A-Za-z0-9-]+)", body):
if handle.lower() != author:
lost.append(f" {sha} harvested @{handle} but is authored by {name}")
broken.append((amap.get(handle.lower(), handle).lower(),
f"{sha} harvested @{handle} with neither authorship nor a Co-authored-by trailer for them"))
if any(author.startswith(skip) for skip in SKIP):
for co_name, co_email in re.findall(r"(?im)^co-authored-by:\s*(.+?)\s*<([^>]+)>", body):
cm = re.search(r"\d+\+([^@<>\s]+)@users\.noreply\.github\.com", co_email)
@@ -187,18 +128,8 @@ def main() -> int:
and "[bot]" not in co_email.lower() and not co_email.lower().endswith(("[email protected]", "[email protected]")):
lost.append(f" {sha} credits @{human} only as a co-author; authored by {name}")
if lost:
print("\nCommits whose human contributor is not the commit author (GitHub counts them only through a\n"
"well-formed Co-authored-by trailer whose address is linked to their account):\n")
print("\nCommits whose human contributor is not the commit author (GitHub's contributor list will miss them):\n")
print("\n".join(lost))
broken = [(login, why) for login, why in broken if login.lower() not in credited]
if broken:
print("\nCredit GitHub cannot see:\n")
print("\n".join(f" {why}" for _, why in broken))
print("\nPut each contributor in the final trailer block as\n"
"'Co-authored-by: Name <[email protected]>', with no blank line between trailers,\n"
"using the id from `gh api users/<login>`. A later commit can carry the trailer when history cannot change.\n"
"Credit is taken only from such an address, or from a raw email listed in .github/AUTHOR_MAP; add the\n"
"email a contributor commits with there once it is linked to their account.")
if missing:
print("\nUncredited work landed in this release:\n")
print("\n".join(missing))
@@ -208,8 +139,6 @@ def main() -> int:
"is still their work -- see AGENTS.md 'Landing other people's work'."
)
return 1
if broken:
return 1
print("Every contributor in the window is credited on all three surfaces.")
return 0
-3
View File
@@ -538,9 +538,6 @@ def report_huggingface_coverage(
def report_env_lookup_order(
label: str, source: str, expected_order: list[str], context: str
) -> list[str]:
non_blank_chain = "env_non_blank(&[" + ", ".join(f'"{name}"' for name in expected_order) + "])"
if non_blank_chain in source:
return []
lookup_needles = [f'std::env::var("{name}")' for name in expected_order]
return report_string_order(label, source, lookup_needles, context)
+1 -2
View File
@@ -24,8 +24,7 @@ fi
# Founder decision 2026-09-01: `fleet` is the public product term for the
# assembled model team; `Pod` is retired from customer-facing copy. The
# English source locale is the gate; other locale packs migrate with their
# translation sweep. `/pod` answers "Unknown command" and `codewhale pod` is
# plain prompt text.
# translation sweep. `/pod` and `codewhale pod` remain parser aliases.
if grep -En '": "[^"]*[Pp]od' crates/localization/locales/en.json; then
printf '%s\n' 'retired Pod vocabulary remains in English product copy' >&2
exit 1
+6 -6
View File
@@ -1,10 +1,10 @@
#!/usr/bin/env python3
"""Everyone whose work landed in or is credited by Codewhale, on one wall.
GitHub's contributor count takes only commit authors and co-author trailers
whose email is linked to an account. Harvested pull requests without a trailer,
unlinked commit emails and credited bug reports never reach it, so it shows
about half of the people who built Codewhale.
GitHub's contributor list counts only commit authors whose email is linked to
an account. Harvested pull requests, co-author trailers, unlinked commit
emails and credited bug reports never reach it, so it shows about half of the
people who built Codewhale.
refresh rebuild docs/contributors.json from git history, the CHANGELOG,
docs/CONTRIBUTORS.md and GitHub (needs `gh`)
@@ -202,8 +202,8 @@ def render() -> None:
"<!-- contributor-wall:start -->\n## Everyone\n\n"
f"[![{total} people built Codewhale](assets/contributors.png)](#everyone)\n\n"
f"{total} people have code, co-authored commits, carried-forward pull requests or credited reports in Codewhale. "
"GitHub's contributor count includes only commit authors and co-authors whose email is linked to their "
"account, so it misses many of them. If your commits are missing there, add your commit email to your "
"GitHub's contributor list shows only commit authors whose email is linked to their account, so it counts "
f"{data['github_contributor_list']} of them. If your commits are missing there, add your commit email to your "
"GitHub account and they will link.\n\n"
f"{links}\n\nAlso with us, from commits whose email is not linked to an account: {' · '.join(extra)}.\n"
"<!-- contributor-wall:end -->\n"
+1 -16
View File
@@ -11,7 +11,7 @@ const path = require("node:path");
const test = require("node:test");
const repo = path.resolve(__dirname, "../..");
const bytes = '#!/bin/sh\necho "executed $*" >> "$INSTALL_TEST_EXECUTED"\n[ "$1" = doctor ] && echo "telemetry=${CODEWHALE_TELEMETRY-unset} update_check=${CODEWHALE_NO_UPDATE_CHECK-unset}" >> "$INSTALL_TEST_DOCTOR_ENV"\ncase "$1" in --help) printf \'%s\\n\' "${INSTALL_TEST_HELP:-}" ;; *) echo "codewhale 0.9.11" ;; esac\n';
const bytes = '#!/bin/sh\necho "executed $*" >> "$INSTALL_TEST_EXECUTED"\ncase "$1" in --help) printf \'%s\\n\' "${INSTALL_TEST_HELP:-}" ;; *) echo "codewhale 0.9.11" ;; esac\n';
function executable(file, body) {
fs.writeFileSync(file, body, { mode: 0o755 });
@@ -56,7 +56,6 @@ cp "$INSTALL_TEST_ASSETS/$asset" "$output"
CODEWHALE_VERSION: "v0.9.11", CODEWHALE_INSTALL_DIR: destination,
PREFIX: path.dirname(destination), INSTALL_TEST_ASSETS: assets,
INSTALL_TEST_EXECUTED: path.join(root, "executed"),
INSTALL_TEST_DOCTOR_ENV: path.join(root, "doctor-env"),
INSTALL_TEST_DOWNLOADS: path.join(root, "downloads") };
for (const key of ["CODEWHALE_RELEASE_BASE_URL", "DEEPSEEK_TUI_RELEASE_BASE_URL", "CODEWHALE_SKIP_GLIBC_CHECK", "DEEPSEEK_TUI_SKIP_GLIBC_CHECK", "DEEPSEEK_SKIP_GLIBC_CHECK", "TERMUX_VERSION", "CODEWHALE_HOME", "CODEWHALE_FORCE_REINSTALL", "CODEWHALE_INSTALL_COMPILED_HOST", "SHELL", "ZDOTDIR"]) delete env[key];
function run(args = []) {
@@ -98,20 +97,6 @@ ${hash} codew
f.untouched();
});
test(`${kind}: the installer's own doctor run is made with usage reporting forced off`, t => {
for (const inherited of [undefined, "1"]) {
const f = fixture(t, kind);
if (inherited !== undefined) f.env.CODEWHALE_TELEMETRY = inherited;
else delete f.env.CODEWHALE_TELEMETRY;
const result = f.run();
assert.equal(result.status, 0, result.stderr);
const seen = fs.readFileSync(f.env.INSTALL_TEST_DOCTOR_ENV, "utf8").trim().split("\n");
assert.deepEqual(seen, ["telemetry=0 update_check=1"], `inherited=${inherited}`);
assert.ok(result.stdout.includes("config set telemetry false"), result.stdout);
assert.ok(!result.stdout.includes("telemetry=0"), result.stdout);
}
});
test(`${kind}: the output ends with the PATH command and the verify command`, t => {
const f = fixture(t, kind);
const result = f.run();
+1 -4
View File
@@ -164,10 +164,7 @@ try {
}
Write-Host "Upgraded the earlier Codewhale. Previous files are kept in $recovery for 30 days. Undo: `"$destination\codewhale.exe`" doctor --restore $(Split-Path -Leaf $recovery)"
}
$priorTelemetry = $env:CODEWHALE_TELEMETRY; $priorUpdateCheck = $env:CODEWHALE_NO_UPDATE_CHECK
$env:CODEWHALE_TELEMETRY = '0'; $env:CODEWHALE_NO_UPDATE_CHECK = '1'
try { & (Join-Path $destination 'codewhale.exe') doctor --installs --brief 2>$null } catch { } finally { $env:CODEWHALE_TELEMETRY = $priorTelemetry; $env:CODEWHALE_NO_UPDATE_CHECK = $priorUpdateCheck }
Write-Host "Usage reporting is on by default (counts only, never your code or conversations). Turn it off: `"$destination\codewhale.exe`" config set telemetry false"
try { & (Join-Path $destination 'codewhale.exe') doctor --installs --brief 2>$null } catch { }
Write-Host "Updates install automatically; turn off with: codewhale config set update.mode notify"
$onPath = @($env:Path -split ';' | Where-Object { $_ -and (Path-Key $_) -eq (Path-Key $destination) }).Count -gt 0
try { Ensure-UserPath $destination } catch { Write-Warning "Could not update your user PATH: $($_.Exception.Message). Add $destination to it yourself." }
+1 -2
View File
@@ -341,8 +341,7 @@ fi
if [[ -n "$recovery_dir" ]]; then
echo "Previous files are kept in $recovery_dir for 30 days. Undo: \"$BIN_DIR/codewhale\" doctor --restore $(basename "$recovery_dir")"
fi
CODEWHALE_TELEMETRY=0 CODEWHALE_NO_UPDATE_CHECK=1 "$BIN_DIR/codewhale" doctor --installs --brief 2>/dev/null || true
echo "Usage reporting is on by default (counts only, never your code or conversations). Turn it off: \"$BIN_DIR/codewhale\" config set telemetry false"
"$BIN_DIR/codewhale" doctor --installs --brief 2>/dev/null || true
echo "Future updates: \"$BIN_DIR/codewhale\" update"
if [[ "$supports_uninstall" == 1 ]]; then
echo "Updates install automatically; turn off with: codewhale config set update.mode notify"
+53 -144
View File
@@ -6,7 +6,7 @@
"osArchitecture": "x64",
"qualification": {
"result": "PASS",
"kind": "reporter exact-OS export resolution plus 15 documented Microsoft named API minima",
"kind": "reporter exact-OS export resolution plus 12 documented Microsoft named API minima",
"evidence": {
"issue": "https://github.com/codewhale-hq/Codewhale/issues/6953",
"binary": "https://github.com/codewhale-hq/Codewhale/releases/download/v0.10.1/codewhale-windows-x64.exe",
@@ -16,7 +16,7 @@
"previous_sha256": "f7b35a1e928f67884637f36b01fc72854f50690130f191bac3bcf62ece3c1d84"
},
"runtimeAcceptance": "Final 0.10.2 release bytes on 14393 not run",
"qualifiedPairs": 471,
"qualifiedPairs": 468,
"namedApiContracts": [
{
"dll": "advapi32.dll",
@@ -89,24 +89,6 @@
"symbol": "expf",
"minimumClient": "Exact API-set pair introduced in 10.0.10240",
"source": "https://learn.microsoft.com/en-us/uwp/win32-and-com/win32-extension-apis"
},
{
"dll": "advapi32.dll",
"symbol": "GetSidIdentifierAuthority",
"minimumClient": "Windows XP",
"source": "https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-getsididentifierauthority"
},
{
"dll": "advapi32.dll",
"symbol": "GetSidSubAuthority",
"minimumClient": "Windows XP",
"source": "https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-getsidsubauthority"
},
{
"dll": "advapi32.dll",
"symbol": "GetSidSubAuthorityCount",
"minimumClient": "Windows XP",
"source": "https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-getsidsubauthoritycount"
}
],
"namedApiContractEvidence": {
@@ -115,21 +97,7 @@
"binarySha256": "cd5a1f9d4b53eb0f2ac1e29fbbedc71f8845d63ab85115192f60b78cf197e2fa",
"importReceiptSha256": "a872374cb767467732c5f36e4b315d58fd424107a9a725800673b9181a714f02",
"ucrtPlatformSource": "https://learn.microsoft.com/en-us/cpp/porting/upgrade-your-code-to-the-universal-crt?view=msvc-170",
"scope": "Documentation-backed static API contracts below the unchanged 14393 floor; not reporter-observed exports, a native x64 export probe, executable startup, installer or ordinary-user acceptance",
"additionalCandidates": [
{
"candidateRun": "https://github.com/codewhale-hq/Codewhale/actions/runs/43186389246",
"source": "f5a3dda9755170aeafecd0841347a5578a540290",
"binarySha256": "6fa43dc8c8680cf3e30a38ea950154b28e30aa97fe373fe82fae6086aa58e1c1",
"importReceiptSha256": "9cc807d09bee35101a926773934937427a53214e6e1a54fb85e622b3f18a6ae3",
"pairs": [
"advapi32.dll!GetSidIdentifierAuthority",
"advapi32.dll!GetSidSubAuthority",
"advapi32.dll!GetSidSubAuthorityCount"
],
"scope": "Three pairs from crates/config private_directory.rs; documentation-backed static contracts, same limits as above"
}
]
"scope": "Documentation-backed static API contracts below the unchanged 14393 floor; not reporter-observed exports, a native x64 export probe, executable startup, installer or ordinary-user acceptance"
}
},
"imports": {
@@ -153,9 +121,6 @@
"GetSecurityDescriptorControl",
"GetSecurityDescriptorDacl",
"GetSecurityInfo",
"GetSidIdentifierAuthority",
"GetSidSubAuthority",
"GetSidSubAuthorityCount",
"GetTokenInformation",
"ImpersonateAnonymousToken",
"InitializeAcl",
@@ -666,25 +631,25 @@
"qualification": {
"result": "PASS",
"kind": "exact native system export resolution",
"time": "2026-11-12T10:08:11.6545236Z",
"time": "2026-11-07T16:01:13.7658879Z",
"osVersion": "10.0.26200.0",
"osBuild": "26200.9725",
"processArchitecture": "ARM64",
"runner": "GitHub-hosted windows-11-arm; its registry ProductName reads \"Windows 10 Enterprise\", as Windows 11 reports it",
"source": "f5a3dda9755170aeafecd0841347a5578a540290",
"sourceUrl": "https://github.com/codewhale-hq/Codewhale/commit/f5a3dda9755170aeafecd0841347a5578a540290",
"binarySha256": "c8c6f97d0ccf20e392b2457cb970187e79a29caa28d091dbfefe3a1393b99040",
"source": "549ff01ebb78856408164058fcb52db1e639ee0d",
"sourceUrl": "https://github.com/codewhale-hq/Codewhale/commit/549ff01ebb78856408164058fcb52db1e639ee0d",
"binarySha256": "38558ab2b4dc54590503543a0a584c5a82da6f078dd616b5b68e301bf09621b0",
"binaries": [
"codewhale-windows-arm64.exe",
"codew-windows-arm64.exe",
"codewhale-tui-windows-arm64.exe"
],
"resolved": 447,
"resolved": 444,
"unresolved": 0,
"buildRun": "https://github.com/codewhale-hq/Codewhale/actions/runs/43186389246",
"probeRun": "https://github.com/codewhale-hq/Codewhale/actions/runs/43197611105",
"buildRun": "https://github.com/codewhale-hq/Codewhale/actions/runs/42381818665",
"probeRun": "https://github.com/codewhale-hq/Codewhale/actions/runs/42389141087",
"probeWorkflow": ".github/workflows/windows-arm64-export-probe.yml",
"profile": "dist, built by release-artifacts.yml (windows-arm64 job) in release candidate run 43186389246",
"profile": "dist, built by release-artifacts.yml (windows-arm64 job) in release candidate run 42381818665",
"runtimeAcceptance": "Static export resolution only; this probe did not launch the release executables. The only ARM64 runtime acceptance is the cf7199b52e debug application run of 2026-10-13; the packaged release and older OS builds are unproven",
"exportProbeMethod": "ARM64 process: LoadLibraryExW per imported DLL with LOAD_LIBRARY_SEARCH_SYSTEM32 (0x00000800), then GetProcAddress for every imported symbol; module path and SHA256 recorded",
"scope": "System-context export resolution only; no Authenticode check, oldest supported OS, or ordinary-user install inference",
@@ -718,119 +683,66 @@
"supersedes": {
"result": "PASS",
"kind": "exact native system export resolution",
"time": "2026-11-07T16:01:13.7658879Z",
"time": "2026-10-16T10:57:53.0754367Z",
"osVersion": "10.0.26200.0",
"osBuild": "26200.9725",
"osBuild": "26200.9457",
"processArchitecture": "ARM64",
"runner": "GitHub-hosted windows-11-arm; its registry ProductName reads \"Windows 10 Enterprise\", as Windows 11 reports it",
"source": "549ff01ebb78856408164058fcb52db1e639ee0d",
"sourceUrl": "https://github.com/codewhale-hq/Codewhale/commit/549ff01ebb78856408164058fcb52db1e639ee0d",
"binarySha256": "38558ab2b4dc54590503543a0a584c5a82da6f078dd616b5b68e301bf09621b0",
"source": "db34bc8949a5b5e7e6fd5a88cb67e998011ad9df",
"sourceUrl": "https://github.com/codewhale-hq/Codewhale/commit/db34bc8949a5b5e7e6fd5a88cb67e998011ad9df",
"binarySha256": "a9a2a521d8d0c971b9d49c2c463cdb49d0c7a4eb700b73e6fed2c743e688eebb",
"binaries": [
"codewhale-windows-arm64.exe",
"codew-windows-arm64.exe",
"codewhale-tui-windows-arm64.exe"
],
"resolved": 444,
"resolved": 445,
"unresolved": 0,
"buildRun": "https://github.com/codewhale-hq/Codewhale/actions/runs/42381818665",
"probeRun": "https://github.com/codewhale-hq/Codewhale/actions/runs/42389141087",
"buildRun": "https://github.com/codewhale-hq/Codewhale/actions/runs/38867608774",
"probeRun": "https://github.com/codewhale-hq/Codewhale/actions/runs/38890634267",
"probeWorkflow": ".github/workflows/windows-arm64-export-probe.yml",
"profile": "dist, built by release-artifacts.yml (windows-arm64 job) in release candidate run 42381818665",
"profile": "release, built by release-artifacts.yml (windows-arm64 job) in release candidate run 38867608774",
"runtimeAcceptance": "Static export resolution only; this probe did not launch the release executables. The only ARM64 runtime acceptance is the cf7199b52e debug application run of 2026-10-13; the packaged release and older OS builds are unproven",
"exportProbeMethod": "ARM64 process: LoadLibraryExW per imported DLL with LOAD_LIBRARY_SEARCH_SYSTEM32 (0x00000800), then GetProcAddress for every imported symbol; module path and SHA256 recorded",
"scope": "System-context export resolution only; no Authenticode check, oldest supported OS, or ordinary-user install inference",
"systemModuleHashes": {
"advapi32.dll": "31a87f532c0e99521de26fb25d1ec33cfbb92359952580c88350891602b6cf35",
"api-ms-win-core-synch-l1-2-0.dll": "06bf19bcd0b4af9e78c46fc81fe8fb1bf950fb5f295d44de889c5028129b5b56",
"api-ms-win-crt-convert-l1-1-0.dll": "e59858bf4245cb0889d8da773612b75d58ac716be5d6bb801e1b39594dccfc6d",
"api-ms-win-crt-heap-l1-1-0.dll": "e59858bf4245cb0889d8da773612b75d58ac716be5d6bb801e1b39594dccfc6d",
"api-ms-win-crt-locale-l1-1-0.dll": "e59858bf4245cb0889d8da773612b75d58ac716be5d6bb801e1b39594dccfc6d",
"api-ms-win-crt-math-l1-1-0.dll": "e59858bf4245cb0889d8da773612b75d58ac716be5d6bb801e1b39594dccfc6d",
"api-ms-win-crt-runtime-l1-1-0.dll": "e59858bf4245cb0889d8da773612b75d58ac716be5d6bb801e1b39594dccfc6d",
"api-ms-win-crt-stdio-l1-1-0.dll": "e59858bf4245cb0889d8da773612b75d58ac716be5d6bb801e1b39594dccfc6d",
"api-ms-win-crt-string-l1-1-0.dll": "e59858bf4245cb0889d8da773612b75d58ac716be5d6bb801e1b39594dccfc6d",
"api-ms-win-crt-time-l1-1-0.dll": "e59858bf4245cb0889d8da773612b75d58ac716be5d6bb801e1b39594dccfc6d",
"api-ms-win-crt-utility-l1-1-0.dll": "e59858bf4245cb0889d8da773612b75d58ac716be5d6bb801e1b39594dccfc6d",
"bcrypt.dll": "2a016e6f29683a7e6be564424d286d0885ff4846fff87cefab4d4e5df47a8d4a",
"bcryptprimitives.dll": "6626bf08ddb38e3c37804d7a000c9684977fb0f5f7ab8eb1c0a91a654ec975fc",
"combase.dll": "6082ef654c1a0978e127a1e045cb3078b1e80f8f7108b5c6cb4bc601c69b7f51",
"crypt32.dll": "3f53b3351e75cb8847b87ce40349d48279cdddab4b19c4c6c9ad38eef6aa6baf",
"kernel32.dll": "905c5e00f64ddf9e3439a617b25d4dd1bd0fe54143ade689422b89ccb324ba3a",
"ntdll.dll": "0a7201420df35a67e76aa2f2f4adb793b0c0b4fbc927d7da112268a7beb74930",
"oleaut32.dll": "61cd70f21624866fb46102644d5af24563a30c85921ed8deed89139cab340f0b",
"shell32.dll": "b9a118ff822ab1e950c7a1009de223ecfea5d5f0a23b8a969d58ff19ce28d9cf",
"shlwapi.dll": "b4df8c76ea7999912ddf12ba026c5f4a55b683fe9234878f506080575481779a",
"user32.dll": "b95f8cce02f51a7cbf77f17582ad3eee8abc577c324963d0026e8dbb977fa097",
"userenv.dll": "e9b94fa6123048d535265214322a6a09e450dd85941292bf5258bf39d5b86f9e",
"advapi32.dll": "021452c20f89c50d64c304391ec18169f95a9fa14762f76cfd2dded627dbf0bb",
"api-ms-win-core-synch-l1-2-0.dll": "26be64c26bac33368a650372cb5fb1e3235f1bd7883471bd3291dbf6372ae274",
"api-ms-win-crt-convert-l1-1-0.dll": "529e795875178b906ea8758abc8de5c28339f336b3e7a121d7778ae040908de9",
"api-ms-win-crt-heap-l1-1-0.dll": "529e795875178b906ea8758abc8de5c28339f336b3e7a121d7778ae040908de9",
"api-ms-win-crt-locale-l1-1-0.dll": "529e795875178b906ea8758abc8de5c28339f336b3e7a121d7778ae040908de9",
"api-ms-win-crt-math-l1-1-0.dll": "529e795875178b906ea8758abc8de5c28339f336b3e7a121d7778ae040908de9",
"api-ms-win-crt-runtime-l1-1-0.dll": "529e795875178b906ea8758abc8de5c28339f336b3e7a121d7778ae040908de9",
"api-ms-win-crt-stdio-l1-1-0.dll": "529e795875178b906ea8758abc8de5c28339f336b3e7a121d7778ae040908de9",
"api-ms-win-crt-string-l1-1-0.dll": "529e795875178b906ea8758abc8de5c28339f336b3e7a121d7778ae040908de9",
"api-ms-win-crt-time-l1-1-0.dll": "529e795875178b906ea8758abc8de5c28339f336b3e7a121d7778ae040908de9",
"api-ms-win-crt-utility-l1-1-0.dll": "529e795875178b906ea8758abc8de5c28339f336b3e7a121d7778ae040908de9",
"bcrypt.dll": "12e3562488e5ca3a26884408d99d81898237c8ca3c5c3c9e40ca6b8f5ed361cd",
"bcryptprimitives.dll": "d3da1097e9e1fc16c910350e40c4405ef52f36f134f9bf165fbe02194c1eaa82",
"combase.dll": "850b43094ae61a5d3b8f40f0cddf89b4a90190bf5fc57c5242e0bf5b06644b2f",
"crypt32.dll": "e3e800f1905658674ce4af1d3efb31d722b9255aaae4fa46c54302d329ba4ab6",
"kernel32.dll": "6a7bd4daff9c0c4cc8b9478ecd9828c3f19be257a4a18db9559cae929197fffa",
"ntdll.dll": "60ef561645b50dce71ad0919dbbef3ad334a0489432cde718e75b73b558c7997",
"oleaut32.dll": "5ebb761b3f96b227006e79d6a92cf5d98ac5a5fd3c136d35dd05391bed867753",
"shell32.dll": "ef0fbd71a46fe4b35bff893ea65241cd08ea5231de704c106e39920c03698fe9",
"shlwapi.dll": "67f713491bc18614d6ea29b920e3c609cabf9eecf054990e030476a2e9f76a2c",
"user32.dll": "f2fc06e7e5016a15ad7f7f4ee250154b7d41d0a0706bf14927a3bbd4c42727dd",
"userenv.dll": "18bd1660ab7cacd90789f1bebe35bc6a5730b1e75e5582f53575473fae3db530",
"vcruntime140.dll": "3c56f4167e2b3d8e6338497731e6aae8cd7ec46bd6789f9423a8d9cf9a630310",
"winmm.dll": "4fd628e0a495b1fa7e7653b562a4f5484592c9cfe733184f9808dfce18e26680",
"ws2_32.dll": "d10c37755ef07b55829406cfaa22c623ae6f86cce5a5fed42d77d75c29ae824a"
"winmm.dll": "2ebb728e5c510492df8fde4ec81b8a8554f2f33d1686b837d22c962b7d2fa9b7",
"ws2_32.dll": "e1266872f796f68b4be9c20640ca940a7a96e1ba98c741d472ae305fccf87ca9"
},
"supersedes": {
"result": "PASS",
"kind": "exact native system export resolution",
"time": "2026-10-16T10:57:53.0754367Z",
"osVersion": "10.0.26200.0",
"osBuild": "26200.9457",
"processArchitecture": "ARM64",
"runner": "GitHub-hosted windows-11-arm; its registry ProductName reads \"Windows 10 Enterprise\", as Windows 11 reports it",
"source": "db34bc8949a5b5e7e6fd5a88cb67e998011ad9df",
"sourceUrl": "https://github.com/codewhale-hq/Codewhale/commit/db34bc8949a5b5e7e6fd5a88cb67e998011ad9df",
"binarySha256": "a9a2a521d8d0c971b9d49c2c463cdb49d0c7a4eb700b73e6fed2c743e688eebb",
"binaries": [
"codewhale-windows-arm64.exe",
"codew-windows-arm64.exe",
"codewhale-tui-windows-arm64.exe"
],
"resolved": 445,
"time": "2026-10-13T10:43:07.5742747Z",
"osVersion": "10.0.26300",
"osBuild": "26300",
"source": "cf7199b52ecb9147e4670ae7874924ffc30a6a67",
"binarySha256": "ca51f91562a0569ee43855715856e1cfee74e8a834bd6206183a3175373fdb2d",
"resolved": 453,
"unresolved": 0,
"buildRun": "https://github.com/codewhale-hq/Codewhale/actions/runs/38867608774",
"probeRun": "https://github.com/codewhale-hq/Codewhale/actions/runs/38890634267",
"probeWorkflow": ".github/workflows/windows-arm64-export-probe.yml",
"profile": "release, built by release-artifacts.yml (windows-arm64 job) in release candidate run 38867608774",
"runtimeAcceptance": "Static export resolution only; this probe did not launch the release executables. The only ARM64 runtime acceptance is the cf7199b52e debug application run of 2026-10-13; the packaged release and older OS builds are unproven",
"exportProbeMethod": "ARM64 process: LoadLibraryExW per imported DLL with LOAD_LIBRARY_SEARCH_SYSTEM32 (0x00000800), then GetProcAddress for every imported symbol; module path and SHA256 recorded",
"scope": "System-context export resolution only; no Authenticode check, oldest supported OS, or ordinary-user install inference",
"systemModuleHashes": {
"advapi32.dll": "021452c20f89c50d64c304391ec18169f95a9fa14762f76cfd2dded627dbf0bb",
"api-ms-win-core-synch-l1-2-0.dll": "26be64c26bac33368a650372cb5fb1e3235f1bd7883471bd3291dbf6372ae274",
"api-ms-win-crt-convert-l1-1-0.dll": "529e795875178b906ea8758abc8de5c28339f336b3e7a121d7778ae040908de9",
"api-ms-win-crt-heap-l1-1-0.dll": "529e795875178b906ea8758abc8de5c28339f336b3e7a121d7778ae040908de9",
"api-ms-win-crt-locale-l1-1-0.dll": "529e795875178b906ea8758abc8de5c28339f336b3e7a121d7778ae040908de9",
"api-ms-win-crt-math-l1-1-0.dll": "529e795875178b906ea8758abc8de5c28339f336b3e7a121d7778ae040908de9",
"api-ms-win-crt-runtime-l1-1-0.dll": "529e795875178b906ea8758abc8de5c28339f336b3e7a121d7778ae040908de9",
"api-ms-win-crt-stdio-l1-1-0.dll": "529e795875178b906ea8758abc8de5c28339f336b3e7a121d7778ae040908de9",
"api-ms-win-crt-string-l1-1-0.dll": "529e795875178b906ea8758abc8de5c28339f336b3e7a121d7778ae040908de9",
"api-ms-win-crt-time-l1-1-0.dll": "529e795875178b906ea8758abc8de5c28339f336b3e7a121d7778ae040908de9",
"api-ms-win-crt-utility-l1-1-0.dll": "529e795875178b906ea8758abc8de5c28339f336b3e7a121d7778ae040908de9",
"bcrypt.dll": "12e3562488e5ca3a26884408d99d81898237c8ca3c5c3c9e40ca6b8f5ed361cd",
"bcryptprimitives.dll": "d3da1097e9e1fc16c910350e40c4405ef52f36f134f9bf165fbe02194c1eaa82",
"combase.dll": "850b43094ae61a5d3b8f40f0cddf89b4a90190bf5fc57c5242e0bf5b06644b2f",
"crypt32.dll": "e3e800f1905658674ce4af1d3efb31d722b9255aaae4fa46c54302d329ba4ab6",
"kernel32.dll": "6a7bd4daff9c0c4cc8b9478ecd9828c3f19be257a4a18db9559cae929197fffa",
"ntdll.dll": "60ef561645b50dce71ad0919dbbef3ad334a0489432cde718e75b73b558c7997",
"oleaut32.dll": "5ebb761b3f96b227006e79d6a92cf5d98ac5a5fd3c136d35dd05391bed867753",
"shell32.dll": "ef0fbd71a46fe4b35bff893ea65241cd08ea5231de704c106e39920c03698fe9",
"shlwapi.dll": "67f713491bc18614d6ea29b920e3c609cabf9eecf054990e030476a2e9f76a2c",
"user32.dll": "f2fc06e7e5016a15ad7f7f4ee250154b7d41d0a0706bf14927a3bbd4c42727dd",
"userenv.dll": "18bd1660ab7cacd90789f1bebe35bc6a5730b1e75e5582f53575473fae3db530",
"vcruntime140.dll": "3c56f4167e2b3d8e6338497731e6aae8cd7ec46bd6789f9423a8d9cf9a630310",
"winmm.dll": "2ebb728e5c510492df8fde4ec81b8a8554f2f33d1686b837d22c962b7d2fa9b7",
"ws2_32.dll": "e1266872f796f68b4be9c20640ca940a7a96e1ba98c741d472ae305fccf87ca9"
},
"supersedes": {
"time": "2026-10-13T10:43:07.5742747Z",
"osVersion": "10.0.26300",
"osBuild": "26300",
"source": "cf7199b52ecb9147e4670ae7874924ffc30a6a67",
"binarySha256": "ca51f91562a0569ee43855715856e1cfee74e8a834bd6206183a3175373fdb2d",
"resolved": 453,
"unresolved": 0,
"buildCommand": "cargo build --locked --target aarch64-pc-windows-msvc -p codewhale-cli --bin codewhale",
"profile": "debug, default features",
"runtimeAcceptance": "Only cf7199b52e ARM64 debug application acceptance; final packaged release and older OS unproven"
}
"buildCommand": "cargo build --locked --target aarch64-pc-windows-msvc -p codewhale-cli --bin codewhale",
"profile": "debug, default features",
"runtimeAcceptance": "Only cf7199b52e ARM64 debug application acceptance; final packaged release and older OS unproven"
}
}
},
@@ -855,9 +767,6 @@
"GetSecurityDescriptorControl",
"GetSecurityDescriptorDacl",
"GetSecurityInfo",
"GetSidIdentifierAuthority",
"GetSidSubAuthority",
"GetSidSubAuthorityCount",
"GetTokenInformation",
"ImpersonateAnonymousToken",
"InitializeAcl",
-10
View File
@@ -2,16 +2,6 @@
## Unreleased — 0.2.0
- Add data displays: `DataSparkline`, `DataBars` and `DataTrend` (a braille
line chart). The words carry every value; the ink draws in from a caller
progress (`MotionStep::drawn`) and is at its end state under reduced or
still motion. Bars glide from their previous values.
- Add `DataDisplay`, the same displays as JSON an agent can send:
`DataDisplay::from_json` validates it (finite numbers, at most 1024 points
or 24 bars, labels up to 120 characters), `describe` says the same facts as
plain text, and `DATA_DISPLAY_SCHEMA` is its tool schema. Try
`cargo run --example data`.
- Reexport the shared avatar contract from Codewhale Protocol. Rendering and
artwork stay in this library. Protocol 0.10.3 registry proof must precede
this package publication.
-1
View File
@@ -57,7 +57,6 @@ Gallery names are the exact names accepted by the gallery example; the
| Engine `agent_roster.rs::render_agent_roster`, `widgets/workflow_panel.rs::row_receipt_text`, `gate_receipts.rs`; GPUI `usage.rs::TurnRow`, `workbar.rs::Readout`, `working_context.rs::ExecutionReceipt` | `Receipt`, `ReceiptTable`, `ReceiptValue`, `Cost`, formatting helpers | Primitive/composite | `receipt-row`, `receipt-table`, `receipt-table-compact`, `receipt-table-minimal`, `receipt-table-clipped` |
| Engine `diff_render.rs::BoundedDiffRender`, `render_diff_bounded`, `history/file_mutation.rs`; GPUI `review.rs::DiffRowKind`, `workspace/render/review.rs::ReviewRows` | `Diff`, `DiffLine`, `DiffKind`, `DiffGutter`, `DiffHighlight`, `parse_unified` | Primitive | `diff`, `diff-wrapped`, `diff-no-numbers`, `diff-highlighted`, `review-scene` |
| Engine `widgets/workflow_panel.rs::WorkflowPanel`, `WorkflowPanelRow`, `history/checklist.rs`; GPUI `plans.rs`, `workspace/render/workers.rs` | `WorkflowTree`, `TreeNode`, `TreeState`, `CountBar` | Primitive/composite | `workflow-tree`, `workflow-tree-selected`, `workflow-tree-clipped`, `workflow-tree-long`, `count-bars` |
| Kit-native; data from the host or an agent's JSON (CW-8202) | `DataSparkline`, `DataBars`, `DataBarItem`, `DataTrend`, `DataDisplay`, `DataDisplayView`, `DataWords`, `DATA_DISPLAY_SCHEMA`, `MotionStep::drawn` | Primitive | `data-sparkline`, `data-bars`, `data-bars-growing`, `data-trend`, `data-trend-drawing`, `data-from-agent` |
| Engine `approval/view.rs::ApprovalView`, `approval/elevation.rs::ElevationView`, `auto_review.rs`; GPUI `workspace/render/conversation.rs::Workspace::render_attention`, `workspace/render/review.rs::Workspace::render_restore_confirmation` | `ApprovalCard`, `ApprovalSubject`, `ApprovalState`, `ApprovalChoice`, `ApprovalPaint`, `DecisionBand`, `DecisionBandPlan`, `DecisionBandAction`, `DecisionBandSave`, `ReviewVerdict`, `ReviewAggregate` | Composite | `approval-command`, `approval-outside`, `approval-patch`, `approval-elevation`, `approval-clipped`, `approval-spoofed`, `approval-native-band`, `approval-native-band-collapsed`, `review-verdicts`, `review-aggregate` |
| GPUI `workspace/render/conversation.rs::Workspace::render_attention`, `workspace/render/files.rs::Workspace::render_artifact_column` | `AttentionQueue`, `AttentionItem`, `ArtifactShelf`, `Artifact` | Composite | `attention-queue`, `attention-focused`, `attention-narrow`, `attention-empty`, `artifact`, `artifact-shelf`, `artifact-narrow`, `artifact-unknown`, `artifact-empty` |
| Engine `views/mod.rs::ConfigView`, `ConfigRow`, `ConfigView::render_setting_detail`; GPUI `settings.rs::Spec`, `Applies`, `workspace/settings/mod.rs::Settings` | `SettingRow`, `SettingDetail`, `SettingWords` | Composite | `setting-row`, `setting-detail` |
-276
View File
@@ -1,276 +0,0 @@
//! Live data displays: `cargo run --example data`.
//!
//! A sparkline takes a new value every 400 ms, the trend draws in, and the
//! bars glide to new values every three seconds. r replays, m switches
//! between full and reduced motion, q or Escape exits.
//!
//! `--json FILE` shows one agent-sent [`DataDisplay`] instead.
//! `--text` prints the final frame and each display's plain description.
//! `--svg DIR` writes frames at fixed draw-in points.
use std::{
io,
path::PathBuf,
time::{Duration, Instant},
};
use codewhale_ratatui::{
DataBarItem, DataDisplay, DataWords, MotionEasing, MotionMode, MotionPolicy, MotionStep,
MotionTiming, Paint, Role, Theme,
testing::{self, Profile},
};
use crossterm::{
event::{self, Event, KeyCode, KeyEventKind},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{Terminal, backend::CrosstermBackend, buffer::Buffer, layout::Rect};
const TICK: Duration = Duration::from_millis(400);
const RESHUFFLE: Duration = Duration::from_secs(3);
const DRAW_IN: MotionTiming =
MotionTiming::space(Duration::from_millis(1_400), MotionEasing::EaseOut);
const GLIDE: MotionTiming = MotionTiming::PANEL;
struct Noise(u64);
impl Noise {
fn next(&mut self) -> f64 {
self.0 = self
.0
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1);
(self.0 >> 33) as f64 / f64::from(1u32 << 31)
}
}
struct Board {
noise: Noise,
requests: Vec<f64>,
latency: Vec<f64>,
bars: DataDisplay,
bars_before: DataDisplay,
draw: MotionStep,
glide: MotionStep,
agent: Option<DataDisplay>,
}
fn crate_bars(noise: &mut Noise) -> DataDisplay {
DataDisplay::Bars {
label: "Tests by crate".into(),
unit: "tests".into(),
items: ["engine", "tui", "runtime", "protocol", "config"]
.into_iter()
.map(|name| DataBarItem::new(name, (40.0 + noise.next() * 400.0).round()))
.collect(),
}
}
impl Board {
fn new(agent: Option<DataDisplay>) -> Self {
let mut noise = Noise(7);
let latency = (0..96)
.map(|i| 80.0 + 45.0 * (f64::from(i) / 9.0).sin() + noise.next() * 30.0)
.collect();
let requests = (0..40).map(|_| 20.0 + noise.next() * 60.0).collect();
let bars = crate_bars(&mut noise);
Self {
noise,
requests,
latency,
bars_before: DataDisplay::Bars {
label: String::new(),
unit: String::new(),
items: Vec::new(),
},
bars,
draw: MotionStep::at_rest(0.0),
glide: MotionStep::at_rest(1.0),
agent,
}
}
fn replay(&mut self, now: Instant, policy: MotionPolicy) {
self.draw = MotionStep::at_rest(0.0);
self.draw.go(1.0, now, DRAW_IN, policy);
}
fn tick(&mut self) {
let last = self.requests.last().copied().unwrap_or(50.0);
let next = (last + (self.noise.next() - 0.5) * 30.0).clamp(5.0, 120.0);
self.requests.push(next.round());
if self.requests.len() > 200 {
self.requests.remove(0);
}
}
fn reshuffle(&mut self, now: Instant, policy: MotionPolicy) {
self.bars_before = std::mem::replace(&mut self.bars, crate_bars(&mut self.noise));
self.glide = MotionStep::at_rest(0.0);
self.glide.go(1.0, now, GLIDE, policy);
}
fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme, draw: f32, glide: f32) {
buf.set_style(area, theme.bg(Role::Background));
let inner = Rect::new(
area.x + 2,
area.y + 1,
area.width.saturating_sub(4),
area.height.saturating_sub(2),
);
if let Some(agent) = &self.agent {
agent.view().drawn(draw).paint(inner, buf, theme);
return;
}
let row =
|y: u16, h: u16| Rect::new(inner.x, inner.y + y, inner.width, h).intersection(inner);
let requests = DataDisplay::Sparkline {
label: "Requests".into(),
unit: "rpm".into(),
values: self.requests.clone(),
};
let latency = DataDisplay::Trend {
label: "Latency".into(),
unit: "ms".into(),
values: self.latency.clone(),
rows: Some(9),
};
requests.view().drawn(draw).paint(row(0, 1), buf, theme);
latency.view().drawn(draw).paint(row(2, 9), buf, theme);
self.bars
.view()
.from(&self.bars_before)
.drawn(glide.min(draw))
.paint(row(12, 6), buf, theme);
}
fn displays(&self) -> Vec<DataDisplay> {
if let Some(agent) = &self.agent {
return vec![agent.clone()];
}
vec![
DataDisplay::Sparkline {
label: "Requests".into(),
unit: "rpm".into(),
values: self.requests.clone(),
},
DataDisplay::Trend {
label: "Latency".into(),
unit: "ms".into(),
values: self.latency.clone(),
rows: Some(9),
},
self.bars.clone(),
]
}
}
fn main() -> io::Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
let value = |flag: &str| {
args.iter()
.position(|a| a == flag)
.and_then(|i| args.get(i + 1))
.cloned()
};
let agent = match value("--json") {
Some(path) => {
let json = std::fs::read_to_string(&path)?;
match DataDisplay::from_json(&json) {
Ok(shown) => Some(shown),
Err(e) => {
eprintln!("{path}: {e}");
std::process::exit(2);
}
}
}
None => None,
};
let board = Board::new(agent);
if args.iter().any(|a| a == "--text") {
let buf = testing::render(84, 20, |area, buf| {
board.paint(area, buf, &Profile::DarkTrue.theme(), 1.0, 1.0);
});
println!("{}", testing::text(&buf));
for shown in board.displays() {
println!("{}", shown.describe(&DataWords::default()));
}
return Ok(());
}
if let Some(dir) = value("--svg") {
let dir = PathBuf::from(dir);
std::fs::create_dir_all(&dir)?;
let theme = Profile::DarkTrue.theme();
for (i, at) in [0.2_f32, 0.5, 0.8, 1.0].into_iter().enumerate() {
let buf = testing::render(84, 20, |area, buf| board.paint(area, buf, &theme, at, at));
let path = dir.join(format!("data-{i}.svg"));
std::fs::write(&path, testing::svg(&buf, &theme))?;
println!("{}", path.display());
}
return Ok(());
}
run(board)
}
fn run(mut board: Board) -> io::Result<()> {
enable_raw_mode()?;
let mut out = io::stdout();
execute!(out, EnterAlternateScreen)?;
let mut terminal = Terminal::new(CrosstermBackend::new(out))?;
let theme = Theme::detect().tui();
let mut mode = MotionMode::Full;
let mut policy = MotionPolicy::new(mode, &theme);
let start = Instant::now();
board.replay(start, policy);
let (mut next_tick, mut next_shuffle) = (start + TICK, start + RESHUFFLE);
let result = loop {
let now = Instant::now();
if now >= next_tick {
board.tick();
next_tick = now + TICK;
}
if now >= next_shuffle {
board.reshuffle(now, policy);
next_shuffle = now + RESHUFFLE;
}
let (draw, glide) = (
board.draw.drawn(now, policy),
board.glide.drawn(now, policy),
);
terminal.draw(|f| board.paint(f.area(), f.buffer_mut(), &theme, draw, glide))?;
let due = [
board.draw.next_frame_at(now, policy),
board.glide.next_frame_at(now, policy),
]
.into_iter()
.flatten()
.chain([next_tick, next_shuffle])
.min()
.unwrap_or(next_tick);
if event::poll(due.saturating_duration_since(Instant::now()))?
&& let Event::Key(key) = event::read()?
&& key.kind != KeyEventKind::Release
{
match key.code {
KeyCode::Char('q') | KeyCode::Esc => break Ok(()),
KeyCode::Char('r') => board.replay(Instant::now(), policy),
KeyCode::Char('m') => {
mode = if mode == MotionMode::Full {
MotionMode::Reduced
} else {
MotionMode::Full
};
policy = MotionPolicy::new(mode, &theme);
}
_ => {}
}
}
};
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
result
}
File diff suppressed because it is too large. Load diff
-2
View File
@@ -59,12 +59,10 @@ pub use tabs::*;
pub use toggle::*;
// Package Display.
mod chart;
mod diff;
mod progress;
mod receipt;
mod tree;
pub use chart::*;
pub use diff::*;
pub use progress::*;
pub use receipt::*;
-8
View File
@@ -366,14 +366,6 @@ impl MotionStep {
}
}
/// Progress for a draw-in, in `0..=1`: eased where `policy` lets space
/// move, the target where it does not. Feed it to a data display's
/// `drawn`.
#[must_use]
pub fn drawn(&self, now: Instant, policy: MotionPolicy) -> f32 {
self.shown(now, policy, MotionChannel::Space)
}
/// A column between `from` and `to`: slide a marker, nudge a panel.
#[must_use]
pub fn offset(&self, now: Instant, policy: MotionPolicy, from: u16, to: u16) -> u16 {
@@ -1,360 +0,0 @@
---
source: src/components/chart.rs
assertion_line: 1242
expression: "testing::snapshot(12, |area, buf, theme|\n{\n let row = |y: u16, h: u16|\n { Rect::new(area.x, area.y + y, area.width, h).intersection(area) };\n DataSparkline::new(\"Requests\",\n &values).unit(\"rpm\").paint(row(0, 1), buf, theme);\n DataBars::new(\"Tokens by model\",\n &items).unit(\"tokens\").paint(row(2, 5), buf, theme);\n DataTrend::new(\"Latency\",\n &values).unit(\"ms\").rows(5).paint(row(7, 5), buf, theme);\n})"
---
== dark-truecolor · 40x12
Requests ▁▃▅▄▅▆▇███▆▅▄▃▂▁ now 45.6 rpm
Tokens by model
opus ██████████████████ 12.4k tokens
sonnet ███████████▌ 7900 tokens
haiku ████▌ 3100 tokens
cache 0 tokens
Latency now 45.6 ms
69.7 ms │ ⡠⠒⠑⠒⠒⠢⣀ ⡀⡠⠔⠉⠉⢢⡀
│⢀⠔⠁ ⢇ ⢀⠔⠉ ⠈⠑⢄
│⠁ ⠑⠢⡀ ⡠⠊
17.2 ms │ ⠈⠱⣀⣀⠤⠊⠊
---
r0 c0..8 fg=Foreground bg=Background bold
r0 c8..10 bg=Background
r0 c10..26 fg=Accent bg=Background
r0 c26..28 bg=Background
r0 c28..32 fg=Muted bg=Background
r0 c32..40 fg=Foreground bg=Background
r1 c0..40 bg=Background
r2 c0..15 fg=Foreground bg=Background bold
r2 c15..40 bg=Background
r3 c0..6 fg=Foreground bg=Background
r3 c6..8 bg=Background
r3 c8..26 fg=Accent bg=Background
r3 c26..28 bg=Background
r3 c28..40 fg=Muted bg=Background
r4 c0..6 fg=Foreground bg=Background
r4 c6..8 bg=Background
r4 c8..20 fg=Accent bg=Background
r4 c20..28 bg=Background
r4 c28..39 fg=Muted bg=Background
r4 c39..40 bg=Background
r5 c0..6 fg=Foreground bg=Background
r5 c6..8 bg=Background
r5 c8..13 fg=Accent bg=Background
r5 c13..28 bg=Background
r5 c28..39 fg=Muted bg=Background
r5 c39..40 bg=Background
r6 c0..6 fg=Foreground bg=Background
r6 c6..28 bg=Background
r6 c28..36 fg=Muted bg=Background
r6 c36..40 bg=Background
r7 c0..7 fg=Foreground bg=Background bold
r7 c7..9 bg=Background
r7 c9..13 fg=Muted bg=Background
r7 c13..20 fg=Foreground bg=Background
r7 c20..40 bg=Background
r8 c0..8 fg=Muted bg=Background
r8 c8..9 fg=Dim bg=Background
r8 c9..11 bg=Background
r8 c11..18 fg=Accent bg=Background
r8 c18..31 bg=Background
r8 c31..38 fg=Accent bg=Background
r8 c38..40 bg=Background
r9 c0..8 fg=Muted bg=Background
r9 c8..9 fg=Dim bg=Background
r9 c9..12 fg=Accent bg=Background
r9 c12..18 bg=Background
r9 c18..19 fg=Accent bg=Background
r9 c19..29 bg=Background
r9 c29..32 fg=Accent bg=Background
r9 c32..37 bg=Background
r9 c37..40 fg=Accent bg=Background
r10 c0..8 fg=Muted bg=Background
r10 c8..9 fg=Dim bg=Background
r10 c9..10 fg=Accent bg=Background
r10 c10..19 bg=Background
r10 c19..22 fg=Accent bg=Background
r10 c22..28 bg=Background
r10 c28..30 fg=Accent bg=Background
r10 c30..40 bg=Background
r11 c0..8 fg=Muted bg=Background
r11 c8..9 fg=Dim bg=Background
r11 c9..21 bg=Background
r11 c21..28 fg=Accent bg=Background
r11 c28..40 bg=Background
== dark-truecolor · 80x12
Requests ▂▁▁▁▁▁▂▂▂▃▃▄▅▆▆▆▇▇████▇▇▆▆▅▅ now 45.6 rpm min 17.2 rpm max 69.7 rpm
Tokens by model
opus ██████████████████████████████████████████████████████████ 12.4k tokens
sonnet █████████████████████████████████████ 7900 tokens
haiku ██████████████▌ 3100 tokens
cache 0 tokens
Latency now 45.6 ms min 17.2 ms max 69.7 ms
69.7 ms │ ⣀⠤⠒⠊⠑⠒⠒⠒⠒⠒⠒⠢⠤⢄⣀ ⣀ ⣀⡠⠤⠒⠒⠉⠉⠉⠉⠢⢄⣀⣀
│ ⣀⠤⠒⠉ ⠑⠤⣀ ⢀⡠⠒⠉ ⠉ ⠉⠑⠒⠤⣀
│⠉ ⠉⠑⠒⠤⢄⣀ ⡠⠔⠊⠁
17.2 ms │ ⠉⠑⢄⣀⣀⣀⣀⣀⠤⠤⠒⠊⠒⠊⠉
---
r0 c0..8 fg=Foreground bg=Background bold
r0 c8..10 bg=Background
r0 c10..38 fg=Accent bg=Background
r0 c38..40 bg=Background
r0 c40..44 fg=Muted bg=Background
r0 c44..52 fg=Foreground bg=Background
r0 c52..54 bg=Background
r0 c54..58 fg=Muted bg=Background
r0 c58..66 fg=Foreground bg=Background
r0 c66..68 bg=Background
r0 c68..72 fg=Muted bg=Background
r0 c72..80 fg=Foreground bg=Background
r1 c0..80 bg=Background
r2 c0..15 fg=Foreground bg=Background bold
r2 c15..80 bg=Background
r3 c0..6 fg=Foreground bg=Background
r3 c6..8 bg=Background
r3 c8..66 fg=Accent bg=Background
r3 c66..68 bg=Background
r3 c68..80 fg=Muted bg=Background
r4 c0..6 fg=Foreground bg=Background
r4 c6..8 bg=Background
r4 c8..45 fg=Accent bg=Background
r4 c45..68 bg=Background
r4 c68..79 fg=Muted bg=Background
r4 c79..80 bg=Background
r5 c0..6 fg=Foreground bg=Background
r5 c6..8 bg=Background
r5 c8..23 fg=Accent bg=Background
r5 c23..68 bg=Background
r5 c68..79 fg=Muted bg=Background
r5 c79..80 bg=Background
r6 c0..6 fg=Foreground bg=Background
r6 c6..68 bg=Background
r6 c68..76 fg=Muted bg=Background
r6 c76..80 bg=Background
r7 c0..7 fg=Foreground bg=Background bold
r7 c7..9 bg=Background
r7 c9..13 fg=Muted bg=Background
r7 c13..20 fg=Foreground bg=Background
r7 c20..22 bg=Background
r7 c22..26 fg=Muted bg=Background
r7 c26..33 fg=Foreground bg=Background
r7 c33..35 bg=Background
r7 c35..39 fg=Muted bg=Background
r7 c39..46 fg=Foreground bg=Background
r7 c46..80 bg=Background
r8 c0..8 fg=Muted bg=Background
r8 c8..9 fg=Dim bg=Background
r8 c9..14 bg=Background
r8 c14..29 fg=Accent bg=Background
r8 c29..60 bg=Background
r8 c60..61 fg=Accent bg=Background
r8 c61..62 bg=Background
r8 c62..75 fg=Accent bg=Background
r8 c75..80 bg=Background
r9 c0..8 fg=Muted bg=Background
r9 c8..9 fg=Dim bg=Background
r9 c9..10 bg=Background
r9 c10..14 fg=Accent bg=Background
r9 c14..29 bg=Background
r9 c29..32 fg=Accent bg=Background
r9 c32..56 bg=Background
r9 c56..60 fg=Accent bg=Background
r9 c60..61 bg=Background
r9 c61..62 fg=Accent bg=Background
r9 c62..75 bg=Background
r9 c75..80 fg=Accent bg=Background
r10 c0..8 fg=Muted bg=Background
r10 c8..9 fg=Dim bg=Background
r10 c9..10 fg=Accent bg=Background
r10 c10..32 bg=Background
r10 c32..38 fg=Accent bg=Background
r10 c38..53 bg=Background
r10 c53..57 fg=Accent bg=Background
r10 c57..80 bg=Background
r11 c0..8 fg=Muted bg=Background
r11 c8..9 fg=Dim bg=Background
r11 c9..38 bg=Background
r11 c38..53 fg=Accent bg=Background
r11 c53..80 bg=Background
== dark-truecolor · 120x12
Requests ▄▅▆▆▇██▇██▇▇▇▇▅▅▄▄▃▃▂▁▁▁▁▁▂▂▂▃▃▄▅▆▆▆▇▇████▇▇▆▆▅▅ now 45.6 rpm min 17.2 rpm max 69.7 rpm
Tokens by model
opus ██████████████████████████████████████████████████████████████████████████████████████████████████ 12.4k tokens
sonnet ██████████████████████████████████████████████████████████████▍ 7900 tokens
haiku ████████████████████████▌ 3100 tokens
cache 0 tokens
Latency now 45.6 ms min 17.2 ms max 69.7 ms
69.7 ms │ ⢀⣀⠤⠤⠒⠒⠊⠉⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠢⠤⠤⠤⣀⣀ ⣀⡀ ⣀⣀⠤⠤⠒⠒⠒⠊⠉⠉⠉⠉⠉⠉⠒⠤⢄⣀⣀⣀
│ ⢀⣀⠤⠔⠒⠉⠁ ⠉⠢⠤⣀⣀ ⣀⡠⠤⠒⠊⠉ ⠈⠉ ⠉⠉⠑⠒⠒⠤⠤⣀
│⠉⠁ ⠉⠉⠒⠒⠢⠤⢄⣀⣀ ⢀⣀⠤⠔⠊⠉
17.2 ms │ ⠉⠉⠑⠢⢄⣀⣀⣀⣀⣀⣀⣀⡠⠤⠤⠤⠒⠒⠊⠑⠒⠒⠉⠁
---
r0 c0..8 fg=Foreground bg=Background bold
r0 c8..10 bg=Background
r0 c10..58 fg=Accent bg=Background
r0 c58..60 bg=Background
r0 c60..64 fg=Muted bg=Background
r0 c64..72 fg=Foreground bg=Background
r0 c72..74 bg=Background
r0 c74..78 fg=Muted bg=Background
r0 c78..86 fg=Foreground bg=Background
r0 c86..88 bg=Background
r0 c88..92 fg=Muted bg=Background
r0 c92..100 fg=Foreground bg=Background
r0 c100..120 bg=Background
r1 c0..120 bg=Background
r2 c0..15 fg=Foreground bg=Background bold
r2 c15..120 bg=Background
r3 c0..6 fg=Foreground bg=Background
r3 c6..8 bg=Background
r3 c8..106 fg=Accent bg=Background
r3 c106..108 bg=Background
r3 c108..120 fg=Muted bg=Background
r4 c0..6 fg=Foreground bg=Background
r4 c6..8 bg=Background
r4 c8..71 fg=Accent bg=Background
r4 c71..108 bg=Background
r4 c108..119 fg=Muted bg=Background
r4 c119..120 bg=Background
r5 c0..6 fg=Foreground bg=Background
r5 c6..8 bg=Background
r5 c8..33 fg=Accent bg=Background
r5 c33..108 bg=Background
r5 c108..119 fg=Muted bg=Background
r5 c119..120 bg=Background
r6 c0..6 fg=Foreground bg=Background
r6 c6..108 bg=Background
r6 c108..116 fg=Muted bg=Background
r6 c116..120 bg=Background
r7 c0..7 fg=Foreground bg=Background bold
r7 c7..9 bg=Background
r7 c9..13 fg=Muted bg=Background
r7 c13..20 fg=Foreground bg=Background
r7 c20..22 bg=Background
r7 c22..26 fg=Muted bg=Background
r7 c26..33 fg=Foreground bg=Background
r7 c33..35 bg=Background
r7 c35..39 fg=Muted bg=Background
r7 c39..46 fg=Foreground bg=Background
r7 c46..120 bg=Background
r8 c0..8 fg=Muted bg=Background
r8 c8..9 fg=Dim bg=Background
r8 c9..16 bg=Background
r8 c16..40 fg=Accent bg=Background
r8 c40..89 bg=Background
r8 c89..91 fg=Accent bg=Background
r8 c91..92 bg=Background
r8 c92..112 fg=Accent bg=Background
r8 c112..120 bg=Background
r9 c0..8 fg=Muted bg=Background
r9 c8..9 fg=Dim bg=Background
r9 c9..10 bg=Background
r9 c10..17 fg=Accent bg=Background
r9 c17..40 bg=Background
r9 c40..45 fg=Accent bg=Background
r9 c45..83 bg=Background
r9 c83..89 fg=Accent bg=Background
r9 c89..90 bg=Background
r9 c90..92 fg=Accent bg=Background
r9 c92..112 bg=Background
r9 c112..120 fg=Accent bg=Background
r10 c0..8 fg=Muted bg=Background
r10 c8..9 fg=Dim bg=Background
r10 c9..11 fg=Accent bg=Background
r10 c11..45 bg=Background
r10 c45..54 fg=Accent bg=Background
r10 c54..77 bg=Background
r10 c77..83 fg=Accent bg=Background
r10 c83..120 bg=Background
r11 c0..8 fg=Muted bg=Background
r11 c8..9 fg=Dim bg=Background
r11 c9..54 bg=Background
r11 c54..78 fg=Accent bg=Background
r11 c78..120 bg=Background
== no-color · 40x12
Requests ▁▃▅▄▅▆▇███▆▅▄▃▂▁ now 45.6 rpm
Tokens by model
opus ██████████████████ 12.4k tokens
sonnet ███████████▌ 7900 tokens
haiku ████▌ 3100 tokens
cache 0 tokens
Latency now 45.6 ms
69.7 ms │ ⡠⠒⠑⠒⠒⠢⣀ ⡀⡠⠔⠉⠉⢢⡀
│⢀⠔⠁ ⢇ ⢀⠔⠉ ⠈⠑⢄
│⠁ ⠑⠢⡀ ⡠⠊
17.2 ms │ ⠈⠱⣀⣀⠤⠊⠊
== no-color · 80x12
Requests ▂▁▁▁▁▁▂▂▂▃▃▄▅▆▆▆▇▇████▇▇▆▆▅▅ now 45.6 rpm min 17.2 rpm max 69.7 rpm
Tokens by model
opus ██████████████████████████████████████████████████████████ 12.4k tokens
sonnet █████████████████████████████████████ 7900 tokens
haiku ██████████████▌ 3100 tokens
cache 0 tokens
Latency now 45.6 ms min 17.2 ms max 69.7 ms
69.7 ms │ ⣀⠤⠒⠊⠑⠒⠒⠒⠒⠒⠒⠢⠤⢄⣀ ⣀ ⣀⡠⠤⠒⠒⠉⠉⠉⠉⠢⢄⣀⣀
│ ⣀⠤⠒⠉ ⠑⠤⣀ ⢀⡠⠒⠉ ⠉ ⠉⠑⠒⠤⣀
│⠉ ⠉⠑⠒⠤⢄⣀ ⡠⠔⠊⠁
17.2 ms │ ⠉⠑⢄⣀⣀⣀⣀⣀⠤⠤⠒⠊⠒⠊⠉
== no-color · 120x12
Requests ▄▅▆▆▇██▇██▇▇▇▇▅▅▄▄▃▃▂▁▁▁▁▁▂▂▂▃▃▄▅▆▆▆▇▇████▇▇▆▆▅▅ now 45.6 rpm min 17.2 rpm max 69.7 rpm
Tokens by model
opus ██████████████████████████████████████████████████████████████████████████████████████████████████ 12.4k tokens
sonnet ██████████████████████████████████████████████████████████████▍ 7900 tokens
haiku ████████████████████████▌ 3100 tokens
cache 0 tokens
Latency now 45.6 ms min 17.2 ms max 69.7 ms
69.7 ms │ ⢀⣀⠤⠤⠒⠒⠊⠉⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠢⠤⠤⠤⣀⣀ ⣀⡀ ⣀⣀⠤⠤⠒⠒⠒⠊⠉⠉⠉⠉⠉⠉⠒⠤⢄⣀⣀⣀
│ ⢀⣀⠤⠔⠒⠉⠁ ⠉⠢⠤⣀⣀ ⣀⡠⠤⠒⠊⠉ ⠈⠉ ⠉⠉⠑⠒⠒⠤⠤⣀
│⠉⠁ ⠉⠉⠒⠒⠢⠤⢄⣀⣀ ⢀⣀⠤⠔⠊⠉
17.2 ms │ ⠉⠉⠑⠢⢄⣀⣀⣀⣀⣀⣀⣀⡠⠤⠤⠤⠒⠒⠊⠑⠒⠒⠉⠁
== ascii · 40x12
Requests _.---=####=--.__ now 45.6 rpm
Tokens by model
opus ################## 12.4k tokens
sonnet ############ 7900 tokens
haiku ##### 3100 tokens
cache 0 tokens
Latency now 45.6 ms
69.7 ms | ....... .....:.
|... : ... ...
|. ... ..
17.2 ms | .:.....
== ascii · 80x12
Requests _________..--===########==-- now 45.6 rpm min 17.2 rpm max 69.7 rpm
Tokens by model
opus ########################################################## 12.4k tokens
sonnet ##################################### 7900 tokens
haiku ############### 3100 tokens
cache 0 tokens
Latency now 45.6 ms min 17.2 ms max 69.7 ms
69.7 ms | ............... . .............
| .... ... .... . .....
|. ...... ....
17.2 ms | ...............
== ascii · 120x12
Requests --==##########----.._________..--===########==-- now 45.6 rpm min 17.2 rpm max 69.7 rpm
Tokens by model
opus ################################################################################################## 12.4k tokens
sonnet ############################################################### 7900 tokens
haiku ######################### 3100 tokens
cache 0 tokens
Latency now 45.6 ms min 17.2 ms max 69.7 ms
69.7 ms | ........................ .. ....................
| ....... ..... ...... .. ........
|.. ......... ......
17.2 ms | ........................
-118
View File
@@ -1,118 +0,0 @@
//! Gallery: data displays fully drawn, mid draw-in, empty and from agent JSON.
use ratatui::{buffer::Buffer, layout::Rect};
use super::Entry;
use crate::{DataBarItem, DataBars, DataDisplay, DataSparkline, DataTrend, Paint, Theme};
fn series() -> Vec<f64> {
(0..64)
.map(|i| 120.0 + 60.0 * (f64::from(i) / 6.0).sin() + f64::from((i * 7) % 11) * 3.0)
.collect()
}
fn items() -> Vec<DataBarItem> {
vec![
DataBarItem::new("engine", 412.0),
DataBarItem::new("tui", 268.0),
DataBarItem::new("runtime", 131.0),
DataBarItem::new("protocol", 44.0),
]
}
fn row(area: Rect, y: u16, h: u16) -> Rect {
Rect::new(area.x, area.y.saturating_add(y), area.width, h).intersection(area)
}
fn sparklines(area: Rect, buf: &mut Buffer, theme: &Theme) {
let values = series();
DataSparkline::new("Requests", &values)
.unit("rpm")
.paint(row(area, 0, 1), buf, theme);
DataSparkline::new("Requests", &values)
.unit("rpm")
.drawn(0.45)
.paint(row(area, 1, 1), buf, theme);
DataSparkline::new("Requests", &[]).paint(row(area, 2, 1), buf, theme);
}
fn bars(area: Rect, buf: &mut Buffer, theme: &Theme) {
let items = items();
DataBars::new("Tests by crate", &items).paint(row(area, 0, 5), buf, theme);
}
fn bars_growing(area: Rect, buf: &mut Buffer, theme: &Theme) {
let items = items();
DataBars::new("Tests by crate", &items)
.previous(&[300.0, 300.0, 300.0, 300.0])
.drawn(0.5)
.paint(row(area, 0, 5), buf, theme);
}
fn trend(area: Rect, buf: &mut Buffer, theme: &Theme) {
let values = series();
DataTrend::new("Latency", &values)
.unit("ms")
.rows(7)
.paint(area, buf, theme);
}
fn trend_drawing(area: Rect, buf: &mut Buffer, theme: &Theme) {
let values = series();
DataTrend::new("Latency", &values)
.unit("ms")
.rows(7)
.drawn(0.6)
.paint(area, buf, theme);
}
fn from_agent(area: Rect, buf: &mut Buffer, theme: &Theme) {
let shown = DataDisplay::from_json(
r#"{"kind":"bars","label":"Tokens this turn","unit":"tokens",
"items":[{"label":"read","value":18200},{"label":"wrote","value":2400},
{"label":"cached","value":61000}]}"#,
)
.expect("gallery fixture is a valid data display");
shown.view().paint(area, buf, theme);
}
pub(crate) fn entries() -> Vec<Entry> {
vec![
Entry {
name: "data-sparkline",
width: 80,
height: 3,
draw: sparklines,
},
Entry {
name: "data-bars",
width: 60,
height: 5,
draw: bars,
},
Entry {
name: "data-bars-growing",
width: 60,
height: 5,
draw: bars_growing,
},
Entry {
name: "data-trend",
width: 72,
height: 7,
draw: trend,
},
Entry {
name: "data-trend-drawing",
width: 72,
height: 7,
draw: trend_drawing,
},
Entry {
name: "data-from-agent",
width: 60,
height: 4,
draw: from_agent,
},
]
}
-2
View File
@@ -31,7 +31,6 @@ mod approval;
mod artifacts;
mod atmosphere;
mod attention;
mod chart;
mod chrome;
mod display;
mod habitat;
@@ -105,7 +104,6 @@ pub fn entries() -> Vec<Entry> {
lists::entries(),
chrome::entries(),
display::entries(),
chart::entries(),
approval::entries(),
motion::entries(),
workspace::entries(),
-19
View File
@@ -1,19 +0,0 @@
import { DocArticle } from "../_components/doc-article";
import { getDocsFirstTime } from "@/lib/i18n/dictionaries";
import { buildPageMetadata } from "@/lib/page-meta";
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
const t = getDocsFirstTime(locale);
return buildPageMetadata({
path: "/docs/first-time",
locale,
title: t.metaTitle,
description: t.metaDescription,
});
}
export default async function FirstTimePage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
return <DocArticle t={getDocsFirstTime(locale)} locale={locale} />;
}
-24
View File
@@ -97,30 +97,6 @@ export default async function InstallPage({ params }: { params: Promise<{ locale
<div lang={copyLocale}>
{locale === "zh" ? mirrors : null}
<Block id="install-new-to-terminal" title={t(INSTALL_COPY.newTitle)}>
{p(INSTALL_COPY.newLead)}
<Channel id="install-new-macos" title={t(INSTALL_COPY.newMacTitle)}>
<ol>
<li><Rich text={t(INSTALL_COPY.newMac1)} /></li>
<li><Rich text={t(INSTALL_COPY.newMac2)} />{code(INSTALL_COMMANDS.shell)}</li>
<li><Rich text={t(INSTALL_COPY.newMac3)} /></li>
</ol>
</Channel>
<Channel id="install-new-windows" title={t(INSTALL_COPY.newWinTitle)}>
<ol>
<li><Rich text={t(INSTALL_COPY.newWin1)} /></li>
<li><Rich text={t(INSTALL_COPY.newWin2)} />{code(INSTALL_COMMANDS.windows)}</li>
<li><Rich text={t(INSTALL_COPY.newWin3)} /></li>
</ol>
</Channel>
<p>
<Link href={`/${locale}/docs/first-time`} className="section-link">
{t(INSTALL_COPY.newNext)}
<Icon name="arrow-right" className="icon icon-flip" />
</Link>
</p>
</Block>
<Block id="install-path" title={t(INSTALL_COPY.pathTitle)}>
{p(INSTALL_COPY.pathPlaces)}
<ul>
+4 -19
View File
@@ -2,16 +2,16 @@ import Link from "next/link";
import { Icon } from "@/components/icon";
import { PageHeader, Section } from "@/components/page-header";
import { buildPageMetadata } from "@/lib/page-meta";
import { PLUGINS_COPY, PLUGINS_COMING, pluginsComingApplies, PLUGIN_KINDS, FEATURED_CONNECTORS, CATALOG_FEATURED, PLUGIN_SOURCES, PLUGIN_COMMANDS, PLUGIN_COMPATIBILITY } from "@/lib/content/plugins";
import { PLUGINS_COPY, PLUGIN_KINDS, FEATURED_CONNECTORS, CATALOG_FEATURED, PLUGIN_SOURCES, PLUGIN_COMMANDS, PLUGIN_COMPATIBILITY } from "@/lib/content/plugins";
import { CATALOG_PLUGINS, CONNECTORS, type ConnectorAuth } from "@/lib/content/connectors.generated";
import type { LocalizedText } from "@/lib/content/vocabulary";
import { getFacts } from "@/lib/facts";
import { pickText } from "@/lib/i18n/dictionaries";
import { FallbackRegion } from "@/components/fallback-region";
export const revalidate = 300;
const MARKETPLACE_REPO = "https://github.com/codewhale-hq/codewhale-plugin-marketplace";
const AUTHORING_DOC = "https://github.com/codewhale-hq/CodeWhale/blob/main/docs/PLUGIN_AUTHORING.md";
const DSH_REPO = "https://github.com/deepseek-ai/deepseek-harness";
const DSH_COMPAT_DOC = "https://github.com/codewhale-hq/Codewhale/blob/main/docs/DSH_PLUGIN_COMPAT.md";
@@ -35,8 +35,6 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
export default async function PluginsPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
const t = (copy: LocalizedText) => pickText(copy, locale);
const showComing = pluginsComingApplies((await getFacts()).latestPublishedRelease);
const authoringDoc = `/${locale}/docs/plugin-authoring`;
const localHref = (href: string) => (href.startsWith("/") ? `/${locale}${href}` : href);
const moreConnectors = CONNECTORS.length - featuredConnectors.length;
const moreCatalog = CATALOG_PLUGINS.length - CATALOG_FEATURED.length;
@@ -51,7 +49,7 @@ export default async function PluginsPage({ params }: { params: Promise<{ locale
actions={
<>
<Link href="#catalog-title" className="btn btn-primary btn-lg">{t(PLUGINS_COPY.marketplaceCta)}</Link>
<Link href={authoringDoc} className="btn btn-secondary btn-lg">{t(PLUGINS_COPY.docsCta)}</Link>
<Link href={AUTHORING_DOC} className="btn btn-secondary btn-lg">{t(PLUGINS_COPY.docsCta)}</Link>
</>
}
/>
@@ -80,19 +78,6 @@ export default async function PluginsPage({ params }: { params: Promise<{ locale
</div>
</Section>
{showComing ? (
<Section id="coming-title" title={t(PLUGINS_COPY.comingTitle)} scope={t(PLUGINS_COPY.comingLead)}>
<ul className="plugin-kinds plugin-kinds-coming" role="list" data-release-scope="coming">
{PLUGINS_COMING.map((item) => (
<li key={item.title.en}>
<h3>{t(item.title)}</h3>
<p>{t(item.detail)}</p>
</li>
))}
</ul>
</Section>
) : null}
<Section id="connectors-title" title={t(PLUGINS_COPY.connectorsTitle)} scope={t(PLUGINS_COPY.connectorsLead)}>
<ul className="connector-grid" role="list">
{featuredConnectors.map((connector) => (
@@ -188,7 +173,7 @@ export default async function PluginsPage({ params }: { params: Promise<{ locale
<Section id="contribute-title" title={t(PLUGINS_COPY.contributeTitle)} scope={t(PLUGINS_COPY.contributeLead)}>
<div className="actions">
<Link href={authoringDoc} className="btn btn-secondary">{t(PLUGINS_COPY.docsCta)}</Link>
<Link href={AUTHORING_DOC} className="btn btn-secondary">{t(PLUGINS_COPY.docsCta)}</Link>
<Link href={`${MARKETPLACE_REPO}/issues`} className="section-link">{t(PLUGINS_COPY.contributeLink)}<Icon name="external" className="icon" /></Link>
</div>
</Section>
-1
View File
@@ -23,7 +23,6 @@
.install-guide ul { list-style: disc; }
.install-guide ol { list-style: decimal; }
.install-guide li { margin-block: var(--space-1); }
.install-guide li > .code { margin-block: var(--space-3); }
.install-guide ::marker { color: var(--muted); }
.install-guide a:not(.btn) { color: var(--accent); text-decoration: underline; text-decoration-color: color-mix(in srgb, var(--accent) 40%, transparent); text-underline-offset: 0.2em; }
.install-guide a:not(.btn):hover { text-decoration-color: var(--accent); }
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff. Show more