mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-13 03:21:33 -10:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac501c9562 | ||
|
|
d7cadafbab | ||
|
|
f03f69e086 | ||
|
|
84a359272f | ||
|
|
622a493078 | ||
|
|
8aa7707d04 | ||
|
|
97c1ad923d | ||
|
|
00cf9f002d | ||
|
|
2cd5969e31 | ||
|
|
5d7ca06a5a | ||
|
|
31178aec57 | ||
|
|
5500fee639 | ||
|
|
30952497ca | ||
|
|
9c10aabf78 | ||
|
|
2c9174012e | ||
|
|
898c192257 | ||
|
|
3f2a019444 | ||
|
|
eef19b9d30 | ||
|
|
f9236ad8f7 |
No files matched your search
@@ -319,3 +319,5 @@ namezzb = sailorZ <[email protected]>
|
||||
alphapku-ux = Alpha Wu <[email protected]>
|
||||
BX166 = BX166 <[email protected]>
|
||||
c020627 = c020627 <[email protected]>
|
||||
[email protected] = Parman Mohammadalizadeh <[email protected]>
|
||||
Parman Mohammadalizadeh = Parman Mohammadalizadeh <[email protected]>
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
id: sccache
|
||||
continue-on-error: true
|
||||
- name: Enable sccache
|
||||
if: steps.sccache.outcome == 'success' && runner.os != 'Windows'
|
||||
if: steps.sccache.outcome == 'success'
|
||||
shell: bash
|
||||
run: |
|
||||
{
|
||||
|
||||
@@ -178,7 +178,7 @@ jobs:
|
||||
id: sccache
|
||||
continue-on-error: true
|
||||
- name: Enable sccache
|
||||
if: steps.sccache.outcome == 'success' && runner.os != 'Windows'
|
||||
if: steps.sccache.outcome == 'success'
|
||||
shell: bash
|
||||
run: |
|
||||
{
|
||||
|
||||
@@ -7,8 +7,15 @@ 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.
|
||||
|
||||
Generated
+1
@@ -1179,6 +1179,7 @@ dependencies = [
|
||||
"agent-client-protocol",
|
||||
"agent-client-protocol-schema",
|
||||
"anyhow",
|
||||
"arboard",
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
"axum",
|
||||
|
||||
@@ -3049,6 +3049,7 @@ fn start_cli_telemetry(
|
||||
telemetry::init(consent);
|
||||
telemetry::record(Event::SessionStart {
|
||||
source: SessionSource::Unknown,
|
||||
install_age: telemetry::install_age(),
|
||||
});
|
||||
Some(CliTelemetrySession {
|
||||
started: std::time::Instant::now(),
|
||||
|
||||
@@ -484,6 +484,22 @@ pub fn public_model_identifier(model: &str) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Case-insensitive form of [`public_model_identifier`] for ids that were
|
||||
/// lowercased before the lookup.
|
||||
#[must_use]
|
||||
pub fn public_model_identifier_ignore_case(model: &str) -> bool {
|
||||
let catalog = super::bundled_models_dev_catalog();
|
||||
let matches = |id: &str| id.eq_ignore_ascii_case(model);
|
||||
catalog.reviewed.intrinsic.keys().any(|id| matches(id))
|
||||
|| catalog.models.keys().any(|id| matches(id))
|
||||
|| catalog.providers.values().any(|provider| {
|
||||
provider
|
||||
.models
|
||||
.iter()
|
||||
.any(|(id, row)| matches(id) || matches(&row.id))
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn official_deepseek_model_id(model: &str) -> Option<&'static str> {
|
||||
bundled_reviewed()
|
||||
|
||||
@@ -140,48 +140,17 @@ impl EnvRuntimeOverrides {
|
||||
Self {
|
||||
provider,
|
||||
provider_source,
|
||||
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()),
|
||||
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"]),
|
||||
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"]),
|
||||
@@ -216,9 +185,7 @@ impl EnvRuntimeOverrides {
|
||||
None
|
||||
}
|
||||
}),
|
||||
http_headers: std::env::var("CODEWHALE_HTTP_HEADERS")
|
||||
.or_else(|_| std::env::var("DEEPSEEK_HTTP_HEADERS"))
|
||||
.ok()
|
||||
http_headers: env_non_blank(&["CODEWHALE_HTTP_HEADERS", "DEEPSEEK_HTTP_HEADERS"])
|
||||
.and_then(|value| match parse_http_headers(&value) {
|
||||
Ok(h) => Some(h),
|
||||
Err(_) => {
|
||||
@@ -227,290 +194,85 @@ impl EnvRuntimeOverrides {
|
||||
}
|
||||
})
|
||||
.filter(|headers| !headers.is_empty()),
|
||||
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()),
|
||||
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"]),
|
||||
// `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: 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()),
|
||||
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"]),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ pub const SETUP_STATE_FILE_NAME: &str = "setup_state.json";
|
||||
/// when the collection policy, schema, or disclosure materially changes. Prior
|
||||
/// declines remain off. Keying it to the app version would re-prompt every
|
||||
/// release, which is nagging with extra steps.
|
||||
pub const TELEMETRY_NOTICE_VERSION: &str = "6";
|
||||
pub const TELEMETRY_NOTICE_VERSION: &str = "8";
|
||||
|
||||
/// Canonical setup step ids. The ordering matches the first-run spine so a
|
||||
/// `BTreeMap<SetupStep, _>` renders in wizard order.
|
||||
|
||||
@@ -27,10 +27,11 @@ 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(
|
||||
std::env::var("CODEWHALE_STREAM_OPEN_TIMEOUT_SECS")
|
||||
.or_else(|_| std::env::var("DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS"))
|
||||
.ok()
|
||||
.as_deref(),
|
||||
crate::env_overrides::env_non_blank(&[
|
||||
"CODEWHALE_STREAM_OPEN_TIMEOUT_SECS",
|
||||
"DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS",
|
||||
])
|
||||
.as_deref(),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -50,9 +51,7 @@ 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 {
|
||||
std::env::var("CODEWHALE_FORCE_HTTP1")
|
||||
.or_else(|_| std::env::var("DEEPSEEK_FORCE_HTTP1"))
|
||||
.ok()
|
||||
crate::env_overrides::env_non_blank(&["CODEWHALE_FORCE_HTTP1", "DEEPSEEK_FORCE_HTTP1"])
|
||||
.map(|v| v.trim().to_ascii_lowercase())
|
||||
.is_some_and(|v| matches!(v.as_str(), "1" | "true" | "yes" | "on"))
|
||||
}
|
||||
@@ -10767,6 +10767,60 @@ 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() {
|
||||
|
||||
@@ -1183,7 +1183,7 @@ impl Git {
|
||||
overrides: Vec<(String, &'static str)>,
|
||||
) -> anyhow::Result<Command> {
|
||||
let mut command = Self::review_base(workspace)?;
|
||||
let mut count = 2;
|
||||
let mut count = 3;
|
||||
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 and replace objects disabled,
|
||||
/// Git with fsmonitor, hooks, lazy fetch, replace objects and path quoting 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", "2")
|
||||
.env("GIT_CONFIG_COUNT", "3")
|
||||
.env("GIT_CONFIG_KEY_0", "core.fsmonitor")
|
||||
.env("GIT_CONFIG_VALUE_0", "false")
|
||||
.env("GIT_CONFIG_KEY_1", "core.hooksPath")
|
||||
@@ -1265,6 +1265,8 @@ 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)
|
||||
|
||||
@@ -219,6 +219,9 @@ libc = "0.2"
|
||||
windows-sys = { version = "0.61.2", features = ["Wdk_Storage_FileSystem", "Win32_System_IO", "Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Security_Isolation", "Win32_Storage_FileSystem", "Win32_System_LibraryLoader", "Win32_System_Pipes", "Win32_System_Threading", "Win32_System_SystemServices", "Win32_System_WindowsProgramming"] }
|
||||
|
||||
|
||||
[target.'cfg(any(target_os = "macos", target_os = "windows", all(target_os = "linux", not(target_env = "ohos"))))'.dependencies]
|
||||
arboard = { version = "3.4", features = ["wayland-data-control"] }
|
||||
|
||||
[target.'cfg(not(target_env = "ohos"))'.dependencies]
|
||||
portable-pty = "0.9"
|
||||
|
||||
|
||||
@@ -11793,6 +11793,7 @@ fn record_model_turn_telemetry(
|
||||
{
|
||||
return;
|
||||
}
|
||||
let model = turn.last_parent_model.as_deref().unwrap_or(model);
|
||||
let outcome = match status {
|
||||
TurnOutcomeStatus::Completed => codewhale_telemetry::TurnOutcome::Ok,
|
||||
TurnOutcomeStatus::Failed => codewhale_telemetry::TurnOutcome::Failed,
|
||||
|
||||
@@ -73,6 +73,7 @@ include!("tests/test_cases_18.rs");
|
||||
include!("tests/test_cases_19.rs");
|
||||
|
||||
mod admission_gates;
|
||||
mod model_turn_telemetry;
|
||||
mod redacted_thinking;
|
||||
mod runtime_state;
|
||||
mod sse_turn_recovery;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn model_turn_telemetry_names_the_model_the_last_request_used() {
|
||||
let mut turn = crate::core::turn::TurnContext::new(4);
|
||||
turn.add_parent_usage(&Usage {
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
..Usage::default()
|
||||
});
|
||||
turn.note_parent_model("deepseek-v4pro");
|
||||
|
||||
record_model_turn_telemetry(
|
||||
codewhale_config::ProviderKind::Deepseek,
|
||||
"auto",
|
||||
&turn,
|
||||
TurnOutcomeStatus::Completed,
|
||||
);
|
||||
|
||||
let reported = codewhale_telemetry::session_counters()
|
||||
.usage_events()
|
||||
.into_iter()
|
||||
.any(|event| {
|
||||
matches!(
|
||||
event,
|
||||
codewhale_telemetry::Event::ModelUsage { provider, model, .. }
|
||||
if provider == "deepseek" && model == "deepseek-v4-pro"
|
||||
)
|
||||
});
|
||||
assert!(reported, "the resolved wire model, not `auto`, is reported");
|
||||
}
|
||||
@@ -641,6 +641,7 @@ impl Engine {
|
||||
job.response_settled(!response_stream_failed);
|
||||
}
|
||||
turn.add_parent_usage(&usage);
|
||||
turn.note_parent_model(&stream_request.model);
|
||||
turn.note_parent_prompt_len(
|
||||
self.session.messages.len(),
|
||||
crate::compaction::estimate_request_input_tokens(
|
||||
|
||||
@@ -93,6 +93,9 @@ pub struct TurnContext {
|
||||
/// their own routed receipts.
|
||||
pub parent_route_usage: Usage,
|
||||
|
||||
/// Model the most recent parent-route request was dispatched with.
|
||||
pub last_parent_model: Option<String>,
|
||||
|
||||
/// Provider calls whose usage became ambiguous after dispatch (for
|
||||
/// example an RLM timeout). A non-zero value makes cost coverage
|
||||
/// explicitly incomplete instead of inventing a zero-usage receipt.
|
||||
@@ -199,6 +202,7 @@ impl TurnContext {
|
||||
..Usage::default()
|
||||
},
|
||||
parent_route_usage: Usage::default(),
|
||||
last_parent_model: None,
|
||||
routed_usage_dropped_records: 0,
|
||||
latest_parent_input_tokens: None,
|
||||
messages_len_at_last_parent_prompt: None,
|
||||
@@ -275,6 +279,10 @@ impl TurnContext {
|
||||
add_usage_to(&mut self.parent_route_usage, usage);
|
||||
}
|
||||
|
||||
pub fn note_parent_model(&mut self, model: &str) {
|
||||
self.last_parent_model = Some(model.to_string());
|
||||
}
|
||||
|
||||
pub fn add_routed_usage_dropped_records(&mut self, dropped_records: u64) {
|
||||
self.routed_usage_dropped_records = self
|
||||
.routed_usage_dropped_records
|
||||
|
||||
@@ -187,6 +187,19 @@ 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 {
|
||||
|
||||
@@ -1669,6 +1669,10 @@ pub trait McpTransport: Send + Sync {
|
||||
|
||||
async fn recv(&mut self) -> Result<Vec<u8>>;
|
||||
|
||||
async fn send_reply(&mut self, msg: Vec<u8>) -> Result<()> {
|
||||
self.send(msg).await
|
||||
}
|
||||
|
||||
/// Record the protocol revision negotiated at `initialize`. Only the
|
||||
/// Streamable HTTP transport uses it (the `MCP-Protocol-Version` header on
|
||||
/// subsequent requests); stdio and legacy SSE have no header channel, so
|
||||
@@ -3125,6 +3129,30 @@ impl McpConnection {
|
||||
result
|
||||
}
|
||||
|
||||
async fn answer_server_request(&mut self, reply: &serde_json::Value) {
|
||||
let result = match serde_json::to_vec(reply) {
|
||||
Ok(bytes) => {
|
||||
let cancel_token = self.cancel_token.clone();
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancel_token.cancelled() => {
|
||||
Err(anyhow::anyhow!("MCP connection was cancelled"))
|
||||
}
|
||||
result = self.transport.send_reply(bytes) => result,
|
||||
}
|
||||
}
|
||||
Err(err) => Err(err.into()),
|
||||
};
|
||||
if let Err(err) = result {
|
||||
tracing::debug!(
|
||||
target: "mcp",
|
||||
server = %self.name,
|
||||
error = %err,
|
||||
"could not answer an MCP server request"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handshake and discovery receive: each frame wait is bounded by the read
|
||||
/// knob, and a server that stays silent past it is treated as dead.
|
||||
async fn recv(&mut self, expected_id: String) -> Result<serde_json::Value> {
|
||||
@@ -3202,6 +3230,22 @@ impl McpConnection {
|
||||
{
|
||||
self.catalog_stale = true;
|
||||
}
|
||||
if let Some(method) = value.get("method") {
|
||||
if let Some(id) = value.get("id") {
|
||||
let method = method.as_str().unwrap_or_default();
|
||||
let reply = if method == "ping" {
|
||||
serde_json::json!({"jsonrpc": "2.0", "id": id, "result": {}})
|
||||
} else {
|
||||
serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"error": {"code": -32601, "message": format!("Method not found: {method}")}
|
||||
})
|
||||
};
|
||||
self.answer_server_request(&reply).await;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if response_id_matches(value.get("id"), &expected_id) {
|
||||
// Marks the connection stale so it is rebuilt, but this is a
|
||||
// reply to the request, so it never qualifies for a replay.
|
||||
|
||||
@@ -177,6 +177,21 @@ impl McpTransport for HttpTransport {
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_reply(&mut self, msg: Vec<u8>) -> Result<()> {
|
||||
match &mut self.mode {
|
||||
HttpTransportMode::Streamable(transport) => {
|
||||
transport.send(msg).await.map_err(|err| match err {
|
||||
StreamableSendError::Incompatible(detail)
|
||||
| StreamableSendError::StaleSession(detail) => anyhow::anyhow!(
|
||||
"MCP Streamable HTTP refused a reply to a server request: {detail}"
|
||||
),
|
||||
StreamableSendError::Other(err) => err,
|
||||
})
|
||||
}
|
||||
HttpTransportMode::Sse(transport) => transport.send(msg).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn recv(&mut self) -> Result<Vec<u8>> {
|
||||
match &mut self.mode {
|
||||
HttpTransportMode::Streamable(transport) => transport.recv().await,
|
||||
|
||||
@@ -3314,6 +3314,80 @@ async fn call_method_skips_notifications_and_unmatched_responses() {
|
||||
assert_eq!(sent[0]["method"], "tools/call");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn call_method_answers_server_requests_instead_of_taking_them_as_the_reply() {
|
||||
let sent = Arc::new(Mutex::new(Vec::new()));
|
||||
let transport = ScriptedValueTransport {
|
||||
sent: Arc::clone(&sent),
|
||||
responses: VecDeque::from([
|
||||
json_frame(serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "ping"})),
|
||||
json_frame(serde_json::json!({"jsonrpc": "2.0", "id": "1", "method": "roots/list"})),
|
||||
json_frame(serde_json::json!({"jsonrpc": "2.0", "id": 1, "result": {"ok": true}})),
|
||||
]),
|
||||
};
|
||||
let mut conn = test_connection(Box::new(transport));
|
||||
|
||||
let result = conn
|
||||
.call_method("tools/call", serde_json::json!({"name": "echo"}), 1)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, serde_json::json!({"ok": true}));
|
||||
let sent = sent.lock().unwrap();
|
||||
assert_eq!(sent.len(), 3);
|
||||
assert_eq!(
|
||||
sent[1],
|
||||
serde_json::json!({"jsonrpc": "2.0", "id": 1, "result": {}})
|
||||
);
|
||||
assert_eq!(sent[2]["id"], "1");
|
||||
assert_eq!(sent[2]["error"]["code"], -32601);
|
||||
}
|
||||
|
||||
struct ReplyRefusingTransport {
|
||||
sent: Arc<Mutex<Vec<serde_json::Value>>>,
|
||||
responses: VecDeque<Vec<u8>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl McpTransport for ReplyRefusingTransport {
|
||||
async fn send(&mut self, msg: Vec<u8>) -> Result<()> {
|
||||
let msg: serde_json::Value = serde_json::from_slice(&msg)?;
|
||||
anyhow::ensure!(msg.get("method").is_some(), "status=405 Method Not Allowed");
|
||||
self.sent.lock().unwrap().push(msg);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn recv(&mut self) -> Result<Vec<u8>> {
|
||||
self.responses
|
||||
.pop_front()
|
||||
.context("scripted transport exhausted")
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn call_method_keeps_its_reply_when_answering_a_server_request_fails() {
|
||||
let sent = Arc::new(Mutex::new(Vec::new()));
|
||||
let transport = ReplyRefusingTransport {
|
||||
sent: Arc::clone(&sent),
|
||||
responses: VecDeque::from([
|
||||
json_frame(serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "ping"})),
|
||||
json_frame(serde_json::json!({"jsonrpc": "2.0", "id": 1, "result": {"ok": true}})),
|
||||
]),
|
||||
};
|
||||
let mut conn = test_connection(Box::new(transport));
|
||||
|
||||
let result = conn
|
||||
.call_method("tools/call", serde_json::json!({"name": "echo"}), 1)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, serde_json::json!({"ok": true}));
|
||||
assert_eq!(conn.state, ConnectionState::Ready);
|
||||
let sent = sent.lock().unwrap();
|
||||
assert_eq!(sent.len(), 1);
|
||||
assert_eq!(sent[0]["method"], "tools/call");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn call_method_invalid_json_includes_server_output_preview() {
|
||||
let sent = Arc::new(Mutex::new(Vec::new()));
|
||||
@@ -6770,6 +6844,60 @@ async fn custom_headers_applied_to_get_preflight() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refused_reply_to_a_server_request_does_not_fall_back_to_sse() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
let _env = crate::test_support::lock_test_env();
|
||||
let _lock = lock_mcp_loopback_tests().await;
|
||||
let _no_proxy = crate::test_support::EnvVarGuard::set("NO_PROXY", "*,127.0.0.1");
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let requests = Arc::new(Mutex::new(Vec::new()));
|
||||
let requests_srv = Arc::clone(&requests);
|
||||
|
||||
let server = tokio::spawn(async move {
|
||||
loop {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let mut buf = [0u8; 4096];
|
||||
let n = socket.read(&mut buf).await.unwrap();
|
||||
let request_line = String::from_utf8_lossy(&buf[..n])
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
requests_srv.lock().unwrap().push(request_line);
|
||||
socket
|
||||
.write_all(
|
||||
b"HTTP/1.1 405 Method Not Allowed\r\nConnection: close\r\nContent-Length: 0\r\n\r\n",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
});
|
||||
|
||||
let url = format!("http://{addr}/mcp");
|
||||
let mut transport = HttpTransport::new(
|
||||
test_mcp_http_client(&url),
|
||||
url,
|
||||
tokio_util::sync::CancellationToken::new(),
|
||||
Duration::from_secs(2),
|
||||
);
|
||||
|
||||
let result = transport
|
||||
.send_reply(json_frame(
|
||||
serde_json::json!({"jsonrpc": "2.0", "id": 1, "result": {}}),
|
||||
))
|
||||
.await;
|
||||
|
||||
server.abort();
|
||||
assert!(result.is_err(), "a refused reply reports the refusal");
|
||||
let requests = requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 1, "requests seen: {requests:?}");
|
||||
assert!(requests[0].starts_with("POST "), "{requests:?}");
|
||||
}
|
||||
|
||||
// === add_runtime_server_config conflict tests ===
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -226,10 +226,10 @@
|
||||
"TranscriptReasoningExpand": "expand",
|
||||
"TranscriptThought": "Thought",
|
||||
"TranscriptThoughtFor": "Thought for {duration}",
|
||||
"TelemetryNoticeDefaultOn": "Usage reporting is on by default. Share aggregate version/platform, session, feature and error counts with Codewhale and PostHog (processor). Also counts providers, public catalog models, token totals and built-in tool use. No content or IP. docs/TELEMETRY.md Turn off: codewhale config set telemetry false",
|
||||
"TelemetryNoticeDefaultOn": "Usage reporting is on by default. Share aggregate version/platform, session, feature and error counts with Codewhale and PostHog (processor). Also counts providers, public catalog models, token totals, request counts, model wait time, prompt-size ranges, built-in tool use, how an exec session started and a coarse install-age range. No content or IP. docs/TELEMETRY.md Turn off: codewhale config set telemetry false",
|
||||
"TelemetryNoticeHeadline": "Codewhale usage reporting",
|
||||
"TelemetryNoticeBody": "Codewhale counts: which version you run, OS and CPU family, session\nduration and outcome, and aggregate feature and error counters.\n\nIt also counts the providers and public catalog models you use, their\ntoken totals, and how often each built-in tool runs.\n\nIt never collects your conversations, code, prompts, files, repo or\nbranch names, model content, or credentials — and it never sends a\nper-turn or per-tool timeline of agent activity.\n\nYou are identified only by a random ID stored on this machine, replaced\nevery 90 days. Change your mind any time:\n codewhale config set telemetry false\n\nFull schema, field by field: docs/TELEMETRY.md\n\nUsage reporting is on by default. Codewhale and PostHog process these counts when delivery is configured. No IP is collected.",
|
||||
"TelemetryNoticeCompactBody": "Counts: version, OS/CPU, session\ntime/result, feature/error totals.\nAlso: provider/model, tokens, tools.\nRandom local ID rotates every 90d.\nNever: chat/code/prompts/files/names;\nmodel content/credentials; turn/tools.\nSchema: docs/TELEMETRY.md\nOff: codewhale config set telemetry\nfalse\nUsage reporting is on by default. Codewhale and PostHog process these counts when delivery is configured. No IP is collected.",
|
||||
"TelemetryNoticeBody": "Codewhale counts: which version you run, OS and CPU family, session\nduration and outcome, and aggregate feature and error counters.\n\nIt also counts the providers and public catalog models you use, their\ntoken totals, request counts, time spent waiting on the model and\nprompt-size ranges, how often each built-in tool runs, how an exec\nsession was started, and a coarse install-age range.\n\nIt never collects your conversations, code, prompts, files, repo or\nbranch names, model content, or credentials — and it never sends a\nper-turn or per-tool timeline of agent activity.\n\nYou are identified only by a random ID stored on this machine, replaced\nevery 90 days. Change your mind any time:\n codewhale config set telemetry false\n\nFull schema, field by field: docs/TELEMETRY.md\n\nUsage reporting is on by default. Codewhale and PostHog process these counts when delivery is configured. No IP is collected.",
|
||||
"TelemetryNoticeCompactBody": "Counts: version, OS/CPU, session\ntime/result, feature/error totals.\nAlso: provider/model, tokens, tools,\nrequests, wait time, prompt-size and\ninstall-age ranges, exec start.\nRandom local ID rotates every 90d.\nNever: chat/code/prompts/files/names;\nmodel content/credentials; turn/tools.\nSchema: docs/TELEMETRY.md\nOff: codewhale config set telemetry\nfalse\nUsage reporting is on by default. Codewhale and PostHog process these counts when delivery is configured. No IP is collected.",
|
||||
"TelemetryNoticeChoiceKeep": "Yes, keep anonymous counts",
|
||||
"TelemetryNoticeChoiceDisable": "No, turn off tracking",
|
||||
"TelemetryNoticeActionChoose": "choose",
|
||||
|
||||
@@ -255,11 +255,12 @@ impl StdioLspTransport {
|
||||
let child = Arc::new(AsyncMutex::new(Some(child)));
|
||||
let dispatcher_child = child.clone();
|
||||
let dispatcher_pending = pending.clone();
|
||||
let dispatcher_outbound = tx_outbound.clone();
|
||||
let dispatcher_task = spawn_supervised(
|
||||
"lsp-dispatcher",
|
||||
std::panic::Location::caller(),
|
||||
async move {
|
||||
dispatcher_task(rx_inbound, tx_diag, dispatcher_pending).await;
|
||||
dispatcher_task(rx_inbound, tx_diag, dispatcher_outbound, dispatcher_pending).await;
|
||||
// EOF, malformed frames, or an overflowing diagnostics queue
|
||||
// terminate the producer too, so its pipes cannot stay stuck.
|
||||
if let Some(mut child) = dispatcher_child.lock().await.take() {
|
||||
@@ -536,15 +537,19 @@ impl LspTransport for StdioLspTransport {
|
||||
|
||||
/// Send a JSON value as one Content-Length-framed JSON-RPC message.
|
||||
async fn send_message(tx: &mpsc::Sender<Vec<u8>>, value: &Value) -> Result<()> {
|
||||
tx.send(frame_message(value)?)
|
||||
.await
|
||||
.map_err(|_| anyhow!("LSP outbound channel closed"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn frame_message(value: &Value) -> Result<Vec<u8>> {
|
||||
let body = serde_json::to_vec(value).context("serialize LSP message")?;
|
||||
let header = format!("Content-Length: {}\r\n\r\n", body.len());
|
||||
let mut frame = Vec::with_capacity(header.len() + body.len());
|
||||
frame.extend_from_slice(header.as_bytes());
|
||||
frame.extend_from_slice(&body);
|
||||
tx.send(frame)
|
||||
.await
|
||||
.map_err(|_| anyhow!("LSP outbound channel closed"))?;
|
||||
Ok(())
|
||||
Ok(frame)
|
||||
}
|
||||
|
||||
/// Background task that drains the outbound queue and writes each frame to
|
||||
@@ -638,6 +643,7 @@ fn parse_header(buf: &[u8]) -> Result<Option<(usize, usize)>> {
|
||||
async fn dispatcher_task(
|
||||
mut rx: mpsc::Receiver<Value>,
|
||||
tx_diag: mpsc::Sender<DiagnosticMessage>,
|
||||
tx_outbound: mpsc::Sender<Vec<u8>>,
|
||||
pending: Arc<AsyncMutex<HashMap<i64, oneshot::Sender<Value>>>>,
|
||||
) {
|
||||
while let Some(value) = rx.recv().await {
|
||||
@@ -654,6 +660,19 @@ async fn dispatcher_task(
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(method) = method {
|
||||
if let Some(id) = value.get("id") {
|
||||
let reply = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"error": {"code": -32601, "message": format!("unsupported method {method}")},
|
||||
});
|
||||
if let Ok(frame) = frame_message(&reply) {
|
||||
let _ = tx_outbound.try_send(frame);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Replies have an `id` and a `result` or `error`.
|
||||
if let Some(id) = value.get("id").and_then(|v| v.as_i64()) {
|
||||
let mut map = pending.lock().await;
|
||||
@@ -780,6 +799,31 @@ pub(super) mod tests {
|
||||
assert_eq!(diags[0].message, "missing semicolon");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn server_request_sharing_a_pending_id_is_answered_not_taken_as_the_reply() {
|
||||
let (tx_in, rx_in) = mpsc::channel(4);
|
||||
let (tx_out, mut rx_out) = mpsc::channel::<Vec<u8>>(4);
|
||||
let (tx_diag, _rx_diag) = mpsc::channel(4);
|
||||
let pending = Arc::new(AsyncMutex::new(HashMap::new()));
|
||||
let (reply_tx, reply_rx) = oneshot::channel();
|
||||
pending.lock().await.insert(2, reply_tx);
|
||||
let dispatcher = tokio::spawn(dispatcher_task(rx_in, tx_diag, tx_out, pending));
|
||||
tx_in
|
||||
.send(json!({"jsonrpc":"2.0","id":2,"method":"workspace/configuration","params":{"items":[]}}))
|
||||
.await
|
||||
.unwrap();
|
||||
tx_in
|
||||
.send(json!({"jsonrpc":"2.0","id":2,"result":"definition"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(reply_rx.await.unwrap()["result"], "definition");
|
||||
let answer = next_document(&mut rx_out).await;
|
||||
assert_eq!(answer["id"], 2);
|
||||
assert_eq!(answer["error"]["code"], -32601);
|
||||
drop(tx_in);
|
||||
dispatcher.await.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_uri_path() {
|
||||
let path = fixture_path("example/foo.rs");
|
||||
@@ -1309,11 +1353,12 @@ while True:
|
||||
let (mut producer, reader) = tokio::io::duplex(MAX_LSP_HEADER_BYTES * 2);
|
||||
let (tx, rx) = mpsc::channel(8);
|
||||
let (diag, _diagnostics) = mpsc::channel(8);
|
||||
let (outbound, _outbound_rx) = mpsc::channel(8);
|
||||
let pending = Arc::new(AsyncMutex::new(HashMap::new()));
|
||||
let (reply, receiver) = oneshot::channel();
|
||||
pending.lock().await.insert(1, reply);
|
||||
let read_task = tokio::spawn(reader_task(reader, tx));
|
||||
let dispatch = tokio::spawn(dispatcher_task(rx, diag, pending.clone()));
|
||||
let dispatch = tokio::spawn(dispatcher_task(rx, diag, outbound, pending.clone()));
|
||||
producer.write_all(&frame).await.unwrap();
|
||||
assert!(
|
||||
timeout(Duration::from_secs(1), receiver)
|
||||
|
||||
@@ -1371,6 +1371,9 @@ 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))
|
||||
}
|
||||
@@ -1655,6 +1658,19 @@ 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");
|
||||
|
||||
@@ -1457,6 +1457,7 @@ 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() {
|
||||
@@ -1850,7 +1851,7 @@ impl SnapshotRepo {
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|e| io_other(format!("clock error: {e}")))?
|
||||
.as_secs() as i64;
|
||||
let cutoff = now - max_age.as_secs() as i64;
|
||||
let cutoff = now.saturating_sub(i64::try_from(max_age.as_secs()).unwrap_or(i64::MAX));
|
||||
|
||||
let snapshots = self.list(usize::MAX)?;
|
||||
if snapshots.is_empty() {
|
||||
@@ -3948,6 +3949,22 @@ 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
|
||||
@@ -4892,6 +4909,24 @@ 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.
|
||||
|
||||
+37
-2
@@ -11,7 +11,7 @@ mod runtime_aliases;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, LazyLock, Mutex, MutexGuard};
|
||||
|
||||
@@ -1453,6 +1453,7 @@ 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(|| {
|
||||
@@ -1461,7 +1462,14 @@ impl StateStore {
|
||||
self.session_index_path.display()
|
||||
)
|
||||
})?;
|
||||
writeln!(file, "{encoded}").context("failed to append session index entry")?;
|
||||
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")?;
|
||||
// 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.
|
||||
@@ -2922,6 +2930,33 @@ 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
|
||||
|
||||
@@ -344,6 +344,7 @@ mod tests {
|
||||
};
|
||||
handle.record(Event::SessionStart {
|
||||
source: crate::SessionSource::Unknown,
|
||||
install_age: None,
|
||||
});
|
||||
let waiter = std::thread::spawn(move || handle.persist_local());
|
||||
assert!(matches!(rx.try_recv(), Ok(Message::Event(_))));
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
use crate::event::{Counters, Errors, Event, ModelCounters, TurnWall};
|
||||
use crate::event::{ContextSize, Counters, Errors, Event, ModelCounters, TurnWall};
|
||||
|
||||
/// One session's accumulated counts.
|
||||
#[derive(Debug, Default)]
|
||||
@@ -48,6 +48,7 @@ struct ModelSlot {
|
||||
model: String,
|
||||
counters: ModelCounters,
|
||||
turn_wall: TurnWall,
|
||||
context: ContextSize,
|
||||
}
|
||||
|
||||
/// At most this many distinct provider and model pairs per session; later
|
||||
@@ -186,6 +187,7 @@ impl SessionCounters {
|
||||
model,
|
||||
counters: ModelCounters::default(),
|
||||
turn_wall: TurnWall::default(),
|
||||
context: ContextSize::default(),
|
||||
});
|
||||
models.len() - 1
|
||||
}
|
||||
@@ -238,6 +240,7 @@ impl SessionCounters {
|
||||
model: slot.model.clone(),
|
||||
counters: slot.counters,
|
||||
turn_wall: slot.turn_wall,
|
||||
context: slot.context,
|
||||
}));
|
||||
}
|
||||
if let Ok(tools) = self.tools.lock() {
|
||||
|
||||
@@ -6,7 +6,7 @@ use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::buffer;
|
||||
use crate::event::{Arch, Libc, Os};
|
||||
use crate::event::{Arch, InstallAge, Libc, Os};
|
||||
|
||||
/// How long an install id may live before it is replaced.
|
||||
///
|
||||
@@ -47,6 +47,77 @@ pub struct TelemetryState {
|
||||
/// a permanently offline machine tries at most once per interval.
|
||||
#[serde(default)]
|
||||
pub last_flush: Option<String>,
|
||||
/// The UTC day (`YYYY-MM-DD`) this state was first created. Local only: it
|
||||
/// is read to pick an [`InstallAge`] bucket and is never sent.
|
||||
#[serde(default)]
|
||||
pub first_seen_day: Option<String>,
|
||||
/// Set when this file predates `first_seen_day`, so its age is unknown and
|
||||
/// is reported as the oldest bucket rather than as new.
|
||||
#[serde(default)]
|
||||
pub first_seen_unknown: bool,
|
||||
}
|
||||
|
||||
enum StateRead {
|
||||
Missing,
|
||||
Unreadable,
|
||||
Present(TelemetryState),
|
||||
}
|
||||
|
||||
fn read_state_checked(root: &Path) -> StateRead {
|
||||
match std::fs::read_to_string(buffer::state_path(root)) {
|
||||
Ok(body) => serde_json::from_str::<TelemetryState>(&body)
|
||||
.map_or(StateRead::Unreadable, StateRead::Present),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => StateRead::Missing,
|
||||
Err(_) => StateRead::Unreadable,
|
||||
}
|
||||
}
|
||||
|
||||
/// Today's date in UTC.
|
||||
#[must_use]
|
||||
pub fn utc_today() -> chrono::NaiveDate {
|
||||
chrono::Utc::now().date_naive()
|
||||
}
|
||||
|
||||
/// Record the first-seen day if this state has none. A missing state file is a
|
||||
/// new install; a file that has a recorded version but no day predates this
|
||||
/// field and is marked unknown. An unreadable file is left untouched.
|
||||
pub(crate) fn stamp_first_seen(root: &Path, today: chrono::NaiveDate) {
|
||||
let mut state = match read_state_checked(root) {
|
||||
StateRead::Unreadable => return,
|
||||
StateRead::Missing => TelemetryState::default(),
|
||||
StateRead::Present(state) => state,
|
||||
};
|
||||
if state.first_seen_day.is_some() || state.first_seen_unknown {
|
||||
return;
|
||||
}
|
||||
if state.last_version.is_some() {
|
||||
state.first_seen_unknown = true;
|
||||
} else {
|
||||
state.first_seen_day = Some(today.format("%Y-%m-%d").to_string());
|
||||
}
|
||||
let _ = write_state(root, &state);
|
||||
}
|
||||
|
||||
/// The coarse age bucket for this installation, or `None` when the local state
|
||||
/// cannot be read. A missing state is new.
|
||||
pub(crate) fn install_age_at(root: &Path, today: chrono::NaiveDate) -> Option<InstallAge> {
|
||||
let state = match read_state_checked(root) {
|
||||
StateRead::Unreadable => return None,
|
||||
StateRead::Missing => return Some(InstallAge::NewToday),
|
||||
StateRead::Present(state) => state,
|
||||
};
|
||||
if state.first_seen_unknown {
|
||||
return Some(InstallAge::Older);
|
||||
}
|
||||
let Some(day) = state.first_seen_day else {
|
||||
return Some(InstallAge::NewToday);
|
||||
};
|
||||
let first = chrono::NaiveDate::parse_from_str(&day, "%Y-%m-%d").ok()?;
|
||||
Some(match today.signed_duration_since(first).num_days() {
|
||||
..=0 => InstallAge::NewToday,
|
||||
1..=6 => InstallAge::UnderWeek,
|
||||
_ => InstallAge::Older,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read the install id, minting a fresh one if it is missing, unreadable,
|
||||
|
||||
+122
-26
@@ -33,10 +33,10 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Wire schema version. Bumped on any field add, remove, or retype; never
|
||||
/// reused. `crates/telemetry/tests/golden/v1.json` pins what v1 was.
|
||||
pub const SCHEMA_VERSION: u32 = 4;
|
||||
pub const SCHEMA_VERSION: u32 = 6;
|
||||
|
||||
/// Default-on policy disclosure version. This is not human consent.
|
||||
pub const NOTICE_VERSION: u32 = 6;
|
||||
pub const NOTICE_VERSION: u32 = 8;
|
||||
|
||||
/// Which product surface produced a batch.
|
||||
///
|
||||
@@ -277,6 +277,12 @@ pub enum SessionSource {
|
||||
/// Started under CI (`CI` or `GITHUB_ACTIONS` set), including the GitHub
|
||||
/// Action, so automation is never counted as a person.
|
||||
Ci,
|
||||
/// `exec` started by Codewhale's own fleet dispatcher.
|
||||
FleetWorker,
|
||||
/// `exec` started from inside an agent's shell command.
|
||||
Agent,
|
||||
/// `exec` whose stdin or stdout is not a terminal.
|
||||
Scripted,
|
||||
}
|
||||
|
||||
impl SessionSource {
|
||||
@@ -288,6 +294,9 @@ impl SessionSource {
|
||||
Self::Api,
|
||||
Self::Unknown,
|
||||
Self::Ci,
|
||||
Self::FleetWorker,
|
||||
Self::Agent,
|
||||
Self::Scripted,
|
||||
];
|
||||
|
||||
/// The wire spelling.
|
||||
@@ -300,6 +309,37 @@ impl SessionSource {
|
||||
Self::Api => "api",
|
||||
Self::Unknown => "unknown",
|
||||
Self::Ci => "ci",
|
||||
Self::FleetWorker => "fleet_worker",
|
||||
Self::Agent => "agent",
|
||||
Self::Scripted => "scripted",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How long this install has existed, as a coarse bucket. The first-seen date
|
||||
/// itself never leaves the device.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InstallAge {
|
||||
/// First seen on the current UTC day.
|
||||
NewToday,
|
||||
/// First seen 1 to 6 UTC days ago.
|
||||
UnderWeek,
|
||||
/// First seen 7 or more UTC days ago, or before the date was recorded.
|
||||
Older,
|
||||
}
|
||||
|
||||
impl InstallAge {
|
||||
/// Every value, for exhaustive iteration.
|
||||
pub const ALL: &'static [Self] = &[Self::NewToday, Self::UnderWeek, Self::Older];
|
||||
|
||||
/// The wire spelling.
|
||||
#[must_use]
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::NewToday => "new_today",
|
||||
Self::UnderWeek => "under_week",
|
||||
Self::Older => "older",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -366,11 +406,20 @@ pub enum ExitClass {
|
||||
Panic,
|
||||
/// Exited non-successfully without a signal or a panic.
|
||||
Error,
|
||||
/// The process ended without its shutdown report; its aggregates were
|
||||
/// recovered from a local checkpoint at a later start.
|
||||
Unclean,
|
||||
}
|
||||
|
||||
impl ExitClass {
|
||||
/// Every value, for exhaustive iteration.
|
||||
pub const ALL: &'static [Self] = &[Self::Clean, Self::Signal, Self::Panic, Self::Error];
|
||||
pub const ALL: &'static [Self] = &[
|
||||
Self::Clean,
|
||||
Self::Signal,
|
||||
Self::Panic,
|
||||
Self::Error,
|
||||
Self::Unclean,
|
||||
];
|
||||
|
||||
/// The wire spelling.
|
||||
#[must_use]
|
||||
@@ -380,6 +429,7 @@ impl ExitClass {
|
||||
Self::Signal => "signal",
|
||||
Self::Panic => "panic",
|
||||
Self::Error => "error",
|
||||
Self::Unclean => "unclean",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,6 +441,7 @@ impl ExitClass {
|
||||
Self::Signal => 1,
|
||||
Self::Panic => 2,
|
||||
Self::Error => 3,
|
||||
Self::Unclean => 4,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,6 +452,7 @@ impl ExitClass {
|
||||
1 => Self::Signal,
|
||||
2 => Self::Panic,
|
||||
3 => Self::Error,
|
||||
4 => Self::Unclean,
|
||||
_ => Self::Clean,
|
||||
}
|
||||
}
|
||||
@@ -639,6 +691,9 @@ pub enum Event {
|
||||
SessionStart {
|
||||
/// How it was started.
|
||||
source: SessionSource,
|
||||
/// Coarse install age. `null` when the local state cannot be read.
|
||||
#[serde(default)]
|
||||
install_age: Option<InstallAge>,
|
||||
},
|
||||
/// A session ended. Everything the session accumulated ships here, once.
|
||||
SessionEnd {
|
||||
@@ -696,6 +751,9 @@ pub enum Event {
|
||||
counters: ModelCounters,
|
||||
/// Turn wall-clock buckets for these turns.
|
||||
turn_wall: TurnWall,
|
||||
/// Requests by input size.
|
||||
#[serde(default)]
|
||||
context: ContextSize,
|
||||
},
|
||||
/// One session's calls of one tool.
|
||||
ToolUsage {
|
||||
@@ -725,6 +783,16 @@ pub struct ModelCounters {
|
||||
pub cache_read_tokens: u64,
|
||||
/// Input tokens written to the provider's prompt cache.
|
||||
pub cache_write_tokens: u64,
|
||||
/// Provider requests on the session's own route for this model.
|
||||
#[serde(default)]
|
||||
pub requests: u32,
|
||||
/// Reasoning tokens as the provider reports them; a subset of
|
||||
/// `output_tokens`, which is not reduced.
|
||||
#[serde(default)]
|
||||
pub reasoning_tokens: u64,
|
||||
/// Whole seconds spent waiting on the model, summed over the session.
|
||||
#[serde(default)]
|
||||
pub generation_secs: u32,
|
||||
}
|
||||
|
||||
impl ModelCounters {
|
||||
@@ -737,9 +805,44 @@ impl ModelCounters {
|
||||
"output_tokens",
|
||||
"cache_read_tokens",
|
||||
"cache_write_tokens",
|
||||
"requests",
|
||||
"reasoning_tokens",
|
||||
"generation_secs",
|
||||
];
|
||||
}
|
||||
|
||||
/// Per-session count of model requests by input size, in tokens including
|
||||
/// cache reads. Half-open intervals; K is 1024. A histogram, never a
|
||||
/// per-request series.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ContextSize {
|
||||
/// Requests with `[0, 32768)` input tokens.
|
||||
pub lt_32k: u32,
|
||||
/// Requests with `[32768, 131072)` input tokens.
|
||||
#[serde(rename = "32k_128k")]
|
||||
pub k32_to_k128: u32,
|
||||
/// Requests with `[131072, 262144)` input tokens.
|
||||
#[serde(rename = "128k_256k")]
|
||||
pub k128_to_k256: u32,
|
||||
/// Requests with 262144 or more input tokens.
|
||||
pub gte_256k: u32,
|
||||
}
|
||||
|
||||
impl ContextSize {
|
||||
/// Field names in wire spelling, in declaration order.
|
||||
pub const FIELDS: &'static [&'static str] = &["lt_32k", "32k_128k", "128k_256k", "gte_256k"];
|
||||
|
||||
/// Record one request with `tokens` input tokens.
|
||||
pub fn observe(&mut self, tokens: u64) {
|
||||
match tokens {
|
||||
0..32_768 => self.lt_32k = self.lt_32k.saturating_add(1),
|
||||
32_768..131_072 => self.k32_to_k128 = self.k32_to_k128.saturating_add(1),
|
||||
131_072..262_144 => self.k128_to_k256 = self.k128_to_k256.saturating_add(1),
|
||||
_ => self.gte_256k = self.gte_256k.saturating_add(1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// First-party tool names reported by `tool_usage`. Any other name is
|
||||
/// reported as `mcp` or `other`, so a plugin's or server's tool name never
|
||||
/// leaves the machine.
|
||||
@@ -830,17 +933,22 @@ pub fn is_reported_tool_name(value: &str) -> bool {
|
||||
value == "mcp" || value == "other" || TOOL_NAMES.contains(&value)
|
||||
}
|
||||
|
||||
/// The `model` value for a model id: the lowercased id when the bundled model
|
||||
/// catalog lists it, otherwise `other`. Custom, local and fine-tuned model
|
||||
/// names therefore never leave the machine.
|
||||
/// The `model` value for a model id: the lowercased canonical id when the
|
||||
/// bundled model catalog lists it, otherwise `other`. Official aliases and
|
||||
/// compatibility spellings are resolved first. Custom, local and fine-tuned
|
||||
/// model names therefore never leave the machine.
|
||||
#[must_use]
|
||||
pub fn reported_model_id(model: &str) -> String {
|
||||
use codewhale_config::catalog::reviewed::{compatibility_alias, official_deepseek_model_id};
|
||||
let id = model.trim().to_ascii_lowercase();
|
||||
if is_catalogued_model(&id) {
|
||||
id
|
||||
} else {
|
||||
"other".to_string()
|
||||
}
|
||||
let canonical = official_deepseek_model_id(&id)
|
||||
.or_else(|| compatibility_alias("canonical_model_name", &id))
|
||||
.map(str::to_ascii_lowercase);
|
||||
canonical
|
||||
.into_iter()
|
||||
.chain(std::iter::once(id))
|
||||
.find(|candidate| is_catalogued_model(candidate))
|
||||
.unwrap_or_else(|| "other".to_string())
|
||||
}
|
||||
|
||||
/// Whether `value` may appear as `model_usage.model`.
|
||||
@@ -850,21 +958,9 @@ pub fn is_reported_model_id(value: &str) -> bool {
|
||||
}
|
||||
|
||||
fn is_catalogued_model(id: &str) -> bool {
|
||||
if id.is_empty() || id.len() > 128 {
|
||||
return false;
|
||||
}
|
||||
let catalog = codewhale_config::catalog::bundled_models_dev_catalog();
|
||||
catalog.reviewed.intrinsic.contains_key(id)
|
||||
|| catalog
|
||||
.models
|
||||
.keys()
|
||||
.any(|key| key.eq_ignore_ascii_case(id))
|
||||
|| catalog.providers.values().any(|provider| {
|
||||
provider
|
||||
.models
|
||||
.keys()
|
||||
.any(|key| key.eq_ignore_ascii_case(id))
|
||||
})
|
||||
!id.is_empty()
|
||||
&& id.len() <= 128
|
||||
&& codewhale_config::catalog::reviewed::public_model_identifier_ignore_case(id)
|
||||
}
|
||||
|
||||
impl Event {
|
||||
|
||||
@@ -57,9 +57,9 @@ pub use decision::{
|
||||
};
|
||||
pub use envelope::reduce_panic_site;
|
||||
pub use event::{
|
||||
Arch, Batch, ColdStartBucket, Counters, DurationBucket, Errors, Event, ExitClass, InstallKind,
|
||||
Libc, ModelCounters, NOTICE_VERSION, Os, ProductCounters, SCHEMA_VERSION, SessionSource,
|
||||
Surface, TurnWall,
|
||||
Arch, Batch, ColdStartBucket, ContextSize, Counters, DurationBucket, Errors, Event, ExitClass,
|
||||
InstallAge, InstallKind, Libc, ModelCounters, NOTICE_VERSION, Os, ProductCounters,
|
||||
SCHEMA_VERSION, SessionSource, Surface, TurnWall,
|
||||
};
|
||||
|
||||
/// How long the shutdown flush may hold the process.
|
||||
@@ -123,9 +123,18 @@ pub fn init(consent: TelemetryConsent) {
|
||||
exit_class: AtomicU8::new(ExitClass::Clean.as_u8()),
|
||||
});
|
||||
|
||||
envelope::stamp_first_seen(&root, envelope::utc_today());
|
||||
record_install_or_upgrade(&root);
|
||||
}
|
||||
|
||||
/// The coarse install-age bucket for this process's `session_start`, or `None`
|
||||
/// when unarmed or when the local state cannot be read.
|
||||
#[must_use]
|
||||
pub fn install_age() -> Option<InstallAge> {
|
||||
let armed = ARMED.get()?;
|
||||
envelope::install_age_at(&armed.root, envelope::utc_today())
|
||||
}
|
||||
|
||||
/// Note that this binary's version differs from the one last seen on this
|
||||
/// machine, at most once per version.
|
||||
///
|
||||
|
||||
@@ -25,7 +25,9 @@ Codewhale counts: which version you run, OS and CPU family, session
|
||||
duration and outcome, and aggregate feature and error counters.
|
||||
|
||||
It also counts the providers and public catalog models you use, their
|
||||
token totals, and how often each built-in tool runs.
|
||||
token totals, request counts, time spent waiting on the model and
|
||||
prompt-size ranges, how often each built-in tool runs, how an exec
|
||||
session was started, and a coarse install-age range.
|
||||
|
||||
It never collects your conversations, code, prompts, files, repo or
|
||||
branch names, model content, or credentials — and it never sends a
|
||||
@@ -41,7 +43,7 @@ Usage reporting is on by default. Codewhale and PostHog process these
|
||||
counts when delivery is configured. No IP is collected.";
|
||||
|
||||
/// Concise disclosure for every armed runtime surface, including headless CLI.
|
||||
pub const STARTUP_DISCLOSURE: &str = "Usage reporting is on by default: Codewhale and PostHog process aggregate version/platform, session, feature, error, model/token and built-in tool counts when delivery is configured. No content or IP. Turn off: codewhale config set telemetry false. Details: codewhale config telemetry";
|
||||
pub const STARTUP_DISCLOSURE: &str = "Usage reporting is on by default: Codewhale and PostHog process aggregate version/platform, session, feature, error, model/token/request/wait-time/prompt-size, exec-start, install-age range and built-in tool counts when delivery is configured. No content or IP. Turn off: codewhale config set telemetry false. Details: codewhale config telemetry";
|
||||
|
||||
/// Present the policy once per revision without recording human acceptance.
|
||||
/// Failure to save the display marker only causes a later repeat disclosure.
|
||||
|
||||
+322
-11
@@ -121,11 +121,12 @@ fn every_event() -> Vec<Event> {
|
||||
previous_version: Some("0.9.3-rc.1".to_string()),
|
||||
},
|
||||
Event::SessionStart {
|
||||
source: SessionSource::Resume,
|
||||
source: SessionSource::FleetWorker,
|
||||
install_age: Some(InstallAge::UnderWeek),
|
||||
},
|
||||
Event::SessionEnd {
|
||||
duration_bucket: DurationBucket::OneToTen,
|
||||
exit_class: ExitClass::Panic,
|
||||
exit_class: ExitClass::Unclean,
|
||||
cold_start_bucket: Some(ColdStartBucket::Mid),
|
||||
providers: vec!["custom".to_string(), "deepseek".to_string()],
|
||||
counters: Counters {
|
||||
@@ -183,6 +184,9 @@ fn every_event() -> Vec<Event> {
|
||||
output_tokens: 9_120,
|
||||
cache_read_tokens: 380_001,
|
||||
cache_write_tokens: 0,
|
||||
requests: 27,
|
||||
reasoning_tokens: 2_310,
|
||||
generation_secs: 412,
|
||||
},
|
||||
turn_wall: TurnWall {
|
||||
lt_5s: 3,
|
||||
@@ -190,6 +194,12 @@ fn every_event() -> Vec<Event> {
|
||||
thirty_to_onetwenty: 2,
|
||||
gte_120s: 0,
|
||||
},
|
||||
context: ContextSize {
|
||||
lt_32k: 9,
|
||||
k32_to_k128: 11,
|
||||
k128_to_k256: 5,
|
||||
gte_256k: 2,
|
||||
},
|
||||
},
|
||||
Event::ToolUsage {
|
||||
tool: "read_file".to_string(),
|
||||
@@ -269,6 +279,7 @@ fn closed_enum_values() -> Vec<String> {
|
||||
values.extend(Libc::ALL.iter().map(|v| v.as_str().to_string()));
|
||||
values.extend(InstallKind::ALL.iter().map(|v| v.as_str().to_string()));
|
||||
values.extend(SessionSource::ALL.iter().map(|v| v.as_str().to_string()));
|
||||
values.extend(InstallAge::ALL.iter().map(|v| v.as_str().to_string()));
|
||||
values.extend(DurationBucket::ALL.iter().map(|v| v.as_str().to_string()));
|
||||
values.extend(ExitClass::ALL.iter().map(|v| v.as_str().to_string()));
|
||||
values.extend(ColdStartBucket::ALL.iter().map(|v| v.as_str().to_string()));
|
||||
@@ -1226,6 +1237,7 @@ fn probe_threshold_cannot_hide_an_over_cap_buffer() {
|
||||
// only safe if `MAX_EVENTS` lines cannot fit under it.
|
||||
let shortest = serde_json::to_string(&Event::SessionStart {
|
||||
source: SessionSource::Api,
|
||||
install_age: None,
|
||||
})
|
||||
.expect("serialize");
|
||||
let floor = (shortest.len() as u64 + 1) * buffer::MAX_EVENTS as u64;
|
||||
@@ -1446,6 +1458,7 @@ fn record_blocking_is_a_noop_when_unarmed() {
|
||||
});
|
||||
crate::record(Event::SessionStart {
|
||||
source: SessionSource::Interactive,
|
||||
install_age: None,
|
||||
});
|
||||
crate::set_exit_class(ExitClass::Panic);
|
||||
assert_eq!(crate::exit_class(), ExitClass::Clean);
|
||||
@@ -1659,6 +1672,7 @@ fn no_public_api_accepts_a_bare_bool() {
|
||||
|
||||
const TELEMETRY_DOC: &str = include_str!("../../../docs/TELEMETRY.md");
|
||||
const GOLDEN_V4: &str = include_str!("../tests/golden/v4.json");
|
||||
const GOLDEN_V6: &str = include_str!("../tests/golden/v6.json");
|
||||
|
||||
/// Extract the fenced ```jsonc blocks from the schema doc, in order.
|
||||
fn jsonc_blocks(doc: &str) -> Vec<String> {
|
||||
@@ -1864,13 +1878,12 @@ fn event_field_names_match_documented_schema() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn golden_payload_v4() {
|
||||
fn golden_payload_v6() {
|
||||
assert_eq!(NOTICE_VERSION.to_string(), TELEMETRY_NOTICE_VERSION);
|
||||
// `crates/telemetry/tests/golden/v4.json` is one fully-populated instance of
|
||||
// the envelope and every event. Any field add, remove, or retype fails here
|
||||
// until the developer re-blesses it under a bumped `SCHEMA_VERSION` — and it
|
||||
// is also the artifact a future receiver author reads to know exactly what
|
||||
// v1 was.
|
||||
// `crates/telemetry/tests/golden/v6.json` is one fully-populated instance of
|
||||
// the envelope and every event, and the batch the collector's own tests
|
||||
// post. Any field add, remove, or retype fails here until the developer
|
||||
// re-blesses it under a bumped `SCHEMA_VERSION`.
|
||||
//
|
||||
// Re-bless with: `CODEWHALE_BLESS_TELEMETRY_GOLDEN=1 cargo test -p codewhale-telemetry`
|
||||
let batch = every_field_batch();
|
||||
@@ -1878,22 +1891,81 @@ fn golden_payload_v4() {
|
||||
batch.schema_version, SCHEMA_VERSION,
|
||||
"the fixture must be built at the current schema version"
|
||||
);
|
||||
assert_eq!(SCHEMA_VERSION, 6);
|
||||
let mut rendered = serde_json::to_string_pretty(&batch).expect("serialize");
|
||||
rendered.push('\n');
|
||||
|
||||
if std::env::var("CODEWHALE_BLESS_TELEMETRY_GOLDEN").is_ok() {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/golden/v4.json");
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/golden/v6.json");
|
||||
std::fs::create_dir_all(path.parent().expect("parent")).expect("create golden dir");
|
||||
std::fs::write(&path, &rendered).expect("write golden");
|
||||
return;
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
rendered, GOLDEN_V4,
|
||||
"the v4 payload changed; bump SCHEMA_VERSION and re-bless the golden file"
|
||||
rendered, GOLDEN_V6,
|
||||
"the v6 payload changed; bump SCHEMA_VERSION and re-bless the golden file"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_schema_4_golden_still_reads_back_with_the_new_fields_defaulted() {
|
||||
let legacy: Batch = serde_json::from_str(GOLDEN_V4).expect("v4 golden parses");
|
||||
assert_eq!(legacy.schema_version, 4);
|
||||
for event in &legacy.events {
|
||||
match event {
|
||||
Event::SessionStart { install_age, .. } => assert_eq!(*install_age, None),
|
||||
Event::ModelUsage {
|
||||
counters, context, ..
|
||||
} => {
|
||||
assert_eq!(counters.requests, 0);
|
||||
assert_eq!(counters.reasoning_tokens, 0);
|
||||
assert_eq!(counters.generation_secs, 0);
|
||||
assert_eq!(*context, ContextSize::default());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_buckets_are_half_open_at_32k_128k_and_256k() {
|
||||
let mut size = ContextSize::default();
|
||||
for tokens in [
|
||||
0,
|
||||
32_767,
|
||||
32_768,
|
||||
131_071,
|
||||
131_072,
|
||||
262_143,
|
||||
262_144,
|
||||
u64::MAX,
|
||||
] {
|
||||
size.observe(tokens);
|
||||
}
|
||||
assert_eq!(
|
||||
size,
|
||||
ContextSize {
|
||||
lt_32k: 2,
|
||||
k32_to_k128: 2,
|
||||
k128_to_k256: 2,
|
||||
gte_256k: 2,
|
||||
}
|
||||
);
|
||||
let json = serde_json::to_value(size).expect("serialize");
|
||||
let keys: Vec<&str> = json
|
||||
.as_object()
|
||||
.expect("object")
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect();
|
||||
let mut expected: Vec<&str> = ContextSize::FIELDS.to_vec();
|
||||
expected.sort_unstable();
|
||||
let mut got = keys;
|
||||
got.sort_unstable();
|
||||
assert_eq!(got, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_comparison_names_install_upgrade_and_downgrade() {
|
||||
assert!(crate::version_is_older("0.9.3", "0.9.4"));
|
||||
@@ -1994,3 +2066,242 @@ fn the_notice_summarizes_what_the_schema_collects_and_states_every_red_line() {
|
||||
assert!(!body.contains("CODEWHALE_TELEMETRY=0"));
|
||||
assert!(body.contains("docs/TELEMETRY.md"));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- model naming --
|
||||
|
||||
fn model_id_is_collector_shaped(id: &str) -> bool {
|
||||
let bytes = id.as_bytes();
|
||||
(1..=128).contains(&bytes.len())
|
||||
&& bytes[0].is_ascii_lowercase() | bytes[0].is_ascii_digit()
|
||||
&& bytes[1..].iter().all(|b| {
|
||||
b.is_ascii_lowercase()
|
||||
|| b.is_ascii_digit()
|
||||
|| matches!(b, b'.' | b'_' | b':' | b'/' | b'@' | b'+' | b'-')
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reported_model_id_resolves_aliases_and_spellings() {
|
||||
assert_eq!(reported_model_id("deepseek-v4-pro"), "deepseek-v4-pro");
|
||||
assert_eq!(reported_model_id(" DeepSeek-V4-Pro "), "deepseek-v4-pro");
|
||||
assert_eq!(reported_model_id("deepseek-v4pro"), "deepseek-v4-pro");
|
||||
assert_eq!(
|
||||
reported_model_id("deepseek-ai/deepseek-v4-pro"),
|
||||
"deepseek-v4-pro"
|
||||
);
|
||||
assert_eq!(
|
||||
reported_model_id("deepseek/deepseek-v4pro"),
|
||||
"deepseek-v4-pro"
|
||||
);
|
||||
assert_eq!(reported_model_id("pro"), "deepseek-v4-pro");
|
||||
assert_eq!(reported_model_id("flash"), "deepseek-flash");
|
||||
assert_eq!(reported_model_id("auto"), "other");
|
||||
assert_eq!(reported_model_id("my-private-finetune"), "other");
|
||||
assert_eq!(reported_model_id(""), "other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reported_model_id_never_leaves_the_catalog() {
|
||||
use codewhale_config::catalog::reviewed::{
|
||||
bundled_reviewed, public_model_identifier_ignore_case,
|
||||
};
|
||||
let reviewed = bundled_reviewed();
|
||||
let mut spellings: Vec<&str> = reviewed
|
||||
.official_deepseek_aliases
|
||||
.keys()
|
||||
.chain(reviewed.go_aliases.keys())
|
||||
.map(String::as_str)
|
||||
.collect();
|
||||
if let Some(group) = reviewed.compatibility_aliases.get("canonical_model_name") {
|
||||
spellings.extend(group.keys().map(String::as_str));
|
||||
}
|
||||
assert!(spellings.len() > 20, "alias tables look empty");
|
||||
for spelling in spellings {
|
||||
let reported = reported_model_id(spelling);
|
||||
assert!(is_reported_model_id(&reported), "{spelling} -> {reported}");
|
||||
assert!(
|
||||
model_id_is_collector_shaped(&reported),
|
||||
"{spelling} -> {reported}"
|
||||
);
|
||||
}
|
||||
for hostile in ["", "x/../y", "deepseek-v4-pro\n", "Ünï", &"a".repeat(300)] {
|
||||
let reported = reported_model_id(hostile);
|
||||
assert!(
|
||||
reported == "other" || public_model_identifier_ignore_case(&reported),
|
||||
"{hostile:?} -> {reported}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_listed_model_and_provider_default_maps_to_a_catalogued_id() {
|
||||
let catalog = codewhale_config::catalog::bundled_models_dev_catalog();
|
||||
let mut missing = Vec::new();
|
||||
let mut check = |origin: &str, id: &str| {
|
||||
let reported = reported_model_id(id);
|
||||
if reported == "other" || !model_id_is_collector_shaped(&reported) {
|
||||
missing.push(format!("{origin}: {id}"));
|
||||
}
|
||||
};
|
||||
for provider in catalog.providers.values() {
|
||||
for (id, row) in &provider.models {
|
||||
check("catalog", id);
|
||||
check("catalog row", &row.id);
|
||||
}
|
||||
}
|
||||
for id in catalog.models.keys() {
|
||||
check("canonical", id);
|
||||
}
|
||||
|
||||
let descriptors: Value = serde_json::from_str(
|
||||
&std::fs::read_to_string(
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../config/assets/provider_descriptors.json"),
|
||||
)
|
||||
.expect("provider descriptors"),
|
||||
)
|
||||
.expect("descriptors json");
|
||||
let exceptions: &[&str] = &["antigravity", "custom", "openai-compatible", "ollama"];
|
||||
for provider in descriptors["providers"].as_array().expect("providers") {
|
||||
let id = provider["id"].as_str().expect("provider id");
|
||||
let selectable = provider["selectable"].as_bool().unwrap_or(false);
|
||||
let default = provider["default_model"].as_str().unwrap_or_default();
|
||||
if selectable && !default.is_empty() && !exceptions.contains(&id) {
|
||||
check(&format!("default for {id}"), default);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
missing.is_empty(),
|
||||
"ids the catalog does not name:\n{}",
|
||||
missing.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- install age --
|
||||
|
||||
fn day(text: &str) -> chrono::NaiveDate {
|
||||
chrono::NaiveDate::parse_from_str(text, "%Y-%m-%d").expect("date")
|
||||
}
|
||||
|
||||
fn root_with_state(state: &str) -> (tempfile::TempDir, PathBuf) {
|
||||
let home = temp_home();
|
||||
let root = root_of(&home);
|
||||
buffer::ensure_dir(&root).expect("create telemetry dir");
|
||||
std::fs::write(buffer::state_path(&root), state).expect("write state");
|
||||
(home, root)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_home_is_new_today_and_ages_by_utc_day() {
|
||||
let home = temp_home();
|
||||
let root = root_of(&home);
|
||||
buffer::ensure_dir(&root).expect("create telemetry dir");
|
||||
assert_eq!(
|
||||
envelope::install_age_at(&root, day("2026-11-13")),
|
||||
Some(InstallAge::NewToday)
|
||||
);
|
||||
envelope::stamp_first_seen(&root, day("2026-11-13"));
|
||||
for (today, expected) in [
|
||||
("2026-11-13", InstallAge::NewToday),
|
||||
("2026-11-14", InstallAge::UnderWeek),
|
||||
("2026-11-16", InstallAge::UnderWeek),
|
||||
("2026-11-19", InstallAge::UnderWeek),
|
||||
("2026-11-20", InstallAge::Older),
|
||||
("2027-06-01", InstallAge::Older),
|
||||
("2026-11-12", InstallAge::NewToday),
|
||||
] {
|
||||
assert_eq!(
|
||||
envelope::install_age_at(&root, day(today)),
|
||||
Some(expected),
|
||||
"{today}"
|
||||
);
|
||||
}
|
||||
envelope::stamp_first_seen(&root, day("2026-12-25"));
|
||||
assert_eq!(
|
||||
envelope::read_state(&root).first_seen_day.as_deref(),
|
||||
Some("2026-11-13"),
|
||||
"a second stamp never moves the first-seen day"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_state_written_before_the_day_existed_reads_older_never_new() {
|
||||
let (_home, root) = root_with_state(r#"{"schema_version":4,"last_version":"0.10.2"}"#);
|
||||
envelope::stamp_first_seen(&root, day("2026-11-13"));
|
||||
let state = envelope::read_state(&root);
|
||||
assert_eq!(state.first_seen_day, None);
|
||||
assert!(state.first_seen_unknown);
|
||||
assert_eq!(state.last_version.as_deref(), Some("0.10.2"));
|
||||
for today in ["2026-11-13", "2026-11-14", "2030-01-01"] {
|
||||
assert_eq!(
|
||||
envelope::install_age_at(&root, day(today)),
|
||||
Some(InstallAge::Older),
|
||||
"{today}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_or_hostile_state_gives_null_and_is_not_overwritten() {
|
||||
for body in [
|
||||
"not json",
|
||||
r#"{"first_seen_day":"9999-99-99"}"#,
|
||||
r#"{"first_seen_day":"yesterday"}"#,
|
||||
] {
|
||||
let (_home, root) = root_with_state(body);
|
||||
envelope::stamp_first_seen(&root, day("2026-11-13"));
|
||||
assert_eq!(
|
||||
envelope::install_age_at(&root, day("2026-11-13")),
|
||||
None,
|
||||
"{body}"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(buffer::state_path(&root)).expect("state"),
|
||||
body,
|
||||
"{body}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opting_out_erases_the_age_and_rotating_the_install_id_keeps_it() {
|
||||
let home = temp_home();
|
||||
let root = root_of(&home);
|
||||
buffer::ensure_dir(&root).expect("create telemetry dir");
|
||||
envelope::stamp_first_seen(&root, day("2026-01-01"));
|
||||
assert_eq!(
|
||||
envelope::install_age_at(&root, day("2026-11-13")),
|
||||
Some(InstallAge::Older)
|
||||
);
|
||||
|
||||
std::fs::write(
|
||||
buffer::install_id_path(&root),
|
||||
r#"{"schema_version":1,"install_id":"3f2a9c1e-0000-4000-8000-000000000001","rotated_at":"2020-01-01T00:00:00Z"}"#,
|
||||
)
|
||||
.expect("write expired id");
|
||||
let rotated = envelope::read_or_create_install_id(&root).expect("rotate");
|
||||
assert_ne!(rotated.install_id, "3f2a9c1e-0000-4000-8000-000000000001");
|
||||
assert_eq!(
|
||||
envelope::read_state(&root).first_seen_day.as_deref(),
|
||||
Some("2026-01-01"),
|
||||
"rotation does not touch the age"
|
||||
);
|
||||
|
||||
buffer::wipe(&root).expect("wipe");
|
||||
std::fs::remove_file(buffer::tombstone_path(&root)).expect("re-enable");
|
||||
assert_eq!(
|
||||
envelope::install_age_at(&root, day("2026-11-13")),
|
||||
Some(InstallAge::NewToday)
|
||||
);
|
||||
envelope::stamp_first_seen(&root, day("2026-11-13"));
|
||||
assert_eq!(
|
||||
envelope::read_state(&root).first_seen_day.as_deref(),
|
||||
Some("2026-11-13")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_seen_day_never_reaches_the_wire() {
|
||||
let wire = serde_json::to_string(&every_field_batch()).expect("serialize");
|
||||
assert!(!wire.contains("first_seen"));
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
{
|
||||
"schema_version": 6,
|
||||
"notice_version": 8,
|
||||
"sent_at": "2026-08-03T18:04:11Z",
|
||||
"install_id": "3f2a9c1e-0000-4000-8000-000000000001",
|
||||
"app_version": "0.9.4",
|
||||
"git_sha": "abcdef012345",
|
||||
"surface": "control-plane",
|
||||
"os": "macos",
|
||||
"arch": "aarch64",
|
||||
"libc": "none",
|
||||
"tty": true,
|
||||
"events": [
|
||||
{
|
||||
"event": "install_or_upgrade",
|
||||
"kind": "upgrade",
|
||||
"previous_version": "0.9.3-rc.1"
|
||||
},
|
||||
{
|
||||
"event": "session_start",
|
||||
"source": "fleet_worker",
|
||||
"install_age": "under_week"
|
||||
},
|
||||
{
|
||||
"event": "session_end",
|
||||
"duration_bucket": "1m_10m",
|
||||
"exit_class": "unclean",
|
||||
"cold_start_bucket": "250_1000",
|
||||
"providers": [
|
||||
"custom",
|
||||
"deepseek"
|
||||
],
|
||||
"counters": {
|
||||
"turns": 14,
|
||||
"tool_calls": 61,
|
||||
"fleet_dispatch": 0,
|
||||
"workflow_run": 0,
|
||||
"subagent_spawn": 2,
|
||||
"mcp_server_connected": 0,
|
||||
"memory_search": 0,
|
||||
"approval_modal_shown": 0,
|
||||
"approval_auto_allowed": 0,
|
||||
"command_palette_open": 3
|
||||
},
|
||||
"errors": {
|
||||
"auth_preflight_failed": 0,
|
||||
"provider_http_4xx": 0,
|
||||
"provider_http_5xx": 1,
|
||||
"tool_denied_by_policy": 0,
|
||||
"tool_timeout": 0,
|
||||
"network_error": 0
|
||||
},
|
||||
"turn_wall": {
|
||||
"lt_5s": 9,
|
||||
"5_30s": 4,
|
||||
"30_120s": 1,
|
||||
"gte_120s": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": "panic",
|
||||
"site": "crates/tui/src/tui/ui.rs:8801:17"
|
||||
},
|
||||
{
|
||||
"event": "product_usage",
|
||||
"counters": {
|
||||
"page_view": 1,
|
||||
"docs_view": 0,
|
||||
"install_copy": 0,
|
||||
"download": 0,
|
||||
"signup": 0,
|
||||
"login": 0,
|
||||
"session_create": 0,
|
||||
"session_resume": 0,
|
||||
"turn_submit": 0,
|
||||
"turn_complete": 0,
|
||||
"settings_open": 0,
|
||||
"integration_connect": 0,
|
||||
"error_shown": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": "operations_summary",
|
||||
"requests": 10,
|
||||
"errors": 1,
|
||||
"duration_ms_total": 1500,
|
||||
"duration_ms_max": 300,
|
||||
"probes": 2,
|
||||
"probes_failed": 0
|
||||
},
|
||||
{
|
||||
"event": "model_usage",
|
||||
"provider": "deepseek",
|
||||
"model": "other",
|
||||
"counters": {
|
||||
"turns_ok": 12,
|
||||
"turns_failed": 1,
|
||||
"turns_interrupted": 0,
|
||||
"input_tokens": 418230,
|
||||
"output_tokens": 9120,
|
||||
"cache_read_tokens": 380001,
|
||||
"cache_write_tokens": 0,
|
||||
"requests": 27,
|
||||
"reasoning_tokens": 2310,
|
||||
"generation_secs": 412
|
||||
},
|
||||
"turn_wall": {
|
||||
"lt_5s": 3,
|
||||
"5_30s": 8,
|
||||
"30_120s": 2,
|
||||
"gte_120s": 0
|
||||
},
|
||||
"context": {
|
||||
"lt_32k": 9,
|
||||
"32k_128k": 11,
|
||||
"128k_256k": 5,
|
||||
"gte_256k": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": "tool_usage",
|
||||
"tool": "read_file",
|
||||
"calls": 41,
|
||||
"errors": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -7,8 +7,15 @@ 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.
|
||||
|
||||
@@ -157,7 +157,8 @@ 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(args, workspace);
|
||||
let git =
|
||||
|args: &[&str]| Git::output(&[&["-c", "core.quotePath=false"], args].concat(), workspace);
|
||||
// Outside a repository `git diff` prints nothing on stdout, which would
|
||||
// read as "no changes".
|
||||
match git(&["rev-parse", "--is-inside-work-tree"]) {
|
||||
|
||||
@@ -2515,7 +2515,10 @@ fn arm_telemetry_with_setup(
|
||||
};
|
||||
codewhale_telemetry::init(consent.with_config_path(Some(store.path().to_path_buf())));
|
||||
let _ = TELEMETRY_SESSION_START.set(std::time::Instant::now());
|
||||
codewhale_telemetry::record(codewhale_telemetry::Event::SessionStart { source });
|
||||
codewhale_telemetry::record(codewhale_telemetry::Event::SessionStart {
|
||||
source,
|
||||
install_age: codewhale_telemetry::install_age(),
|
||||
});
|
||||
}
|
||||
|
||||
fn arm_telemetry(cli: &Cli, command: Option<&Commands>) {
|
||||
|
||||
@@ -403,6 +403,25 @@ 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();
|
||||
|
||||
@@ -22,8 +22,8 @@ use crate::tui::views::{
|
||||
};
|
||||
use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
|
||||
use codewhale_engine::session_manager::{
|
||||
SavedSession, SessionListFilter, SessionManager, SessionMetadata, extract_title,
|
||||
extract_user_prompt, strip_thinking_tags,
|
||||
MAX_SESSION_TITLE_CHARS, 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.len() > 100 {
|
||||
if new_title.is_empty() || new_title.chars().count() > MAX_SESSION_TITLE_CHARS {
|
||||
self.status = Some(tr(self.locale, MessageId::SessionsTitleLength).into_owned());
|
||||
return ViewAction::None;
|
||||
}
|
||||
@@ -2045,6 +2045,28 @@ 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();
|
||||
|
||||
@@ -1104,13 +1104,13 @@ mod screen_mode_tests {
|
||||
Ok(())
|
||||
},
|
||||
|| {
|
||||
Ok(Terminal::with_options(
|
||||
Terminal::with_options(
|
||||
TestBackend::new(20, 6),
|
||||
ratatui::TerminalOptions {
|
||||
viewport: ratatui::Viewport::Inline(3),
|
||||
},
|
||||
)
|
||||
.expect("inline test terminal"))
|
||||
.map_err(|err| match err {})
|
||||
},
|
||||
)
|
||||
.expect("a successful probe must switch");
|
||||
|
||||
@@ -51,13 +51,6 @@ fn resolve(
|
||||
directory
|
||||
.parent()
|
||||
.ok_or("test deps directory has no parent")?
|
||||
} else if directory.ends_with("out") {
|
||||
directory
|
||||
.ancestors()
|
||||
.skip(1)
|
||||
.find(|ancestor| ancestor.ends_with("build"))
|
||||
.and_then(Path::parent)
|
||||
.unwrap_or(directory)
|
||||
} else {
|
||||
directory
|
||||
};
|
||||
@@ -207,27 +200,4 @@ mod tests {
|
||||
assert_eq!(resolve(None, None, &test, &target).unwrap(), canonical);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_unit_build_layout_resolves_the_final_target_and_cross_profile() {
|
||||
let root = tempfile::TempDir::new().unwrap();
|
||||
let target = root.path().join("final target");
|
||||
for triple in [None, Some("aarch64-unknown-linux-gnu")] {
|
||||
let final_profile = match triple {
|
||||
Some(triple) => target.join(triple).join("debug"),
|
||||
None => target.join("debug"),
|
||||
};
|
||||
std::fs::create_dir_all(&final_profile).unwrap();
|
||||
let canonical =
|
||||
final_profile.join(format!("codewhale{}", std::env::consts::EXE_SUFFIX));
|
||||
std::fs::write(&canonical, b"canonical").unwrap();
|
||||
let test = final_profile
|
||||
.join("build")
|
||||
.join("codewhale-tui")
|
||||
.join("0123456789abcdef")
|
||||
.join("out")
|
||||
.join("integration-hash");
|
||||
assert_eq!(resolve(None, None, &test, &target).unwrap(), canonical);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ changelog or release notes, and this contributor record.
|
||||
|
||||
**Contributions**
|
||||
|
||||
- **[MannXo](https://github.com/MannXo)** — added Qwen Code to the external agent registry so an installed Qwen Code CLI runs as a session over ACP ([#7293](https://github.com/codewhale-hq/Codewhale/pull/7293)).
|
||||
- **[SamhandsomeLee](https://github.com/SamhandsomeLee)** — drove Responses request, URL and stream behavior from one checked dialect profile table ([#7261](https://github.com/codewhale-hq/Codewhale/pull/7261)).
|
||||
- **[gaord](https://github.com/gaord)** — ran the read-only `/system`, `/tokens` and `/cost` builtins over the Runtime API through one gated terminal-host catalog ([#7277](https://github.com/codewhale-hq/Codewhale/pull/7277)).
|
||||
|
||||
|
||||
@@ -172,15 +172,6 @@ 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.
|
||||
|
||||
Release candidate run 43320217667, built after the stable toolchain moved to
|
||||
Rust 1.100.0, stopped the check on two more kernel32.dll pairs,
|
||||
`GetLogicalDrives` and `QueryDosDeviceW`, which the Rust 1.100.0 standard
|
||||
library's Windows file system code imports. Probe run 43330905781 (Windows 11
|
||||
ARM64 build 26200.9725) resolved all 449 imports of that candidate's ARM64
|
||||
executables. For x64 the 2 pairs are documented Microsoft API contracts
|
||||
(Windows XP minimum on each function's reference page); the x64 profile now
|
||||
allows 473 pairs and the ARM64 profile 475, with the same limits as above.
|
||||
|
||||
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,
|
||||
|
||||
+1
-1
@@ -3270,7 +3270,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 and Grok. `state` is `installed` or `not_installed`; a binary is
|
||||
OpenCode, Kimi Code, Mistral Vibe, Qoder, CodeBuddy, Grok and Qwen Code. `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
|
||||
|
||||
+59
-19
@@ -4,7 +4,7 @@
|
||||
|
||||
**Usage analytics are on by default in the current source.** The first
|
||||
interactive launch gives a localized, nonblocking disclosure naming **Codewhale
|
||||
and PostHog**, with a direct route to turn counting off. Notice version `6`
|
||||
and PostHog**, with a direct route to turn counting off. Notice version `8`
|
||||
describes this policy; displaying it is not recorded as human acceptance.
|
||||
Missing an older acceptance record does not turn a fresh installation off.
|
||||
Existing explicit opt-outs remain off across upgrades, and unreadable privacy
|
||||
@@ -65,7 +65,7 @@ codewhale --telemetry false # the same kill switch, for one command
|
||||
**`telemetry = false` in the config file is the opt-out.** It is a floor:
|
||||
`--telemetry true` and `CODEWHALE_TELEMETRY=1` both lose to it, because a
|
||||
setting you can undo by accident from a wrapper script is not a setting. It
|
||||
deletes the random install id, truncates every buffered event and every dry-run
|
||||
deletes the random install id and the local first-seen day (so the install-age bucket starts over at `new_today` if you turn telemetry back on), truncates every buffered event and every dry-run
|
||||
record, and writes a tombstone. Appends, identity/state writes, and delivery all
|
||||
share the wipe's ordering lock, so once opt-out returns no pre-opt-out write or
|
||||
POST remains in flight. If any part of that wipe fails, the tombstone is still
|
||||
@@ -104,7 +104,7 @@ Everything is under `$CODEWHALE_HOME/telemetry/` (`0700`), every file `0600`:
|
||||
| `buffer.jsonl` | pending events, one JSON object per line |
|
||||
| `buffer.jsonl.lock` | the sibling ordering lock shared by writes, delivery, arming, and wipe |
|
||||
| `dryrun.jsonl` | where batches go when the endpoint is configured empty |
|
||||
| `state.json` | the last app version seen and the last flush attempt |
|
||||
| `state.json` | the last app version seen, the last flush attempt, and the UTC day this state was first created. The date never leaves the device: only the `install_age` bucket is sent |
|
||||
| `install_id.json` | the random install id and when it was minted |
|
||||
| `disabled` | the tombstone; present means nothing is appended or sent |
|
||||
|
||||
@@ -162,10 +162,14 @@ most once per flush point and never grows a queue.
|
||||
|
||||
## Event schema
|
||||
|
||||
`SCHEMA_VERSION = 4`. `notice_version = 6` identifies the disclosed default-on
|
||||
policy; it does not assert human consent or that a notice was seen. Schema 4
|
||||
adds the `model_usage` and `tool_usage` events and the `ci` session source.
|
||||
Schema-3 batches with `notice_version = 5` are still accepted without them, which
|
||||
`SCHEMA_VERSION = 6`. `notice_version = 8` identifies the disclosed default-on
|
||||
policy; it does not assert human consent or that a notice was seen. Schema 6
|
||||
adds, to `model_usage`, the request count, reasoning tokens, whole seconds spent
|
||||
waiting on the model and a four-bucket input-size histogram; adds a coarse
|
||||
install age and three exec start labels to `session_start`; and adds the
|
||||
`unclean` exit class. Schema 4 added the `model_usage` and `tool_usage` events
|
||||
and the `ci` session source. Schema-4 and schema-3 batches are still accepted
|
||||
without the schema-6 fields. Schema-3 batches with `notice_version = 5` are still accepted without them, which
|
||||
is what the web and desktop control-plane relay sends. The closed
|
||||
v2 contract still accepts only `consent_version = 4`, retaining its original
|
||||
explicit opt-in meaning. Fields cannot be mixed between versions. The unchanged
|
||||
@@ -178,8 +182,8 @@ new fields, surfaces, and `product_usage` events.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"schema_version": 4,
|
||||
"notice_version": 6,
|
||||
"schema_version": 6,
|
||||
"notice_version": 8,
|
||||
"sent_at": "2026-08-03T18:04:11Z", // RFC3339 UTC, second precision
|
||||
"install_id": "3f2a…", // uuid v4, rotates every 90 days
|
||||
"app_version": "0.9.12",
|
||||
@@ -196,7 +200,7 @@ new fields, surfaces, and `product_usage` events.
|
||||
| Field | Type | Source anchor | Rule |
|
||||
|---|---|---|---|
|
||||
| `schema_version` | `u32` | const in `crates/telemetry/src/event.rs` | Bumped on any field add/remove/retype. Never reused. Pinned by a golden snapshot test. |
|
||||
| `notice_version` | `u32` | policy constant in `crates/telemetry/src/event.rs` | Exactly `6` for schema 4; identifies the disclosed opt-out policy, not a human acceptance record. |
|
||||
| `notice_version` | `u32` | policy constant in `crates/telemetry/src/event.rs` | Exactly `8` for schema 6; identifies the disclosed opt-out policy, not a human acceptance record. |
|
||||
| `sent_at` | RFC3339 | `chrono::Utc::now()` | Second precision. Per-**batch** only — events carry no timestamps at all. |
|
||||
| `install_id` | uuid v4 | `crates/telemetry/src/envelope.rs` | Random, never derived, rotated every 90 days. See "Where it lives" above. |
|
||||
| `app_version` | string | `env!("CARGO_PKG_VERSION")`, as at `crates/telemetry/src/lib.rs:112` | Must match `^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$`. |
|
||||
@@ -284,14 +288,27 @@ Emitted once when `state.json`'s `last_version` differs from `app_version`.
|
||||
### Event: `session_start`
|
||||
|
||||
```jsonc
|
||||
{ "event": "session_start", "source": "interactive" }
|
||||
{ "event": "session_start", "source": "interactive", "install_age": "older" }
|
||||
```
|
||||
|
||||
`source` is `SessionSource` (`crates/state/src/lib.rs:34-41`) stringified by `session_source_to_str` (`:1909-1917`): `interactive | resume | fork | api | unknown | ci`.
|
||||
`source` is `SessionSource` (`crates/telemetry/src/event.rs`), a closed enum declared in the telemetry crate: `interactive | resume | fork | api | unknown | ci | fleet_worker | agent | scripted`.
|
||||
`ci` replaces the others whenever `CI` or `GITHUB_ACTIONS` is set to a true
|
||||
value, including the Codewhale GitHub Action, so automated runs are never
|
||||
counted as people. Only that boolean environment fact is read.
|
||||
|
||||
`fleet_worker`, `agent` and `scripted` separate automated `exec` sessions from
|
||||
people. `fleet_worker` is an `exec` that Codewhale's own fleet dispatcher
|
||||
started, `agent` is an `exec` started from inside an agent's shell command (only
|
||||
the presence of a Codewhale-owned environment variable is read, never its
|
||||
value), and `scripted` is an `exec` whose standard input or output is not a
|
||||
terminal. When more than one applies the order is `fleet_worker`, `agent`, `ci`,
|
||||
`scripted`. Other surfaces are unchanged.
|
||||
|
||||
`install_age` is `new_today | under_week | older | null`: a coarse bucket of how
|
||||
long this installation has existed. It is computed on your device and only the
|
||||
bucket leaves it; the date it comes from is stored only in the local state file
|
||||
and never leaves the device. The same UTC day is `new_today`, 1 to 6 days is `under_week`, and 7 or more is `older`. An installation whose state predates this field reports `older`, never `new_today`. `null` means the local state could not be read.
|
||||
|
||||
### Event: `session_end`
|
||||
|
||||
The workhorse. Everything a session accumulated ships here, once.
|
||||
@@ -317,11 +334,11 @@ The workhorse. Everything a session accumulated ships here, once.
|
||||
|
||||
**`duration_bucket`** — `chrono` delta from `app.session_started_at` (`crates/tui/src/tui/app.rs:1889`). Half-open, seconds: `lt_1m` (`d < 60`), `1m_10m` (`60 ≤ d < 600`), `10m_60m` (`600 ≤ d < 3600`), `gt_60m` (`d ≥ 3600`).
|
||||
|
||||
**`exit_class`** — `clean | signal | panic | error`. **Derived from an explicit `AtomicU8`, never from an exit code.** `RunTerminationReason::Canceled` maps to exit 130 (`crates/engine/src/core/runtime_contract/termination.rs:53`), the same value the signal task uses (`crates/tui/src/lib.rs:682`, 128+SIGINT), so a code-based derivation would report every Esc-cancelled turn as a signal. The atomic is set by the panic hook (`crates/tui/src/lib.rs:1582`), by the signal task (`:678-696`) before `std::process::exit`, and on the clean path from `RunTerminationReason::is_success()` (`crates/engine/src/core/runtime_contract/termination.rs:44-46`) — `error` otherwise. Exit codes remain insufficient: an approval-required exit (3) and a terminal exec failure (1) need their actual termination classification.
|
||||
**`exit_class`** — `clean | signal | panic | error | unclean`. `unclean` means the process ended without its own shutdown report and its totals were recovered from a local checkpoint at a later start. **Derived from an explicit `AtomicU8`, never from an exit code.** `RunTerminationReason::Canceled` maps to exit 130 (`crates/engine/src/core/runtime_contract/termination.rs:53`), the same value the signal task uses (`crates/tui/src/lib.rs:682`, 128+SIGINT), so a code-based derivation would report every Esc-cancelled turn as a signal. The atomic is set by the panic hook (`crates/tui/src/lib.rs:1582`), by the signal task (`:678-696`) before `std::process::exit`, and on the clean path from `RunTerminationReason::is_success()` (`crates/engine/src/core/runtime_contract/termination.rs:44-46`) — `error` otherwise. Exit codes remain insufficient: an approval-required exit (3) and a terminal exec failure (1) need their actual termination classification.
|
||||
|
||||
**`cold_start_bucket`** — from `startup_trace::elapsed_ms()`, which reads `PROCESS_START` directly and is independent of the startup summary's buffer clear (`crates/runtime/src/startup_trace.rs:39-45`). Boundaries: `lt_250`, `250_1000`, `1000_3000`, `gte_3000`. Absent on non-TUI surfaces.
|
||||
|
||||
**`providers`** — sorted, deduplicated array of `ProviderKind::as_str()` (`crates/config/src/provider_kind.rs:295`, a `&'static str` from a closed enum; `Custom` yields the literal `"custom"`). **The API takes `ProviderKind` by value, never `&str`.** Do not call `ProviderKind::parse` or `parse_config_identity` (`:300`, `:330`) — those are for config-table resolution. **Do not read** `provider_identity_for_persistence()` (`crates/tui/src/tui/app.rs:5049`), `provider_id_for_persistence()` (`:5058`), `ExecStreamMeta.provider_id` (`crates/app-server/src/exec_agent.rs`), or `PlannedTurnRoute.effective_provider_label` (`crates/engine/src/turn_route_plan.rs:189-193`) — all four return the customer's own `[providers.<name>]` table key when the route is Custom. This is the single most likely leak in the feature: it is one field away from the natural seam and `/status` already prints it (`crates/tui/src/commands/groups/config/status.rs:24-28`). **No model id is ever sent, for any provider** — `crates/runtime/src/safe_label.rs:11-15` documents that a model id can be a path, a URL, or a deployment id that is itself a credential.
|
||||
**`providers`** — sorted, deduplicated array of `ProviderKind::as_str()` (`crates/config/src/provider_kind.rs:295`, a `&'static str` from a closed enum; `Custom` yields the literal `"custom"`). **The API takes `ProviderKind` by value, never `&str`.** Do not call `ProviderKind::parse` or `parse_config_identity` (`:300`, `:330`) — those are for config-table resolution. **Do not read** `provider_identity_for_persistence()` (`crates/tui/src/tui/app.rs:5049`), `provider_id_for_persistence()` (`:5058`), `ExecStreamMeta.provider_id` (`crates/app-server/src/exec_agent.rs`), or `PlannedTurnRoute.effective_provider_label` (`crates/engine/src/turn_route_plan.rs:189-193`) — all four return the customer's own `[providers.<name>]` table key when the route is Custom. This is the single most likely leak in the feature: it is one field away from the natural seam and `/status` already prints it (`crates/tui/src/commands/groups/config/status.rs:24-28`). **No model id outside the bundled public catalog is ever sent, for any provider** — `crates/runtime/src/safe_label.rs:11-15` documents that a model id can be a path, a URL, or a deployment id that is itself a credential. Those are sent as `other`; see `model_usage`.
|
||||
|
||||
**`counters`** — closed field set. Every bump happens at the **call site**, never inside a conditionally-entered handler:
|
||||
|
||||
@@ -443,9 +460,13 @@ to make defaults, and whether prompt caching is working.
|
||||
"input_tokens": 418230,
|
||||
"output_tokens": 9120,
|
||||
"cache_read_tokens": 380001,
|
||||
"cache_write_tokens": 0
|
||||
"cache_write_tokens": 0,
|
||||
"requests": 27,
|
||||
"reasoning_tokens": 2310,
|
||||
"generation_secs": 412
|
||||
},
|
||||
"turn_wall": { "lt_5s": 3, "5_30s": 8, "30_120s": 2, "gte_120s": 0 }
|
||||
"turn_wall": { "lt_5s": 3, "5_30s": 8, "30_120s": 2, "gte_120s": 0 },
|
||||
"context": { "lt_32k": 9, "32k_128k": 11, "128k_256k": 5, "gte_256k": 2 }
|
||||
}
|
||||
```
|
||||
|
||||
@@ -458,6 +479,25 @@ cache reads. Turn counts are by outcome, and `turn_wall` buckets each turn's
|
||||
wall time as `session_end` does. A session reports at most 16 provider and model
|
||||
pairs; later pairs are counted under `other`. No per-turn timeline is sent.
|
||||
|
||||
Schema 6 adds four aggregates to each row:
|
||||
|
||||
- `requests` counts the model requests this session made on its own route for
|
||||
that provider and model.
|
||||
- `reasoning_tokens` is the part of `output_tokens` the provider reports as
|
||||
reasoning. `output_tokens` is not reduced, and the value is `0` when the
|
||||
provider does not report it.
|
||||
- `generation_secs` is the total whole seconds the session spent waiting on the
|
||||
model for those requests. It is one sum for the session; no per-request
|
||||
duration is ever sent.
|
||||
- `context` counts requests by input size, in tokens including cache reads:
|
||||
`lt_32k` is under 32768, `32k_128k` is 32768 up to 131072, `128k_256k` is
|
||||
131072 up to 262144, and `gte_256k` is 262144 or more. The four buckets add up
|
||||
to `requests`. Each edge belongs to the higher bucket. No per-request size is
|
||||
sent.
|
||||
|
||||
Until a client fills them these values are `0`. A schema-4 row carries none of
|
||||
these fields.
|
||||
|
||||
### Event: `tool_usage`
|
||||
|
||||
One event per tool a session called, sent with `session_end`. It is how
|
||||
@@ -508,7 +548,7 @@ configured ingest may forward validated schema-v3 / notice-v5 batches and
|
||||
legacy schema-v2 / consent-v4 batches to
|
||||
PostHog's [batch capture API](https://posthog.com/docs/api/capture). The only
|
||||
permitted origins are `https://us.i.posthog.com` and `https://eu.i.posthog.com`.
|
||||
Schema-v1 and schema-v4 batches cannot reach that path in this source. PostHog receives the same bounded
|
||||
Schema-v1, schema-v4 and schema-v6 batches cannot reach that path in this source. PostHog receives the same bounded
|
||||
envelope and event fields, the batch timestamp as its event timestamp, and
|
||||
`codewhale:<install_id>` as its anonymous `distinct_id`. Events are named
|
||||
`codewhale_install_or_upgrade`, `codewhale_session_start`,
|
||||
@@ -558,7 +598,7 @@ It prints the daily series, a 7-day trend over complete UTC days, the freshness
|
||||
|
||||
### What is never collected — the public red-line list
|
||||
|
||||
Prompts; completions; tool arguments; diffs; patches; file contents; filenames; absolute or relative paths; git remotes; repo names; branch names; workspace commit SHAs; memory entries; chat history; API keys, tokens, cookies, or `Authorization` headers (including any boolean asserting a key exists); model ids that are not in Codewhale's bundled public model catalog (custom, local, fine-tuned or private model names are sent only as `other`); custom provider table names; MCP server names, commands, URLs, or tool names; plugin and configured tool names; approval rule text; error message bodies; panic message text; per-event timestamps; keystrokes; clipboard; screenshots; microphone; camera; location; and any third-party ad or analytics SDK — there are none in the runtime binary and none may be added.
|
||||
Prompts; completions; tool arguments; diffs; patches; file contents; filenames; absolute or relative paths; git remotes; repo names; branch names; workspace commit SHAs; memory entries; chat history; API keys, tokens, cookies, or `Authorization` headers (including any boolean asserting a key exists); model ids that are not in Codewhale's bundled public model catalog (custom, local, fine-tuned or private model names are sent only as `other`); custom provider table names; MCP server names, commands, URLs, or tool names; plugin and configured tool names; approval rule text; error message bodies; panic message text; per-event timestamps; per-request durations or sizes; environment variable values; CI vendor or job names; parent process names; hostnames; container ids; the date an installation was first seen (only the install-age bucket leaves the device); keystrokes; clipboard; screenshots; microphone; camera; location; and any third-party ad or analytics SDK — there are none in the runtime binary and none may be added.
|
||||
|
||||
Two named traps for the implementer. `crates/state/src/lib.rs` persists `git_sha`, `git_branch`, `git_origin_url`, `cwd`, and `path` on the threads table (`:93, :399, :653`): a payload builder that accepts a `Thread` or `ThreadMeta` and derives `Serialize` breaches the contract in one line. **Never derive `Serialize` over an existing state type** — build every telemetry struct from scratch with explicit fields. And `crates/core/src/lib.rs:1389-1398` is the one place in the tree where the word `telemetry` sits inside a JSON object next to `prompt`, `base_url`, and `has_api_key`. It is the object someone will copy. Do not.
|
||||
|
||||
@@ -567,7 +607,7 @@ Two named traps for the implementer. `crates/state/src/lib.rs` persists `git_sha
|
||||
## Website funnel aggregates in the source candidate
|
||||
|
||||
The website extension uses `FUNNEL_SCHEMA_VERSION = 5` and notice version `7`.
|
||||
The Rust runtime remains on `SCHEMA_VERSION = 4`, notice `6`; existing browser
|
||||
The Rust runtime is on `SCHEMA_VERSION = 6`, notice `8`; existing browser
|
||||
schema `3`, notice `5`, and legacy schemas `1` and `2` retain their original
|
||||
contracts. Displaying a newer notice never overrides an opt-out or establishes
|
||||
human acceptance. Schema 5 is first-party only and does not activate PostHog.
|
||||
|
||||
@@ -132,8 +132,6 @@ x64 没有导出探测,因此这 12 对作为低于 14393 下限的微软文
|
||||
x64 的这 3 对作为微软文档 API 契约记录(各函数参考页的最低客户端为 Windows XP),与 #6953 报告者实测导出分开标注;x64 允许列表现为 471 对,ARM64 为 473 对。
|
||||
限制不变:候选仍须以最终可执行文件通过导入检查,两项新增都不是 14393 或安装器运行。
|
||||
|
||||
候选运行 43320217667 在稳定工具链升级到 Rust 1.100.0 后,又在 kernel32.dll 的 `GetLogicalDrives` 与 `QueryDosDeviceW` 两对上停止;它们来自 Rust 1.100.0 标准库的 Windows 文件系统代码。探测运行 43330905781(Windows 11 ARM64 build 26200.9725)解析了该候选 ARM64 可执行文件的全部 449 个导入。x64 的这 2 对作为微软文档 API 契约记录(各函数参考页的最低客户端为 Windows XP);x64 允许列表现为 473 对,ARM64 为 475 对,限制同上。
|
||||
|
||||
此快照中,最终发布文件在真实 1607 上启动/安装及候选程序生成的卸载器仍未验收。
|
||||
最终冻结源码、发布导入记录、各安装途径、打包/原生应用崩溃恢复和最低 OS 回执应
|
||||
进入发布验收记录,见[英文发布检查单](../RELEASE_CHECKLIST.md)。
|
||||
|
||||
@@ -538,6 +538,9 @@ 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)
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"osArchitecture": "x64",
|
||||
"qualification": {
|
||||
"result": "PASS",
|
||||
"kind": "reporter exact-OS export resolution plus 17 documented Microsoft named API minima",
|
||||
"kind": "reporter exact-OS export resolution plus 15 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": 473,
|
||||
"qualifiedPairs": 471,
|
||||
"namedApiContracts": [
|
||||
{
|
||||
"dll": "advapi32.dll",
|
||||
@@ -107,18 +107,6 @@
|
||||
"symbol": "GetSidSubAuthorityCount",
|
||||
"minimumClient": "Windows XP",
|
||||
"source": "https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-getsidsubauthoritycount"
|
||||
},
|
||||
{
|
||||
"dll": "kernel32.dll",
|
||||
"symbol": "GetLogicalDrives",
|
||||
"minimumClient": "Windows XP",
|
||||
"source": "https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-getlogicaldrives"
|
||||
},
|
||||
{
|
||||
"dll": "kernel32.dll",
|
||||
"symbol": "QueryDosDeviceW",
|
||||
"minimumClient": "Windows XP",
|
||||
"source": "https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-querydosdevicew"
|
||||
}
|
||||
],
|
||||
"namedApiContractEvidence": {
|
||||
@@ -140,17 +128,6 @@
|
||||
"advapi32.dll!GetSidSubAuthorityCount"
|
||||
],
|
||||
"scope": "Three pairs from crates/config private_directory.rs; documentation-backed static contracts, same limits as above"
|
||||
},
|
||||
{
|
||||
"candidateRun": "https://github.com/codewhale-hq/Codewhale/actions/runs/43320217667",
|
||||
"source": "b97e38645f13f9f5828fd8a013f414955f46d528",
|
||||
"binarySha256": "b9abea95d29ed0c6caae1373675f7e45e6fe6099b15dae9a99ac64f170263681",
|
||||
"importReceiptSha256": "9bfddf3ed0633d23184b402cb02a880306847a6de43116bd1fabd19bea764544",
|
||||
"pairs": [
|
||||
"kernel32.dll!GetLogicalDrives",
|
||||
"kernel32.dll!QueryDosDeviceW"
|
||||
],
|
||||
"scope": "Two pairs that the Rust 1.100.0 standard library's Windows file system code imports (the toolchain, not a Codewhale call site); documentation-backed static contracts, same limits as above"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -444,7 +421,6 @@
|
||||
"GetFullPathNameA",
|
||||
"GetFullPathNameW",
|
||||
"GetLastError",
|
||||
"GetLogicalDrives",
|
||||
"GetModuleFileNameW",
|
||||
"GetModuleHandleA",
|
||||
"GetModuleHandleExA",
|
||||
@@ -511,7 +487,6 @@
|
||||
"PostQueuedCompletionStatus",
|
||||
"Process32FirstW",
|
||||
"Process32NextW",
|
||||
"QueryDosDeviceW",
|
||||
"QueryInformationJobObject",
|
||||
"QueryPerformanceCounter",
|
||||
"QueryPerformanceFrequency",
|
||||
@@ -691,25 +666,25 @@
|
||||
"qualification": {
|
||||
"result": "PASS",
|
||||
"kind": "exact native system export resolution",
|
||||
"time": "2026-11-12T23:41:18.0140691Z",
|
||||
"time": "2026-11-12T10:08:11.6545236Z",
|
||||
"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": "b97e38645f13f9f5828fd8a013f414955f46d528",
|
||||
"sourceUrl": "https://github.com/codewhale-hq/Codewhale/commit/b97e38645f13f9f5828fd8a013f414955f46d528",
|
||||
"binarySha256": "4d7dfba5955681c0e0d7cb6076a7e1e2ecf253711dcfb2931618ee97ccfdb470",
|
||||
"source": "f5a3dda9755170aeafecd0841347a5578a540290",
|
||||
"sourceUrl": "https://github.com/codewhale-hq/Codewhale/commit/f5a3dda9755170aeafecd0841347a5578a540290",
|
||||
"binarySha256": "c8c6f97d0ccf20e392b2457cb970187e79a29caa28d091dbfefe3a1393b99040",
|
||||
"binaries": [
|
||||
"codewhale-windows-arm64.exe",
|
||||
"codew-windows-arm64.exe",
|
||||
"codewhale-tui-windows-arm64.exe"
|
||||
],
|
||||
"resolved": 449,
|
||||
"resolved": 447,
|
||||
"unresolved": 0,
|
||||
"buildRun": "https://github.com/codewhale-hq/Codewhale/actions/runs/43320217667",
|
||||
"probeRun": "https://github.com/codewhale-hq/Codewhale/actions/runs/43330905781",
|
||||
"buildRun": "https://github.com/codewhale-hq/Codewhale/actions/runs/43186389246",
|
||||
"probeRun": "https://github.com/codewhale-hq/Codewhale/actions/runs/43197611105",
|
||||
"probeWorkflow": ".github/workflows/windows-arm64-export-probe.yml",
|
||||
"profile": "dist, built by release-artifacts.yml (windows-arm64 job) in release candidate run 43320217667",
|
||||
"profile": "dist, built by release-artifacts.yml (windows-arm64 job) in release candidate run 43186389246",
|
||||
"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",
|
||||
@@ -743,25 +718,25 @@
|
||||
"supersedes": {
|
||||
"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",
|
||||
@@ -795,119 +770,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1200,7 +1122,6 @@
|
||||
"GetFullPathNameA",
|
||||
"GetFullPathNameW",
|
||||
"GetLastError",
|
||||
"GetLogicalDrives",
|
||||
"GetModuleFileNameW",
|
||||
"GetModuleHandleA",
|
||||
"GetModuleHandleExA",
|
||||
@@ -1269,7 +1190,6 @@
|
||||
"PostQueuedCompletionStatus",
|
||||
"Process32FirstW",
|
||||
"Process32NextW",
|
||||
"QueryDosDeviceW",
|
||||
"QueryInformationJobObject",
|
||||
"QueryPerformanceCounter",
|
||||
"QueryPerformanceFrequency",
|
||||
|
||||
@@ -15,9 +15,9 @@ hostname is the only way in.
|
||||
|
||||
The current Engine source defaults usage analytics on under notice version 6, with a
|
||||
clear opt-out. Prior explicit declines remain off; a missing acceptance record
|
||||
is not treated as consent. Schema 4 carries `notice_version: 6`; schema 3
|
||||
is not treated as consent. Schema 6 carries `notice_version: 8` and schema 4 carries `notice_version: 6`; schema 3
|
||||
uses notice 5, and legacy schema 2 retains its original explicit
|
||||
`consent_version: 4` contract. Schema 1 and schema 4 batches are first-party
|
||||
`consent_version: 4` contract. Schema 1, 4 and 6 batches are first-party
|
||||
only in this source. Local validation is not proof this updated Worker has been deployed. A user who wants
|
||||
to contact nobody sets `telemetry_endpoint = ""`, which writes batches to
|
||||
`$CODEWHALE_HOME/telemetry/dryrun.jsonl` instead.
|
||||
@@ -38,7 +38,7 @@ require separate operator approval; none is established by the local tests.
|
||||
|
||||
Validated schema 3 batches carrying `notice_version: 5` and legacy schema 2
|
||||
batches carrying explicit `consent_version: 4` can reach the processor. Schema 1
|
||||
and schema 4 batches remain first-party only. Capture uses
|
||||
and schema 4 and 6 batches remain first-party only. Capture uses
|
||||
`/batch/`, fixed anonymous/no-geo controls, no incoming headers, no cookies,
|
||||
no redirects, no response parsing, no retries, and a 1.5-second timeout.
|
||||
PostHog failure is isolated from the successful Analytics Engine write.
|
||||
@@ -143,20 +143,21 @@ take the next free slot.
|
||||
| `blob8` | `tty` — `'true'` \| `'false'` |
|
||||
| `blob9` | `install_kind` (`install_or_upgrade` only) |
|
||||
| `blob10` | `previous_version` (`install_or_upgrade` only) |
|
||||
| `blob11` | `session_source` (`session_start` only) |
|
||||
| `blob12` | `duration_bucket` (`session_end` only) |
|
||||
| `blob13` | `exit_class` (`session_end` only) |
|
||||
| `blob11` | `session_source` (`session_start` only; schema 6 adds `fleet_worker`, `agent`, `scripted`) |
|
||||
| `blob12` | `duration_bucket` (`session_end`); `install_age` (`session_start`, schema 6 only: `new_today`, `under_week`, `older`, `''` for null or older schemas) |
|
||||
| `blob13` | `exit_class` (`session_end` only; schema 6 adds `unclean`) |
|
||||
| `blob14` | `cold_start_bucket` (`session_end` only; `''` on surfaces that do not measure it) |
|
||||
| `blob15` | `providers`, comma-joined, already sorted and deduplicated; the one `provider` on `model_usage` |
|
||||
| `blob16` | `panic_site` (`panic`) — a `crates/…` path or the literal `<dep>`; `model` (`model_usage`); `tool` (`tool_usage`) |
|
||||
| `blob17` | `sent_at` — the *batch* timestamp. Events carry none. |
|
||||
| `blob18` | `aggregate_counters` — closed JSON counts, for `product_usage` or `operations_summary` |
|
||||
| `blob19` | `schema_version` |
|
||||
| `blob20` | `privacy_version`: notice6 for v4, notice5 for v3, consent4 for v2, empty for v1; `blob19` keeps their meanings distinct |
|
||||
| `blob20` | `privacy_version`: notice8 for v6, notice7 for the v5 website funnel, notice6 for v4, notice5 for v3, consent4 for v2, empty for v1; `blob19` keeps their meanings distinct |
|
||||
| `double1..10` | `counters`: `turns`, `tool_calls`, `fleet_dispatch`, `workflow_run`, `subagent_spawn`, `mcp_server_connected`, `memory_search`, `approval_modal_shown`, `approval_auto_allowed`, `command_palette_open` |
|
||||
| `double11..16` | `errors`: `auth_preflight_failed`, `provider_http_4xx`, `provider_http_5xx`, `tool_denied_by_policy`, `tool_timeout`, `network_error` |
|
||||
| `double17..20` | `turn_wall`: `lt_5s`, `5_30s`, `30_120s`, `gte_120s` |
|
||||
| `double1..11` on `model_usage` | `turns_ok`, `turns_failed`, `turns_interrupted`, `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, then `turn_wall` `lt_5s`, `5_30s`, `30_120s`, `gte_120s` |
|
||||
| `double12..18` on `model_usage` | schema 6 only, `0` on v4 rows (`blob19` tells them apart): `requests`, `reasoning_tokens`, `generation_secs` (whole seconds, session sum, u32), then input-size request counts `lt_32k`, `32k_128k`, `128k_256k`, `gte_256k` |
|
||||
| `double1..2` on `tool_usage` | `calls`, `errors` |
|
||||
|
||||
Columns not relevant to an event are `''` / `0`. `tty` is a blob because the 20
|
||||
|
||||
@@ -13,7 +13,7 @@ export const CAVEATS = [
|
||||
"Runtime install means first initialized enabled telemetry state, not all installations or new people. IDs rotate every 90 days and are removed on opt-out.",
|
||||
"Native success is observed model turns_ok, not a first-ever primary or useful task. Missing usage, same-process child activity and late cancellation can affect it.",
|
||||
"Dates are received batch dates rather than exact event times. Offline, opted-out and dropped deliveries are absent; ingest has no replay deduplication guarantee.",
|
||||
"Native IDs with an observed CI start are excluded in full because the aggregate schema cannot assign model counts to individual sessions.",
|
||||
"Native IDs with an observed CI, fleet worker, agent or scripted start are excluded in full because the aggregate schema cannot assign model counts to individual sessions.",
|
||||
];
|
||||
|
||||
function instant(value, field) {
|
||||
@@ -72,7 +72,7 @@ export function buildFunnelReport(input, { now = new Date() } = {}) {
|
||||
const installs = new Map(); const successes = new Map(); const started = new Set(); const ci = new Set();
|
||||
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
for (const row of input.native_rows) {
|
||||
if (!uuid.test(row.install_id) || !NATIVE_SURFACES.includes(row.surface) || ![1, 2, 3, 4].includes(count(row.schema_version, "schema_version"))) throw new Error("invalid native identity, surface or version");
|
||||
if (!uuid.test(row.install_id) || !NATIVE_SURFACES.includes(row.surface) || ![1, 2, 3, 4, 6].includes(count(row.schema_version, "schema_version"))) throw new Error("invalid native identity, surface or version");
|
||||
const at = instant(fresh(row.received_at), "received_at");
|
||||
if (!within(at, from, to)) throw new Error("native row outside coverage");
|
||||
if (newest === null || at > newest) newest = at;
|
||||
@@ -82,11 +82,11 @@ export function buildFunnelReport(input, { now = new Date() } = {}) {
|
||||
if (!["install", "upgrade", "downgrade"].includes(row.install_kind)) throw new Error("invalid install kind");
|
||||
if (row.install_kind === "install" && (!installs.has(row.install_id) || at < installs.get(row.install_id))) installs.set(row.install_id, at);
|
||||
} else if (row.event === "session_start") {
|
||||
if (!["interactive", "resume", "fork", "api", "unknown", "ci"].includes(row.session_source)) throw new Error("invalid session source");
|
||||
if (row.session_source === "ci") ci.add(row.install_id);
|
||||
if (!["interactive", "resume", "fork", "api", "unknown", "ci", "fleet_worker", "agent", "scripted"].includes(row.session_source)) throw new Error("invalid session source");
|
||||
if (["ci", "fleet_worker", "agent", "scripted"].includes(row.session_source)) ci.add(row.install_id);
|
||||
started.add(row.install_id);
|
||||
} else if (row.event === "model_usage") {
|
||||
if (count(row.schema_version, "schema_version") !== 4) throw new Error("model usage requires v4");
|
||||
if (![4, 6].includes(count(row.schema_version, "schema_version"))) throw new Error("model usage requires v4 or v6");
|
||||
if (count(row.turns_ok, "turns_ok") > 0) {
|
||||
const observations = successes.get(row.install_id) ?? []; observations.push(at); successes.set(row.install_id, observations);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,14 @@
|
||||
*/
|
||||
|
||||
import type { Batch, Event } from "./schema";
|
||||
import { COUNTER_FIELDS, ERROR_FIELDS, MODEL_COUNTER_FIELDS, TURN_WALL_FIELDS } from "./schema";
|
||||
import {
|
||||
CONTEXT_FIELDS,
|
||||
COUNTER_FIELDS,
|
||||
ERROR_FIELDS,
|
||||
MODEL_COUNTER_FIELDS,
|
||||
MODEL_COUNTER_FIELDS_V6_EXTRA,
|
||||
TURN_WALL_FIELDS,
|
||||
} from "./schema";
|
||||
|
||||
/** The subset of `AnalyticsEngineDataset` this Worker uses. */
|
||||
export interface DataPointSink {
|
||||
@@ -58,7 +65,7 @@ export const BLOB_COLUMNS = [
|
||||
"install_kind", // blob9
|
||||
"previous_version", // blob10
|
||||
"session_source", // blob11
|
||||
"duration_bucket", // blob12
|
||||
"duration_bucket", // blob12 session_end; install_age on session_start (schema 6)
|
||||
"exit_class", // blob13
|
||||
"cold_start_bucket", // blob14
|
||||
"providers", // blob15 comma-joined, already sorted and deduplicated
|
||||
@@ -66,7 +73,7 @@ export const BLOB_COLUMNS = [
|
||||
"sent_at", // blob17 the batch timestamp; events carry none
|
||||
"aggregate_counters", // blob18 closed product/operations JSON count object
|
||||
"schema_version", // blob19
|
||||
"privacy_version", // blob20: v3/v4 notice, v2 consent, empty on v1; blob19 disambiguates
|
||||
"privacy_version", // blob20: v3 to v6 notice, v2 consent, empty on v1; blob19 disambiguates
|
||||
] as const;
|
||||
|
||||
/**
|
||||
@@ -111,6 +118,7 @@ function toDataPoint(batch: Batch, event: Event): DataPoint {
|
||||
break;
|
||||
case "session_start":
|
||||
blobs[10] = event.source;
|
||||
blobs[11] = event.install_age ?? "";
|
||||
break;
|
||||
case "session_end":
|
||||
blobs[11] = event.duration_bucket;
|
||||
@@ -141,8 +149,10 @@ function toDataPoint(batch: Batch, event: Event): DataPoint {
|
||||
doubles = [
|
||||
...MODEL_COUNTER_FIELDS.map((field) => event.counters[field]),
|
||||
...TURN_WALL_FIELDS.map((field) => event.turn_wall[field]),
|
||||
...EMPTY_DOUBLES.slice(MODEL_COUNTER_FIELDS.length + TURN_WALL_FIELDS.length),
|
||||
...MODEL_COUNTER_FIELDS_V6_EXTRA.map((field) => event.counters[field] ?? 0),
|
||||
...CONTEXT_FIELDS.map((field) => event.context?.[field] ?? 0),
|
||||
];
|
||||
doubles = [...doubles, ...EMPTY_DOUBLES.slice(doubles.length)];
|
||||
break;
|
||||
case "tool_usage":
|
||||
blobs[15] = event.tool;
|
||||
|
||||
@@ -22,6 +22,8 @@ export const SCHEMA_VERSION = 4;
|
||||
/** Notice-v5 batches without `model_usage`, `tool_usage` or the `ci` source. */
|
||||
export const V3_SCHEMA_VERSION = 3;
|
||||
|
||||
export const V6_SCHEMA_VERSION = 6;
|
||||
|
||||
export const FUNNEL_SCHEMA_VERSION = 5;
|
||||
export const FUNNEL_NOTICE_VERSION = 7;
|
||||
export const FUNNEL_COUNTER_FIELDS = [
|
||||
@@ -34,6 +36,8 @@ export const FUNNEL_COUNTER_FIELDS = [
|
||||
/** Disclosed default-on policy, not a record of human acceptance. */
|
||||
export const NOTICE_VERSION = 6;
|
||||
|
||||
export const V6_NOTICE_VERSION = 8;
|
||||
|
||||
/** The notice version schema v3 batches carry. */
|
||||
export const V3_NOTICE_VERSION = 5;
|
||||
|
||||
@@ -146,6 +150,11 @@ export const SESSION_SOURCES = [
|
||||
"ci",
|
||||
] as const;
|
||||
|
||||
export const V6_ONLY_SESSION_SOURCES = ["fleet_worker", "agent", "scripted"] as const;
|
||||
export const V6_SESSION_SOURCES = [...SESSION_SOURCES, ...V6_ONLY_SESSION_SOURCES] as const;
|
||||
|
||||
export const INSTALL_AGES = ["new_today", "under_week", "older"] as const;
|
||||
|
||||
/** `DurationBucket` wire spellings. */
|
||||
export const DURATION_BUCKETS = [
|
||||
"lt_1m",
|
||||
@@ -157,6 +166,8 @@ export const DURATION_BUCKETS = [
|
||||
/** `ExitClass::as_str`. */
|
||||
export const EXIT_CLASSES = ["clean", "signal", "panic", "error"] as const;
|
||||
|
||||
export const V6_EXIT_CLASSES = [...EXIT_CLASSES, "unclean"] as const;
|
||||
|
||||
/** `ColdStartBucket` wire spellings. */
|
||||
export const COLD_START_BUCKETS = [
|
||||
"lt_250",
|
||||
@@ -211,6 +222,19 @@ export const MODEL_COUNTER_FIELDS = [
|
||||
"cache_write_tokens",
|
||||
] as const;
|
||||
|
||||
export const MODEL_COUNTER_FIELDS_V6_EXTRA = [
|
||||
"requests",
|
||||
"reasoning_tokens",
|
||||
"generation_secs",
|
||||
] as const;
|
||||
|
||||
export const V6_MODEL_COUNTER_FIELDS = [
|
||||
...MODEL_COUNTER_FIELDS,
|
||||
...MODEL_COUNTER_FIELDS_V6_EXTRA,
|
||||
] as const;
|
||||
|
||||
export const CONTEXT_FIELDS = ["lt_32k", "32k_128k", "128k_256k", "gte_256k"] as const;
|
||||
|
||||
/** `event::TOOL_NAMES` plus the `mcp` and `other` buckets. */
|
||||
export const TOOL_NAMES = [
|
||||
"agent", "apply_patch", "authenticate", "bash", "code_execution", "create_goal",
|
||||
@@ -286,6 +310,12 @@ export const EVENT_FIELDS: Readonly<Record<string, readonly string[]>> = {
|
||||
funnel_usage: ["event", "counters"],
|
||||
};
|
||||
|
||||
export const V6_EVENT_FIELDS: Readonly<Record<string, readonly string[]>> = {
|
||||
...EVENT_FIELDS,
|
||||
session_start: ["event", "source", "install_age"],
|
||||
model_usage: ["event", "provider", "model", "counters", "turn_wall", "context"],
|
||||
};
|
||||
|
||||
/** Every event discriminant, for the doc-match test. */
|
||||
export const EVENT_NAMES = Object.keys(EVENT_FIELDS);
|
||||
|
||||
@@ -430,7 +460,7 @@ export type FunnelCounters = Record<(typeof FUNNEL_COUNTER_FIELDS)[number], numb
|
||||
|
||||
export type Event =
|
||||
| { event: "install_or_upgrade"; kind: string; previous_version: string | null }
|
||||
| { event: "session_start"; source: string }
|
||||
| { event: "session_start"; source: string; install_age?: string | null }
|
||||
| {
|
||||
event: "session_end";
|
||||
duration_bucket: string;
|
||||
@@ -448,8 +478,10 @@ export type Event =
|
||||
event: "model_usage";
|
||||
provider: string;
|
||||
model: string;
|
||||
counters: Record<(typeof MODEL_COUNTER_FIELDS)[number], number>;
|
||||
counters: Record<(typeof MODEL_COUNTER_FIELDS)[number], number> &
|
||||
Partial<Record<(typeof MODEL_COUNTER_FIELDS_V6_EXTRA)[number], number>>;
|
||||
turn_wall: TurnWall;
|
||||
context?: Record<(typeof CONTEXT_FIELDS)[number], number>;
|
||||
}
|
||||
| { event: "tool_usage"; tool: string; calls: number; errors: number }
|
||||
| ({ event: "operations_summary" } & Record<(typeof OPERATIONS_FIELDS)[number], number>);
|
||||
@@ -546,13 +578,28 @@ function u32Map(
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateEvent(value: unknown, where: string): string | null {
|
||||
function u32Fields(
|
||||
value: Record<string, unknown>,
|
||||
fields: readonly string[],
|
||||
where: string,
|
||||
): string | null {
|
||||
for (const field of fields) {
|
||||
const item = value[field];
|
||||
if (typeof item !== "number" || !Number.isInteger(item) || item < 0 || item > U32_MAX) {
|
||||
return `${where}.${field}: not a u32`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateEvent(value: unknown, where: string, v6 = false): string | null {
|
||||
if (!isPlainObject(value)) return `${where}: not an object`;
|
||||
const name = value.event;
|
||||
if (typeof name !== "string" || !Object.hasOwn(EVENT_FIELDS, name)) {
|
||||
const eventFields = v6 ? V6_EVENT_FIELDS : EVENT_FIELDS;
|
||||
if (typeof name !== "string" || !Object.hasOwn(eventFields, name)) {
|
||||
return `${where}: unknown event discriminant`;
|
||||
}
|
||||
const keyError = keysExactly(value, EVENT_FIELDS[name], `${where}(${name})`);
|
||||
const keyError = keysExactly(value, eventFields[name], `${where}(${name})`);
|
||||
if (keyError) return keyError;
|
||||
|
||||
switch (name) {
|
||||
@@ -575,8 +622,15 @@ function validateEvent(value: unknown, where: string): string | null {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
case "session_start":
|
||||
return enumString(value.source, SESSION_SOURCES, `${where}.source`);
|
||||
case "session_start": {
|
||||
const sourceError = enumString(
|
||||
value.source,
|
||||
v6 ? V6_SESSION_SOURCES : SESSION_SOURCES,
|
||||
`${where}.source`,
|
||||
);
|
||||
if (sourceError || !v6 || value.install_age === null) return sourceError;
|
||||
return enumString(value.install_age, INSTALL_AGES, `${where}.install_age`);
|
||||
}
|
||||
case "session_end": {
|
||||
const bucketError = enumString(
|
||||
value.duration_bucket,
|
||||
@@ -586,7 +640,7 @@ function validateEvent(value: unknown, where: string): string | null {
|
||||
if (bucketError) return bucketError;
|
||||
const exitError = enumString(
|
||||
value.exit_class,
|
||||
EXIT_CLASSES,
|
||||
v6 ? V6_EXIT_CLASSES : EXIT_CLASSES,
|
||||
`${where}.exit_class`,
|
||||
);
|
||||
if (exitError) return exitError;
|
||||
@@ -638,9 +692,14 @@ function validateEvent(value: unknown, where: string): string | null {
|
||||
if (typeof value.model !== "string" || !MODEL_RE.test(value.model)) {
|
||||
return `${where}.model: not a catalogued model id`;
|
||||
}
|
||||
const counterError = v6
|
||||
? countMap(value.counters, V6_MODEL_COUNTER_FIELDS, `${where}.counters`, Number.MAX_SAFE_INTEGER) ??
|
||||
u32Fields(value.counters as Record<string, unknown>, ["requests", "generation_secs"], `${where}.counters`)
|
||||
: countMap(value.counters, MODEL_COUNTER_FIELDS, `${where}.counters`, Number.MAX_SAFE_INTEGER);
|
||||
return (
|
||||
countMap(value.counters, MODEL_COUNTER_FIELDS, `${where}.counters`, Number.MAX_SAFE_INTEGER) ??
|
||||
u32Map(value.turn_wall, TURN_WALL_FIELDS, `${where}.turn_wall`)
|
||||
counterError ??
|
||||
u32Map(value.turn_wall, TURN_WALL_FIELDS, `${where}.turn_wall`) ??
|
||||
(v6 ? u32Map(value.context, CONTEXT_FIELDS, `${where}.context`) : null)
|
||||
);
|
||||
}
|
||||
case "tool_usage":
|
||||
@@ -675,15 +734,12 @@ export function validateBatch(value: unknown): Accepted | Rejection {
|
||||
|
||||
const legacy = value.schema_version === 1;
|
||||
const explicitConsent = value.schema_version === 2;
|
||||
const v3 = value.schema_version === V3_SCHEMA_VERSION;
|
||||
const v4 = value.schema_version === SCHEMA_VERSION;
|
||||
const funnel = value.schema_version === FUNNEL_SCHEMA_VERSION;
|
||||
const preUsage = value.schema_version !== SCHEMA_VERSION && !funnel;
|
||||
if (
|
||||
!legacy &&
|
||||
!explicitConsent &&
|
||||
value.schema_version !== SCHEMA_VERSION &&
|
||||
!funnel &&
|
||||
value.schema_version !== V3_SCHEMA_VERSION
|
||||
) {
|
||||
const v6 = value.schema_version === V6_SCHEMA_VERSION;
|
||||
const preUsage = legacy || explicitConsent || v3;
|
||||
if (!legacy && !explicitConsent && !v3 && !v4 && !funnel && !v6) {
|
||||
return { ok: false, reason: "batch.schema_version: unsupported" };
|
||||
}
|
||||
const fields = legacy ? LEGACY_ENVELOPE_FIELDS : explicitConsent ? V2_ENVELOPE_FIELDS : ENVELOPE_FIELDS;
|
||||
@@ -695,7 +751,7 @@ export function validateBatch(value: unknown): Accepted | Rejection {
|
||||
if (
|
||||
!legacy &&
|
||||
!explicitConsent &&
|
||||
value.notice_version !== (funnel ? FUNNEL_NOTICE_VERSION : preUsage ? V3_NOTICE_VERSION : NOTICE_VERSION)
|
||||
value.notice_version !== (funnel ? FUNNEL_NOTICE_VERSION : v6 ? V6_NOTICE_VERSION : preUsage ? V3_NOTICE_VERSION : NOTICE_VERSION)
|
||||
) {
|
||||
return { ok: false, reason: "batch.notice_version: current policy required" };
|
||||
}
|
||||
@@ -753,7 +809,7 @@ export function validateBatch(value: unknown): Accepted | Rejection {
|
||||
return { ok: false, reason: "events: aggregate product/operations events require schema v2 or v3" };
|
||||
}
|
||||
if (preUsage && (event.event === "model_usage" || event.event === "tool_usage")) {
|
||||
return { ok: false, reason: "events: model_usage and tool_usage require schema v4" };
|
||||
return { ok: false, reason: "events: model_usage and tool_usage require schema v4 or later" };
|
||||
}
|
||||
if (preUsage && event.event === "session_start" && event.source === "ci") {
|
||||
return { ok: false, reason: "events: the ci session source requires schema v4" };
|
||||
@@ -762,7 +818,7 @@ export function validateBatch(value: unknown): Accepted | Rejection {
|
||||
return { ok: false, reason: "events: operations_summary requires control-plane surface" };
|
||||
}
|
||||
}
|
||||
const eventError = validateEvent(event, `events[${index}]`);
|
||||
const eventError = validateEvent(event, `events[${index}]`, v6);
|
||||
if (eventError) return { ok: false, reason: eventError };
|
||||
}
|
||||
|
||||
|
||||
@@ -97,12 +97,12 @@ describe("additive website funnel contract", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps all four historical goldens accepted and runtime v4 first-party only", async () => {
|
||||
it("keeps the historical goldens and v6 accepted, with runtime v4 and v6 first-party only", async () => {
|
||||
const fetch = vi.fn(); vi.stubGlobal("fetch", fetch);
|
||||
for (const version of [1, 2, 3, 4]) {
|
||||
for (const version of [1, 2, 3, 4, 6]) {
|
||||
const batch = JSON.parse(readFileSync(new URL(`../../crates/telemetry/tests/golden/v${version}.json`, import.meta.url), "utf8"));
|
||||
expect(validateBatch(batch).ok, `v${version}`).toBe(true);
|
||||
if (version !== 4) continue;
|
||||
if (version !== 4 && version !== 6) continue;
|
||||
const { env, written } = harness();
|
||||
expect((await worker.fetch(postJson(batch), { ...env, POSTHOG_HOST: "https://us.i.posthog.com", POSTHOG_PROJECT_TOKEN: "phc_local_test_fixture", POSTHOG_IP_SAFE_EGRESS_VERIFIED: "true" })).status).toBe(204);
|
||||
expect(written).toHaveLength(batch.events.length);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest";
|
||||
import worker from "../src/index";
|
||||
import { INGEST_PATH } from "../src/route";
|
||||
import { BLOB_COLUMNS, DOUBLE_COLUMNS } from "../src/datapoint";
|
||||
import { MAX_BODY_BYTES, SURFACES } from "../src/schema";
|
||||
import { MAX_BODY_BYTES, SURFACES, validateBatch } from "../src/schema";
|
||||
import { goldenBatch, harness, post, postJson } from "./support";
|
||||
|
||||
describe("method and route", () => {
|
||||
@@ -473,3 +473,149 @@ describe("failing closed and quiet", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("schema 6", () => {
|
||||
const golden = (version: number): Record<string, any> =>
|
||||
JSON.parse(
|
||||
readFileSync(
|
||||
new URL(`../../crates/telemetry/tests/golden/v${version}.json`, import.meta.url),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
const event = (batch: Record<string, any>, name: string) =>
|
||||
batch.events.find((item: Record<string, any>) => item.event === name);
|
||||
|
||||
it("accepts every historical golden and the v6 golden", () => {
|
||||
for (const version of [1, 2, 3, 4, 6]) {
|
||||
expect(validateBatch(golden(version)).ok, `v${version}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("answers 204 to the v4 and v6 goldens", async () => {
|
||||
for (const version of [4, 6]) {
|
||||
const { env, written } = harness();
|
||||
expect((await worker.fetch(postJson(golden(version)), env)).status).toBe(204);
|
||||
expect(written).toHaveLength(golden(version).events.length);
|
||||
}
|
||||
});
|
||||
|
||||
it("requires notice 8 on v6 and refuses the v4 notice", () => {
|
||||
const batch = golden(6);
|
||||
batch.notice_version = 6;
|
||||
expect(validateBatch(batch).ok).toBe(false);
|
||||
expect(validateBatch({ ...golden(4), notice_version: 8 }).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a v4 batch carrying any v6 key or value", () => {
|
||||
const mutations: Array<(batch: Record<string, any>) => void> = [
|
||||
(batch) => { event(batch, "model_usage").context = event(golden(6), "model_usage").context; },
|
||||
(batch) => { event(batch, "model_usage").counters.requests = 1; },
|
||||
(batch) => { event(batch, "model_usage").counters.reasoning_tokens = 1; },
|
||||
(batch) => { event(batch, "model_usage").counters.generation_secs = 1; },
|
||||
(batch) => { event(batch, "session_start").install_age = "older"; },
|
||||
(batch) => { event(batch, "session_start").source = "fleet_worker"; },
|
||||
(batch) => { event(batch, "session_start").source = "agent"; },
|
||||
(batch) => { event(batch, "session_start").source = "scripted"; },
|
||||
(batch) => { event(batch, "session_end").exit_class = "unclean"; },
|
||||
];
|
||||
for (const [index, mutate] of mutations.entries()) {
|
||||
const batch = golden(4);
|
||||
mutate(batch);
|
||||
expect(validateBatch(batch).ok, `mutation ${index}`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a v6 batch missing or adding a key", () => {
|
||||
const mutations: Array<(batch: Record<string, any>) => void> = [
|
||||
(batch) => { delete event(batch, "model_usage").context; },
|
||||
(batch) => { delete event(batch, "model_usage").context["32k_128k"]; },
|
||||
(batch) => { event(batch, "model_usage").context.gte_512k = 0; },
|
||||
(batch) => { delete event(batch, "model_usage").counters.requests; },
|
||||
(batch) => { delete event(batch, "model_usage").counters.reasoning_tokens; },
|
||||
(batch) => { delete event(batch, "model_usage").counters.generation_secs; },
|
||||
(batch) => { event(batch, "model_usage").counters.generation_ms = 1; },
|
||||
(batch) => { event(batch, "model_usage").model_family = "x"; },
|
||||
(batch) => { delete event(batch, "session_start").install_age; },
|
||||
(batch) => { event(batch, "session_start").extra = "x"; },
|
||||
(batch) => { event(batch, "session_end").counters.requests = 1; },
|
||||
];
|
||||
for (const [index, mutate] of mutations.entries()) {
|
||||
const batch = golden(6);
|
||||
mutate(batch);
|
||||
expect(validateBatch(batch).ok, `mutation ${index}`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses open values where v6 has a closed set or a count", () => {
|
||||
const mutations: Array<(batch: Record<string, any>) => void> = [
|
||||
(batch) => { event(batch, "session_start").install_age = "2026-11-01"; },
|
||||
(batch) => { event(batch, "session_start").install_age = "OLDER"; },
|
||||
(batch) => { event(batch, "session_start").source = "github-actions"; },
|
||||
(batch) => { event(batch, "session_end").exit_class = "killed"; },
|
||||
(batch) => { event(batch, "model_usage").counters.requests = 4294967296; },
|
||||
(batch) => { event(batch, "model_usage").counters.requests = 1.5; },
|
||||
(batch) => { event(batch, "model_usage").counters.generation_secs = -1; },
|
||||
(batch) => { event(batch, "model_usage").counters.generation_secs = 4294967296; },
|
||||
(batch) => { event(batch, "model_usage").counters.generation_secs = 1794528251123; },
|
||||
(batch) => { event(batch, "model_usage").counters.reasoning_tokens = "7"; },
|
||||
(batch) => { event(batch, "model_usage").context.lt_32k = 4294967296; },
|
||||
(batch) => { event(batch, "model_usage").context.lt_32k = "1"; },
|
||||
(batch) => { event(batch, "model_usage").model = "/Users/someone/model"; },
|
||||
];
|
||||
for (const [index, mutate] of mutations.entries()) {
|
||||
const batch = golden(6);
|
||||
mutate(batch);
|
||||
expect(validateBatch(batch).ok, `mutation ${index}`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a null install_age and every v6 source and exit class", () => {
|
||||
for (const source of ["interactive", "resume", "fork", "api", "unknown", "ci", "fleet_worker", "agent", "scripted"]) {
|
||||
for (const age of ["new_today", "under_week", "older", null]) {
|
||||
const batch = golden(6);
|
||||
event(batch, "session_start").source = source;
|
||||
event(batch, "session_start").install_age = age;
|
||||
expect(validateBatch(batch).ok, `${source}/${age}`).toBe(true);
|
||||
}
|
||||
}
|
||||
for (const exit of ["clean", "signal", "panic", "error", "unclean"]) {
|
||||
const batch = golden(6);
|
||||
event(batch, "session_end").exit_class = exit;
|
||||
expect(validateBatch(batch).ok, exit).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("writes v6 model_usage after the unchanged eleven doubles, and leaves v4 rows zero there", async () => {
|
||||
const v6 = harness();
|
||||
await worker.fetch(postJson(golden(6)), v6.env);
|
||||
const row = v6.written.find((point) => point.blobs[0] === "model_usage")!;
|
||||
expect(row.doubles).toEqual([
|
||||
12, 1, 0, 418230, 9120, 380001, 0,
|
||||
3, 8, 2, 0,
|
||||
27, 2310, 412,
|
||||
9, 11, 5, 2,
|
||||
0, 0,
|
||||
]);
|
||||
expect(row.blobs[BLOB_COLUMNS.indexOf("schema_version")]).toBe("6");
|
||||
|
||||
const v4 = harness();
|
||||
await worker.fetch(postJson(golden(4)), v4.env);
|
||||
const old = v4.written.find((point) => point.blobs[0] === "model_usage")!;
|
||||
expect(old.doubles).toEqual([
|
||||
12, 1, 0, 418230, 9120, 380001, 0,
|
||||
3, 8, 2, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
]);
|
||||
});
|
||||
|
||||
it("writes install_age to blob12 of session_start rows only", async () => {
|
||||
const { env, written } = harness();
|
||||
await worker.fetch(postJson(golden(6)), env);
|
||||
const start = written.find((point) => point.blobs[0] === "session_start")!;
|
||||
expect(start.blobs[BLOB_COLUMNS.indexOf("session_source")]).toBe("fleet_worker");
|
||||
expect(start.blobs[BLOB_COLUMNS.indexOf("duration_bucket")]).toBe("under_week");
|
||||
const end = written.find((point) => point.blobs[0] === "session_end")!;
|
||||
expect(end.blobs[BLOB_COLUMNS.indexOf("exit_class")]).toBe("unclean");
|
||||
expect(end.blobs[BLOB_COLUMNS.indexOf("duration_bucket")]).toBe("1m_10m");
|
||||
});
|
||||
});
|
||||
@@ -49,6 +49,19 @@ describe("existing-pipeline owner funnel report", () => {
|
||||
expect(buildFunnelReport(input, { now: NOW }).native.seven_day_initialization_cohort.unavailable_reason).toContain("incomplete");
|
||||
});
|
||||
|
||||
it("excludes fleet worker, agent and scripted starts like CI, and accepts v6 model usage", () => {
|
||||
for (const source of ["fleet_worker", "agent", "scripted"]) {
|
||||
const input = receipt();
|
||||
input.native_rows.push({ ...native("session_start", "2026-10-04T00:00:00Z"), session_source: source });
|
||||
input.native_rows.push({ ...native("model_usage", "2026-10-04T00:00:00Z"), schema_version: 6 });
|
||||
const report = buildFunnelReport(input, { now: NOW });
|
||||
expect(report.native.excluded_ci_ids, source).toBe(1);
|
||||
expect(report.native.observed_completed_ids, source).toBe(0);
|
||||
}
|
||||
const input = receipt(); input.native_rows.push({ ...native("model_usage", "2026-10-04T00:00:00Z"), schema_version: 5 });
|
||||
expect(() => buildFunnelReport(input, { now: NOW })).toThrow(/version/);
|
||||
});
|
||||
|
||||
it("does not compute a cohort fraction from sampled native records", () => {
|
||||
const input = receipt(); input.native_rows[0].weight = 4;
|
||||
expect(buildFunnelReport(input, { now: NOW }).native.seven_day_initialization_cohort).toMatchObject({ fraction: null, sampled: true });
|
||||
|
||||
@@ -20,23 +20,27 @@ import {
|
||||
BATCH_MAX_BYTES,
|
||||
BATCH_MAX_EVENTS,
|
||||
COLD_START_BUCKETS,
|
||||
CONTEXT_FIELDS,
|
||||
COUNTER_FIELDS,
|
||||
DURATION_BUCKETS,
|
||||
ENVELOPE_FIELDS,
|
||||
ERROR_FIELDS,
|
||||
EVENT_FIELDS,
|
||||
EVENT_NAMES,
|
||||
EXIT_CLASSES,
|
||||
INSTALL_AGES,
|
||||
INSTALL_KINDS,
|
||||
LIBCS,
|
||||
MAX_BODY_BYTES,
|
||||
OSES,
|
||||
PRODUCT_COUNTER_FIELDS,
|
||||
OPERATIONS_FIELDS,
|
||||
SCHEMA_VERSION,
|
||||
SESSION_SOURCES,
|
||||
SURFACES,
|
||||
TURN_WALL_FIELDS,
|
||||
V6_EVENT_FIELDS as EVENT_FIELDS,
|
||||
V6_EXIT_CLASSES as EXIT_CLASSES,
|
||||
V6_MODEL_COUNTER_FIELDS,
|
||||
V6_NOTICE_VERSION,
|
||||
V6_SCHEMA_VERSION as SCHEMA_VERSION,
|
||||
V6_SESSION_SOURCES as SESSION_SOURCES,
|
||||
} from "../src/schema";
|
||||
import { DOC, goldenBatch } from "./support";
|
||||
|
||||
@@ -168,6 +172,16 @@ describe("the doc and the validator publish the same fields", () => {
|
||||
expect([...documentedKeys(BLOCKS[3])].sort()).toEqual(expected);
|
||||
});
|
||||
|
||||
it("agrees on the model_usage field set, nested objects included", () => {
|
||||
const expected = [
|
||||
...EVENT_FIELDS.model_usage,
|
||||
...V6_MODEL_COUNTER_FIELDS,
|
||||
...TURN_WALL_FIELDS,
|
||||
...CONTEXT_FIELDS,
|
||||
].sort();
|
||||
expect([...documentedKeys(BLOCKS[7])].sort()).toEqual([...new Set(expected)].sort());
|
||||
});
|
||||
|
||||
it("agrees on the panic field set", () => {
|
||||
expect([...documentedKeys(BLOCKS[4])].sort()).toEqual(
|
||||
[...EVENT_FIELDS.panic].sort(),
|
||||
@@ -216,6 +230,7 @@ describe("the doc and the validator publish the same enums", () => {
|
||||
["session source", SESSION_SOURCES],
|
||||
["duration bucket", DURATION_BUCKETS],
|
||||
["exit class", EXIT_CLASSES],
|
||||
["install age", INSTALL_AGES],
|
||||
["cold start bucket", COLD_START_BUCKETS],
|
||||
] as const)("publishes every %s value it accepts", (_label, values) => {
|
||||
for (const value of values) {
|
||||
@@ -232,6 +247,8 @@ describe("the doc and the validator publish the same limits", () => {
|
||||
expect(match).not.toBeNull();
|
||||
expect(Number((match as RegExpMatchArray)[1])).toBe(SCHEMA_VERSION);
|
||||
expect(goldenBatch().schema_version).toBe(1); // preserved legacy golden
|
||||
const notice = DOC.match(/`notice_version\s*=\s*(\d+)`/);
|
||||
expect(Number((notice as RegExpMatchArray)[1])).toBe(V6_NOTICE_VERSION);
|
||||
});
|
||||
|
||||
it("agrees on the per-batch caps", () => {
|
||||
|
||||
+10
@@ -2,6 +2,16 @@
|
||||
|
||||
## 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
@@ -57,6 +57,7 @@ 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
@@ -0,0 +1,276 @@
|
||||
//! 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
|
||||
}
|
||||
+1261
File diff suppressed because it is too large.
Load diff
@@ -59,10 +59,12 @@ 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::*;
|
||||
|
||||
@@ -366,6 +366,14 @@ 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 {
|
||||
|
||||
+360
@@ -0,0 +1,360 @@
|
||||
---
|
||||
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
@@ -0,0 +1,118 @@
|
||||
//! 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
@@ -31,6 +31,7 @@ mod approval;
|
||||
mod artifacts;
|
||||
mod atmosphere;
|
||||
mod attention;
|
||||
mod chart;
|
||||
mod chrome;
|
||||
mod display;
|
||||
mod habitat;
|
||||
@@ -104,6 +105,7 @@ pub fn entries() -> Vec<Entry> {
|
||||
lists::entries(),
|
||||
chrome::entries(),
|
||||
display::entries(),
|
||||
chart::entries(),
|
||||
approval::entries(),
|
||||
motion::entries(),
|
||||
workspace::entries(),
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { DocArticle } from "../_components/doc-article";
|
||||
import { getDocsFirstTime } from "@/lib/i18n/dictionaries";
|
||||
import { buildPageMetadata } from "@/lib/page-meta";
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
const t = getDocsFirstTime(locale);
|
||||
return buildPageMetadata({
|
||||
path: "/docs/first-time",
|
||||
locale,
|
||||
title: t.metaTitle,
|
||||
description: t.metaDescription,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function FirstTimePage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
return <DocArticle t={getDocsFirstTime(locale)} locale={locale} />;
|
||||
}
|
||||
@@ -97,6 +97,30 @@ export default async function InstallPage({ params }: { params: Promise<{ locale
|
||||
<div lang={copyLocale}>
|
||||
{locale === "zh" ? mirrors : null}
|
||||
|
||||
<Block id="install-new-to-terminal" title={t(INSTALL_COPY.newTitle)}>
|
||||
{p(INSTALL_COPY.newLead)}
|
||||
<Channel id="install-new-macos" title={t(INSTALL_COPY.newMacTitle)}>
|
||||
<ol>
|
||||
<li><Rich text={t(INSTALL_COPY.newMac1)} /></li>
|
||||
<li><Rich text={t(INSTALL_COPY.newMac2)} />{code(INSTALL_COMMANDS.shell)}</li>
|
||||
<li><Rich text={t(INSTALL_COPY.newMac3)} /></li>
|
||||
</ol>
|
||||
</Channel>
|
||||
<Channel id="install-new-windows" title={t(INSTALL_COPY.newWinTitle)}>
|
||||
<ol>
|
||||
<li><Rich text={t(INSTALL_COPY.newWin1)} /></li>
|
||||
<li><Rich text={t(INSTALL_COPY.newWin2)} />{code(INSTALL_COMMANDS.windows)}</li>
|
||||
<li><Rich text={t(INSTALL_COPY.newWin3)} /></li>
|
||||
</ol>
|
||||
</Channel>
|
||||
<p>
|
||||
<Link href={`/${locale}/docs/first-time`} className="section-link">
|
||||
{t(INSTALL_COPY.newNext)}
|
||||
<Icon name="arrow-right" className="icon icon-flip" />
|
||||
</Link>
|
||||
</p>
|
||||
</Block>
|
||||
|
||||
<Block id="install-path" title={t(INSTALL_COPY.pathTitle)}>
|
||||
{p(INSTALL_COPY.pathPlaces)}
|
||||
<ul>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
.install-guide ul { list-style: disc; }
|
||||
.install-guide ol { list-style: decimal; }
|
||||
.install-guide li { margin-block: var(--space-1); }
|
||||
.install-guide li > .code { margin-block: var(--space-3); }
|
||||
.install-guide ::marker { color: var(--muted); }
|
||||
.install-guide a:not(.btn) { color: var(--accent); text-decoration: underline; text-decoration-color: color-mix(in srgb, var(--accent) 40%, transparent); text-underline-offset: 0.2em; }
|
||||
.install-guide a:not(.btn):hover { text-decoration-color: var(--accent); }
|
||||
|
||||
@@ -1,4 +1,35 @@
|
||||
[
|
||||
{
|
||||
"id": "first-time",
|
||||
"slug": "first-time",
|
||||
"sources": [
|
||||
{
|
||||
"path": "docs/GUIDE.md",
|
||||
"sha256": "2fd312552cabc519c990071a16111fe251f902104ebd65f22513039d94e3412d",
|
||||
"content": "# Codewhale User Guide\n\nThis guide is for your first hour with Codewhale. It explains the main\nworkflow, the important safety controls, and where to go next when you need a\ncomplete reference.\n\nCodewhale has deeper reference documents for installation, configuration,\nproviders, modes, keybindings, tools, and operations. Use this page as a guided\nwalkthrough, then follow the \"Next\" links when you need every option.\n\n## 1. Welcome to Codewhale\n\nCodewhale is a terminal coding agent. You run it from a workspace, give it a\ntask, and it can use structured tools to inspect files, run commands, edit\ncode, and report back with evidence.\n\nThe important difference from a normal chat model is that Codewhale is built\naround a harness:\n\n- It keeps the active workspace and session visible.\n- It routes each turn through explicit modes and approval rules.\n- It shows tool calls in the transcript instead of hiding the work.\n- It can preserve sessions, fork conversations, and continue later.\n- It can run sub-agents for focused background work.\n\nYou can use Codewhale for small questions:\n\n```text\nExplain the authentication flow in this repository.\n```\n\nYou can also use it for multi-step work:\n\n```text\nFind the failing validation path, propose a fix, and wait for my approval\nbefore editing files.\n```\n\nFor a new repository, start conservatively. Ask Codewhale to explore and plan\nbefore asking it to change files. That gives you a reviewable path and makes it\neasier to catch wrong assumptions early.\n\nNext: [ARCHITECTURE.md](ARCHITECTURE.md) explains the internal harness and\nruntime model.\n\n## 2. First Launch\n\nFor a new macOS or Linux installation, use the official GitHub release.\nThe installer verifies the release checksums and provides the same runtime\nunder the `codewhale` and `codew` command names:\n\n```bash\ncurl -fsSL https://codewhale.net/install.sh | sh\n```\n\nWindows users should choose the matching installer or archive from\n[GitHub Releases](https://github.com/codewhale-hq/CodeWhale/releases/latest).\nFor an existing direct install, use `codewhale update --check`, then\n`codewhale update`. npm and Cargo remain secondary packaging routes; Cargo\nalso supports source builds where a compatible prebuilt is unavailable.\nFor occupied directories, package-managed installs, and PATH setup, follow\n[the installation and migration guide](INSTALL.md#recommended-official-github-releases).\nAndroid/Termux uses its own [preview archive or source-build path](INSTALL.md#android--termux-arm64).\n\nDocker is also available when you want an isolated runtime. The published\nimage is currently 0.10.0; to test current `main`, [build it from source](DOCKER.md#building-locally).\n\n```bash\ndocker volume create codewhale-home\ndocker run --rm -it \\\n -e DEEPSEEK_API_KEY=\"$DEEPSEEK_API_KEY\" \\\n -v codewhale-home:/home/codewhale/.codewhale \\\n -v \"$PWD:/workspace\" \\\n -w /workspace \\\n ghcr.io/hmbown/codewhale:latest\n```\n\nOnce the install directory is on PATH, launch Codewhale from the repository or\ndirectory you want it to work in:\n\n```bash\ncodewhale\n```\n\nFor the default GitHub installer destination, you can use\n`\"$HOME/.local/bin/codewhale\"` until that directory is on PATH.\n\nOn first launch, Codewhale opens the composer, even with no key or route.\nWith no key or route, workspace trust is asked once a provider is connected;\notherwise only when the folder requires a decision. A provider is asked for\nwhen you send your first message: the message stays in the box while you sign\nin or use your own key, and a local model is offered only when a local Ollama\nserver answers. The provider step also has an explicit offline route.\n\nEverything optional stays available after that. Use `/setup` for the\nprogressive setup and repair guide, `/settings` for the full typed editor, and\n`/constitution` when you want your own constitution on top of the bundled one.\nThe localized telemetry choice appears only after the workspace is ready and\ndoes not block the composer.\n\nDeepSeek is the default provider. If you want to configure its key before or\nafter the first launch, the most direct setup path is:\n\n```bash\ncodewhale auth set --provider deepseek\n```\n\nYou can also provide a key through the environment:\n\n```bash\nexport DEEPSEEK_API_KEY=\"your-key\"\ncodewhale\n```\n\nNew Codewhale config is stored under `~/.codewhale/config.toml`. Legacy\n`~/.deepseek/config.toml` files are still supported for users migrating from\nthe old name.\n\nUse `/constitution` to review or change standing guidance. After setup, run a\ndoctor check:\n\n```bash\ncodewhale doctor\n```\n\nUse the JSON form when you need a machine-readable report for an issue:\n\n```bash\ncodewhale doctor --json\n```\n\nBoth forms are offline by default. They report structural configuration and\nliteral unknown/not-probed credential states without loading workspace `.env`\ncredentials, openiLine truncated
|
||||
},
|
||||
{
|
||||
"path": "docs/INSTALL.md",
|
||||
"sha256": "35bc2795284622177f1be7f85544174a4cbd9d2881e52fbdedb6f1262b636815",
|
||||
"content": "# Installing Codewhale\n\n> 阅读简体中文版:[zh_hans/INSTALL.md](zh_hans/INSTALL.md)\n\nCodewhale is an open-source coding agent that runs in your terminal. You give\nit a task (\"fix the failing test\", \"add a CLI flag\"). It reads your repository,\nedits files and runs commands. In the default **Accept Edits** posture it\napplies file edits inside the repository immediately and shows you the diff,\nand asks before each shell command, so commit or stash anything you care about\nfirst. **Ask** (`Shift+Tab` round the dial, or `/config`) asks before every\nedit as well. It works with many model\nproviders. **DeepSeek** is the default.\n\nThe command is `codewhale`. `codew` is a shorter alias for the same program.\n\nThe installation paths were first checked with **v0.10.0** (released 2026-09-22) on a\nfresh **Ubuntu 24.04 x86_64** machine. The dated\n[install receipts](https://github.com/codewhale-hq/Codewhale/blob/37ecdfcc49bc68a9b0d058b97c3946e62c34bd31/docs/install-report/v0.10.0-2026-09-23/RECEIPTS.md) record the commands and output checked at that time. Steps that\ncould not be run on that machine are marked **(untested on this VM: reason)**.\nmacOS, Windows and Android are out of scope, apart from a few notes. A second\npass re-ran the installer, manual-download, archive and npm paths, the no-key\nchecks and zsh completion on **macOS 26.1 (Apple silicon)**; see\n[macOS notes](#macos-notes). Steps that need a model call were not re-run\nthere.\n\nInstall commands that use `latest` resolve to the latest **published** GitHub\nRelease or package. Between releases, `main` may already describe the next\nversion (for example a v0.10.3 source candidate before its tag). A\nprebuilt candidate is available through the official installer only after its\ntag, checksums and release assets exist. Contributors can build `main` now.\n\n## No account needed\n\nCodewhale needs no account. Keys you save with `/provider` or\n`codewhale auth set` stay on your machine; see\n[API keys and providers](#8-api-keys-and-providers). New Codewhale accounts\nare not open, so this guide does not cover account key storage.\n\n## Test and contribute to current source\n\n`latest` selects the latest published release. To test unreleased repairs,\nbuild canonical GitHub `main`, or CNB `main` after verifying its commit matches.\nThe source version number alone does not establish a published package.\n\n```bash\ngit clone --branch main https://github.com/codewhale-hq/CodeWhale.git\ncd CodeWhale\ncargo build --release --locked -p codewhale-cli\ntarget/release/codewhale --version\n```\n\nSee the Cargo section for build prerequisites; use the repository's stable Rust\nselection (declared minimum 1.89). Windows uses `target/release/codewhale.exe`\nand needs MSVC. A development build is separate from a signed release package.\nFor mainland-China downloads and source validation, see [CNB mirror](CNB_MIRROR.md).\nThe [Tencent Lighthouse bootstrap](../scripts/tencent-lighthouse/README.md) is\nbilingual. An npm registry mirror serves the wrapper; native binary downloads\nstill need an available release asset source.\n\n---\n\n## 60-second quickstart (Linux or macOS)\n\n```bash\n# 1. Install. Downloads two checksum-verified binaries into ~/.local/bin (no sudo).\ncurl -fsSL https://codewhale.net/install.sh | sh\n\n# 2. The installer adds ~/.local/bin to your shell profile (it prints what it added and\n# how to undo it; --no-modify-path skips that). Shells that are already open do not\n# reread the profile, so use it in this one now, then check it.\nexport PATH=\"$HOME/.local/bin:$PATH\"; hash -r\ncodewhale --version # -> codewhale 0.10.0 (1be1a703b975)\n\n# 3. Give it a DeepSeek API key (from https://platform.deepseek.com/api_keys).\ncodewhale auth set --provider deepseek # prompts for the key; nothing is echoed\ncodewhale auth status --provider deepseek # \"active source: secret store\"\n\n# 4. Run your first task inside a git repository.\ncd ~/your-project\ncodewhale\n```\n\nIn the TUI, type something concrete:\n\n```text\ncreate a Python file primes.py that prints the first 10 primes, run it, and show me the output\n```\n\nCodewhale writes the file, shows you a diff, then asks **APPROVAL: bash\npython3 primes.py – Do you want to proceed?** Press `y` to allow it once. It\nruns the command and reports the output. Press `Ctrl-D` (with an empty input\nbox) to quit. It prints the command to resume the session later.\n\n> **If nothing happens after you send your first message,** you have not\n> connected a model. v0.10.2 shows `no model connected · run /provider` on its\n> first screen and returns your first message to the composer. Type `/provider`,\n> choose a provider or paste a Custom endpoint, then send it again. If messages say\n> they were saved for later after you connect, quit with Ctrl+D and run `codewhale` again.\n\n---\n\n## Contents\n\n1. [Before you start](#1-before-you-start)\n2. [Install: rLine truncated
|
||||
},
|
||||
{
|
||||
"path": "docs/MODES.md",
|
||||
"sha256": "bfd3ed8cae274f9b72d1805232f41d4342cc29b45b53617a26f06c3776941202",
|
||||
"content": "# Modes and Permission Postures\n\n> 阅读简体中文版:[zh_hans/MODES.md](zh_hans/MODES.md)\n\nCodewhale has three related concepts:\n\n- **TUI mode**: what kind of visible interaction you're in (Plan/Work).\n- **Permission posture**: how aggressively the UI asks before executing tools.\n- **Workflow**: named steps that coordinate sub-agents, with dependencies and\n results, available in any TUI mode.\n\nModel selection is separate. `--model auto` and `/model auto` route each turn to\na concrete model and thinking level; they are not TUI modes and are not on the\npermission dial.\n\nWorkflow runs named steps through the same sub-agent runtime; Fleet manages\ntheir saved roles and model assignments. A workflow adds ordering, result\nhandoffs, gates and a progress view. The active mode and permission posture\nstill control what each step may execute.\n\nBefore assigning steps, query `agent(action=\"roster\")` for the saved Fleet\nmodels and roles. A plan child can select a listed model with `model`, or use\nthe saved `role`/`profile` assignment. An Exact Fleet fixes each member's route.\n\n## TUI Modes\n\nOne dial says how much Codewhale may do without asking. Press `Shift+Tab` to\nstep it: **Ask → Accept Edits → Plan → Auto-Review → Full Access → Ask**. Plan\nis the Plan mode; every other stop is Work with that permission posture. The\nfooter shows the current stop and `Shift+Tab to change`. New installs start on\nAccept Edits when no permission posture is saved. An explicit saved choice,\nincluding Ask from an earlier version, stays in control.\n`Tab` only completes composer menus, commands, file mentions and suggestions;\nit never changes the mode, and never sends or queues composer text (use\n`Enter`).\nEvery change of effective permission writes a `Permissions changed to …` line\nin the transcript.\nPress `Ctrl+T` to cycle reasoning effort.\nRun `/mode` to open the mode picker, or switch directly with `/mode work` or\n`/mode plan`. `operate` and its spellings still parse, as Work, and say that\nOperate was retired, so an older setting or script never errors.\n\n- **Plan**: design-first prompting. The stable primitive names remain familiar, but the runtime centrally refuses file mutation and shell execution. Read-only inspection and policy-allowed research, including deferred Web search/fetch, remain available. When a Plan turn successfully finishes a nonempty response, including a prose-only plan, the TUI asks how to continue: Work (Ask), Work (Accept Edits), Work (Auto-Review), or keep planning. Work sends the exact approved response as a normal follow-up and preserves that turn's existing To-dos, or adds one pending plan item without replacing unrelated work.\n- **Work** (internally `agent`): ordinary multi-step execution. The first-turn toolbox includes `read`, `write`, `edit`, `file_search`, `grep_files`, `bash`, `agent`, `todo_write`, `present`, and `load_skill`, plus `create_goal`, `get_goal`, `update_goal`, and `task_shell_wait` so goal and background-shell controls are available without discovery. The agent calls `create_goal` when a request is a durable objective; the host never infers a goal from wording, and the contract must not tell the model otherwise. Approval, sandbox, repository law, and managed policy decide what may execute.\n- **Operate is retired.** It was Work with an orchestration contract, and that contract now lives in every session's constitution: make a goal of a durable request, keep the plan visible, fan independent work out to parallel sub-agents, and have an independent reviewer try to refute a non-trivial change. A saved `default_mode = \"operate\"` loads as Work and says so once; a Runtime API request with `mode: \"operate\"` runs as `agent` with the default posture (Accept Edits) and logs one notice per process, never an error. Agents forked from an older Operate conversation do not inherit its contract. Multi-agent work stays on `agent`, `workflow` and `/fleet` in every mode.\n\n`Act` and `/mode act` remain compatibility aliases for Work. Saved settings\nstill normalize to the internal value `agent`.\n\n### Tool availability by mode\n\n| Tool family | Plan | Work |\n|:---|:---:|:---:|\n| `read` and policy-allowed deferred research tools | yes | yes |\n| `write` and `edit` | visible names; execution denied | approval- and policy-gated |\n| `bash` | visible name; execution denied | approval- and policy-gated; delegation is preferred when parallelism or isolation helps |\n| `agent` | yes, subject to child-depth authority | yes, subject to child-depth authority |\n| Deferred native, MCP, and plugin tools | discoverable through `tool_search` when policy permits | same |\n| Paid or external-service tools | follows permission posture | follows permission posture |\n| Access outside the workspace root | explicit trusted paths only | only through trusted paths or trust mode; fleet profiles never widen it |\n\nMode changes scheduling emphasLine truncated
|
||||
},
|
||||
{
|
||||
"path": "docs/SANDBOX.md",
|
||||
"sha256": "1d765dc9b89ff76aedd512a49983058fae999e25fc5dd84868f38f268d00b0b1",
|
||||
"content": "# Sandbox threat model\n\n> 阅读简体中文版:[zh_hans/SANDBOX.md](zh_hans/SANDBOX.md)。\n\nCodewhale can launch shell commands proposed by a model. Approval policy,\nworkspace-aware tools, and an operating-system command wrapper are separate\ncontrols: an approval is not a sandbox, and selecting `workspace-write` does\nnot prove that the current platform has an OS wrapper available.\n\nThis document describes only behavior wired into the command execution path.\nSee [Authorization order](AUTHORIZATION_ORDER.md) for the policy layers that\nrun before execution reaches this boundary.\n\n## Platform overview\n\n| Mechanism | Platform | Selection | What Codewhale reports |\n|---|---|---|---|\n| Seatbelt (`sandbox-exec`) | macOS | Automatic when the runtime probe succeeds | `macos-seatbelt` |\n| Bubblewrap (`/usr/bin/bwrap`) | Linux | Default when installed and a wrapped probe run succeeds; `prefer_bwrap = false` opts out | `linux-bwrap` |\n| No OS wrapper | Linux without usable bwrap, or opted out | `prefer_bwrap = false`, or bwrap absent/unusable | `none` |\n| No OS wrapper | Windows | Current implementation | `none` |\n| OpenSandbox-compatible service | Any supported host | `sandbox_backend = \"opensandbox\"` | External execution path |\n\nThe repository also contains a future Windows helper contract. It is not\nwired into child-command launch, so Codewhale does not advertise it as an\nactive sandbox. Source-only sandbox code is not evidence that a command was\nrestricted.\n\n## macOS: Seatbelt\n\nCodewhale probes `/usr/bin/sandbox-exec` by running a minimal profile. When the\nprobe succeeds and the selected `SandboxPolicy` requests a sandbox, the child\ncommand is wrapped with a generated Seatbelt profile.\n\nThe profile can provide:\n\n- broad filesystem reads;\n- writes limited by the selected policy, including the workspace and specific\n runtime/cache paths needed by supported tools;\n- network access only when the policy enables it. A `read-only` policy with\n network (the agent `read_only: true` shell mode) gets outbound IPv4 and the\n system DNS resolver only. IPv6 and the host's own addresses, loopback\n included, are denied, except the port of a local proxy named in\n `HTTP_PROXY`, `HTTPS_PROXY` or `ALL_PROXY`, and other local sockets stay\n denied.\n\nIf the probe fails or `sandbox-exec` is unavailable, Codewhale reports no OS\nsandbox and launches the command without a Seatbelt wrapper. It does not set a\nSeatbelt marker on that fallback.\n\n## Linux: default bubblewrap\n\nLinux command sandboxing is on by default whenever bubblewrap works. Set the\ntop-level configuration key to opt out:\n\n```toml\nprefer_bwrap = false\n```\n\nCodewhale selects bubblewrap only when `/usr/bin/bwrap` is a regular\nexecutable file AND a wrapped probe run proves it can create its namespaces\non this host — the exec bit alone lies where user namespaces are restricted\n(e.g. Ubuntu 24.04 with `kernel.apparmor_restrict_unprivileged_userns`),\nwhere every wrapped command would fail rather than run unsandboxed. The\nwrapper derives its mounts and network namespace from the resolved\n`SandboxPolicy`:\n\n```text\n/usr/bin/bwrap \\\n --unshare-all \\\n [--share-net] \\\n --ro-bind / / \\\n --dev /dev \\\n --proc /proc \\\n --tmpfs /tmp \\\n [--dev-bind <device-root> <device-root> ...] \\\n --bind <writable-root> <writable-root> ... \\\n --ro-bind <protected-descendant> <protected-descendant> ... \\\n [--ro-bind <extra-ro-root> <extra-ro-root> ...] \\\n --chdir <cwd> \\\n -- <program> <args>\n```\n\nThe sandbox always gets a private `/dev` (fresh device nodes, so `>/dev/null`\nworks), a private `/proc`, and a tmpfs `/tmp` (#5410). A `workspace-write`\npolicy then binds the host `/tmp` (and `TMPDIR`) over that tmpfs unless it\nexcludes them, so by default a sandboxed command shares the host's `/tmp`,\nwritable, with the usual sticky-bit rules. Two optional top-level\nconfig keys extend the mounts: `bwrap_ro_roots` (extra host paths bind-mounted\nread-only, applied last so they can narrow a policy-writable path) and\n`bwrap_dev_roots` (host character/block device nodes bind-mounted read-write;\ndirectories are never honored). Missing paths are skipped silently.\n\nThat gives the child a read-only root view. For `workspace-write`, every safe,\nexisting policy root is mounted read-write: the working directory, configured\nadditional roots, `/tmp` and `TMPDIR` unless excluded, and verified Git\nworktree metadata roots. Existing `.codewhale` and `.deepseek` descendants are\nremounted read-only after their writable parent. Missing paths, non-directory\npaths, and `/` are not promoted to writable mounts.\n\nFor `read-only`, there are no writable binds, so the working directory remains\ninside the read-only root view. `--unshare-all` isolates the network namespace\nby default. Codewhale adds `--share-net` only when the policy's\n`network_access` is true. The agent `read_only: true` sheLine truncated
|
||||
},
|
||||
{
|
||||
"path": "docs/KEYBINDINGS.md",
|
||||
"sha256": "5b3a71595db86ed30eb79675c2b934d60dce897f0202142794be849537a36da9",
|
||||
"content": "# Keybindings\n\n> 阅读简体中文版:[zh_hans/KEYBINDINGS.md](zh_hans/KEYBINDINGS.md)\n\nThis is the source-of-truth catalog of every keyboard shortcut the TUI recognizes. Bindings are grouped by **context** — the focus or modal state they fire in. A binding listed under \"Composer\" only takes effect when the composer is focused; one under \"Transcript\" only when the transcript has focus; and so on.\n\nGlobal key chords are not yet user-configurable — tracked for a future release (#436, #437). Hotbar slot actions are configurable with `[[hotbar]]` and `/hotbar`; the Hotbar activation chord remains `Alt-1` through `Alt-8`.\n\n## Global (any context)\n\n| Chord | Action |\n|----------------------|---------------------------------------------------------------|\n| `/help`, `F1` or `Ctrl-/` | Toggle the help overlay. `/help` is what the chrome advertises: it is the one route every terminal delivers, where `F1` is eaten by tmux and some emulators and `Ctrl-/` encoding varies |\n| `F2` | Toggle the typed Settings editor |\n| `F3` | Open the provider/model picker (same as `/provider`) |\n| `Ctrl-K` | Open the command palette (slash-command finder) |\n| `Ctrl-C` | Cancel current turn / dismiss modal / clear a non-empty draft (recoverable — see `Ctrl-Z`) / arm-then-confirm quit |\n| `Ctrl-B` | Release the current shell wait so the turn can continue; foreground commands move into `/jobs`, and background tasks keep running. Use `/jobs` or `Bash` with `action: \"wait\"` to inspect output |\n| `Ctrl-D` | Quit (only when the composer is empty) |\n| `Tab` | Complete a slash command, `@` file mention or prompt suggestion. Tab never changes the mode |\n| `Shift+Tab` | Step the permission dial: Ask → Accept Edits → Plan → Auto-Review → Full Access → Ask. Plan is the Plan mode; every other stop is Work with that posture. Live regardless of composer contents or whether a turn is running (suppressed only while a modal other than Config is open). During a turn the change, including into or out of Plan, applies at the agent's next step; a call you already approved runs only if the new posture is equal or broader. Model, thinking level and provider stay locked until the turn ends |\n| `Ctrl-T` | Cycle reasoning effort for the active model. Walks the same ladder as `/model` and `/effort` (catalog or documented route dialect). Always-thinking models omit `off`; Grok 4.6 includes `xhigh`. |\n| `Ctrl-Shift-T` | Toggle live transcript overlay (sticky-tail auto-scroll) |\n| `Ctrl-R` | Open the resume-session picker |\n| `Ctrl-L` | Compact the conversation context (status line shows progress; no-op while a compaction is already running) |\n| `Ctrl-O` | Open the reasoning detail for the selected or current turn, regardless of composer contents |\n| `Ctrl-Alt-O` | Open the whole-turn Turn Inspector, regardless of composer contents |\n| `Alt-V` / `Option-V` (macOS) | Open the details pager for the selected tool/sub-agent card; with nothing selected, the newest visible error, else the first visible card with details, else the most recent one. Click a card first to keep Alt-V on it while newer cards arrive. Terminals that emit the legacy Option-V glyph are also handled |\n| `Ctrl-Shift-E` / `Cmd-Shift-E` | Toggle the file tree in the workbar |\n| `Ctrl-]` or `Ctrl-Tab` | Cycle the pet view while the pet main view is showing; otherwise cycle the work dock: TODO → Agents → Jobs → Background. `Ctrl-]` is portable; `Ctrl-Tab` requires the kitty keyboard protocol. Terminal.app uses it to switch terminal tabs |\n| `Ctrl-Shift-Tab` | Cycle the pet view or work dock backwards where the terminal sends the kitty keyboard chord. Terminal.app uses it to switch terminal tabs |\n| `Alt-G` / `Alt-Shift-G` | Scroll transcript to top / bottom when the composer is empty |\n| `Alt-1`-`Alt-8` | Dispatch Hotbar slots 1-8 when no modal or inline picker is open |\n| `Alt-!` / `Alt-@` / `Alt-#` / `Alt-$` | Select the workbar panel: Tasks / Agents / Context / Files |\n| `Ctrl-Alt-0` | Toggle the workbar off / back to the bottom placement |\n| `Alt-L` | Open the pager for the last message (composer empty) |\n| `Alt-P` / `Alt-A` / `Alt-Y` | Jump to Plan / Work, or request Full Access (`Alt-Y` is the legacy permission channel — Work + Full Access — not a separate mode; it honors a locked approval policy) |\n| `Ctrl-X` (Activity workbar) | Cancel all running background shell jobs |\n| `Esc` | Close topmost modal · cancel slash menu · dismiss toast. During Line truncated
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "install",
|
||||
"slug": "install",
|
||||
@@ -334,8 +365,8 @@
|
||||
"sources": [
|
||||
{
|
||||
"path": "docs/RUNTIME_API.md",
|
||||
"sha256": "c4f348c27735a7df47a79faf11a6d5e3284a1e67a76c9372e76a8809236af45a",
|
||||
"content": "# Codewhale Engine Runtime API & Integration Contract\n\n> 阅读简体中文版:[zh_hans/RUNTIME_API.md](zh_hans/RUNTIME_API.md)。\n\n`codewhale app-server --http` exposes the [Codewhale Engine](ARCHITECTURE.md) through\nits canonical local Runtime API and control plane.\nLocal SDKs, mobile/remote-control clients, and editor integrations talk to it\ninstead of screen-scraping terminal output. It serves the full HTTP/SSE runtime\nAPI (`/v1/*`), a JSON-RPC control transport over stdio, and the phone-friendly\nmobile page. `codewhale doctor --json` provides machine-readable health, and\n`codewhale serve --acp` speaks the Agent Client Protocol over stdio for editors\nsuch as Zed.\n\n`codewhale serve --http` / `serve --mobile` remain as **compatibility aliases**\nfor `codewhale app-server --http` / `--mobile`; both launch the identical\nserver. Desktop, web, SDK and custom integrations should target\n`app-server --http`. `--stdio` and `--socket` expose compatibility controls.\nBare `app-server` selects initialized JSONL control over stdio. Local stdio,\nsocket and ACP owners do not bind a hidden TCP listener. Explicit `--http`\nretains HTTP/SSE and compatibility routes on the same Runtime owner.\n\n`codewhale exec` is the separate one-shot headless worker path (stream-json,\nfleet worker subprocess, CI primitive). It is not part of this API, but it\nshares the same runtime, provider/model resolution, permission profiles, and\nevent vocabulary.\n\nThis document is the stable integration contract for native workbench\napplications (and other local supervisors) that embed the Codewhale Engine.\n\n## Architecture\n\n```\nlocal supervisor / SDK / automation harness\n │\n ├─ codewhale app-server --http → HTTP/SSE runtime API (/v1/*) [canonical]\n ├─ codewhale app-server --mobile → runtime API + mobile control page\n ├─ codewhale app-server --stdio → JSON-RPC control transport over stdio\n ├─ codewhale app-server --socket → same JSON-RPC over a unix domain socket (desktop daemon)\n ├─ codewhale doctor --json → machine-readable health & capability\n ├─ codewhale serve --acp → ACP stdio agent for editors such as Zed\n ├─ codewhale serve --mcp → MCP stdio server\n ├─ codewhale serve --http/--mobile → legacy aliases for `app-server --http/--mobile`\n └─ codewhale exec [args] → one-shot headless worker (stream-json)\n```\n\nThe engine runs as a local-only process. All APIs bind to `localhost` by\ndefault. No hosted relay, no provider-token custody, no secret leakage.\n\nFor the read-only record of what a thread or turn did, see\n[`docs/RECEIPTS.md`](RECEIPTS.md): `codewhale receipts` on the CLI and the\n`/receipt` routes under **Threads** below.\n\n## Runtime API entrypoints\n\n| Entry | Transport | Use |\n|---|---|---|\n| `codewhale web [--port 7878]` | HTTP/SSE on `127.0.0.1:7878` + embedded client | First-class loopback-only browser client; opens the default browser |\n| `codewhale app-server --http` | HTTP/SSE on `127.0.0.1:7878` | Full `/v1/*` runtime API (canonical) |\n| `codewhale app-server --mobile` | HTTP/SSE on loopback + `/mobile` | Runtime API + local mobile control page |\n| `codewhale app-server --stdio` | Initialized JSONL over stdio | Compatibility controls and explicit tool-free turns through the held manager; no hidden TCP listener or full Codex interaction channel. |\n| `codewhale app-server --socket [--socket-path P]` | RPC text messages over WebSocket on a `0600` Unix domain socket (macOS/Linux) or an owner-only named pipe (Windows) | Desktop daemon: multi-client, kernel peer identity checked, `daemon/attach` claim handshake. Windows native-app qualification remains open. |\n| `codewhale app-server` | Initialized JSONL over stdio | Same selection as `--stdio`; HTTP requires explicit `--http`. |\n| `codewhale serve --http` / `--mobile` | same server as `app-server --http`/`--mobile` | Compatibility aliases |\n\n`app-server --http` and `--mobile` launch the same mature runtime API server\nhistorically reached through `serve --http` — no routes or behavior changed, so\nevery endpoint documented below is identical across both entrypoints. The\nruntime API token is read from `--auth-token`, then `CODEWHALE_RUNTIME_TOKEN`,\nthen `DEEPSEEK_RUNTIME_TOKEN`; use `--insecure-no-auth` only with a loopback\nbind. The `serve` compatibility aliases keep their `--insecure` flag.\nBare `app-server` does not open an HTTP port. Selected attached HTTP listeners\nretain their explicit bearer and non-loopback security checks.\n\nInitialized stdio/private-socket controls support `thread/loaded/list` with\n`{cursor?: string | null, limit?: number | null}`. The result is\n`{data: string[], nextCursor: string | null}` from the held manager's loaded\nengine snapshot, including idle and archived engines still in memory. It dLine truncated
|
||||
"sha256": "6f3b8885978ef2e1b33d0fdf66ad6ccf5c25464c1951b59658aec00745390db2",
|
||||
"content": "# Codewhale Engine Runtime API & Integration Contract\n\n> 阅读简体中文版:[zh_hans/RUNTIME_API.md](zh_hans/RUNTIME_API.md)。\n\n`codewhale app-server --http` exposes the [Codewhale Engine](ARCHITECTURE.md) through\nits canonical local Runtime API and control plane.\nLocal SDKs, mobile/remote-control clients, and editor integrations talk to it\ninstead of screen-scraping terminal output. It serves the full HTTP/SSE runtime\nAPI (`/v1/*`), a JSON-RPC control transport over stdio, and the phone-friendly\nmobile page. `codewhale doctor --json` provides machine-readable health, and\n`codewhale serve --acp` speaks the Agent Client Protocol over stdio for editors\nsuch as Zed.\n\n`codewhale serve --http` / `serve --mobile` remain as **compatibility aliases**\nfor `codewhale app-server --http` / `--mobile`; both launch the identical\nserver. Desktop, web, SDK and custom integrations should target\n`app-server --http`. `--stdio` and `--socket` expose compatibility controls.\nBare `app-server` selects initialized JSONL control over stdio. Local stdio,\nsocket and ACP owners do not bind a hidden TCP listener. Explicit `--http`\nretains HTTP/SSE and compatibility routes on the same Runtime owner.\n\n`codewhale exec` is the separate one-shot headless worker path (stream-json,\nfleet worker subprocess, CI primitive). It is not part of this API, but it\nshares the same runtime, provider/model resolution, permission profiles, and\nevent vocabulary.\n\nThis document is the stable integration contract for native workbench\napplications (and other local supervisors) that embed the Codewhale Engine.\n\n## Architecture\n\n```\nlocal supervisor / SDK / automation harness\n │\n ├─ codewhale app-server --http → HTTP/SSE runtime API (/v1/*) [canonical]\n ├─ codewhale app-server --mobile → runtime API + mobile control page\n ├─ codewhale app-server --stdio → JSON-RPC control transport over stdio\n ├─ codewhale app-server --socket → same JSON-RPC over a unix domain socket (desktop daemon)\n ├─ codewhale doctor --json → machine-readable health & capability\n ├─ codewhale serve --acp → ACP stdio agent for editors such as Zed\n ├─ codewhale serve --mcp → MCP stdio server\n ├─ codewhale serve --http/--mobile → legacy aliases for `app-server --http/--mobile`\n └─ codewhale exec [args] → one-shot headless worker (stream-json)\n```\n\nThe engine runs as a local-only process. All APIs bind to `localhost` by\ndefault. No hosted relay, no provider-token custody, no secret leakage.\n\nFor the read-only record of what a thread or turn did, see\n[`docs/RECEIPTS.md`](RECEIPTS.md): `codewhale receipts` on the CLI and the\n`/receipt` routes under **Threads** below.\n\n## Runtime API entrypoints\n\n| Entry | Transport | Use |\n|---|---|---|\n| `codewhale web [--port 7878]` | HTTP/SSE on `127.0.0.1:7878` + embedded client | First-class loopback-only browser client; opens the default browser |\n| `codewhale app-server --http` | HTTP/SSE on `127.0.0.1:7878` | Full `/v1/*` runtime API (canonical) |\n| `codewhale app-server --mobile` | HTTP/SSE on loopback + `/mobile` | Runtime API + local mobile control page |\n| `codewhale app-server --stdio` | Initialized JSONL over stdio | Compatibility controls and explicit tool-free turns through the held manager; no hidden TCP listener or full Codex interaction channel. |\n| `codewhale app-server --socket [--socket-path P]` | RPC text messages over WebSocket on a `0600` Unix domain socket (macOS/Linux) or an owner-only named pipe (Windows) | Desktop daemon: multi-client, kernel peer identity checked, `daemon/attach` claim handshake. Windows native-app qualification remains open. |\n| `codewhale app-server` | Initialized JSONL over stdio | Same selection as `--stdio`; HTTP requires explicit `--http`. |\n| `codewhale serve --http` / `--mobile` | same server as `app-server --http`/`--mobile` | Compatibility aliases |\n\n`app-server --http` and `--mobile` launch the same mature runtime API server\nhistorically reached through `serve --http` — no routes or behavior changed, so\nevery endpoint documented below is identical across both entrypoints. The\nruntime API token is read from `--auth-token`, then `CODEWHALE_RUNTIME_TOKEN`,\nthen `DEEPSEEK_RUNTIME_TOKEN`; use `--insecure-no-auth` only with a loopback\nbind. The `serve` compatibility aliases keep their `--insecure` flag.\nBare `app-server` does not open an HTTP port. Selected attached HTTP listeners\nretain their explicit bearer and non-loopback security checks.\n\nInitialized stdio/private-socket controls support `thread/loaded/list` with\n`{cursor?: string | null, limit?: number | null}`. The result is\n`{data: string[], nextCursor: string | null}` from the held manager's loaded\nengine snapshot, including idle and archived engines still in memory. It dLine truncated
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -387,8 +418,8 @@
|
||||
},
|
||||
{
|
||||
"path": "docs/CONTRIBUTORS.md",
|
||||
"sha256": "bedd1cc62969108856f75d823d13bde9f7217a936d4b0e7af3d2e6232cad5770",
|
||||
"content": "# Contributors\n\nCodewhale is built in the open with a growing community of contributors. Every\nissue report and pull request is real project work — welcome at any experience\nlevel. This is the full per-PR contributor record, grouped into release/time\nbands with the newest band first so it stays scannable. Expand any band to see\neveryone.\n\nFor the live list, see the\n[GitHub contributors page](https://github.com/codewhale-hq/CodeWhale/graphs/contributors),\n[`AUTHOR_MAP`](https://github.com/codewhale-hq/CodeWhale/blob/main/.github/AUTHOR_MAP),\nand [CHANGELOG.md](../CHANGELOG.md).\n\n<!-- contributor-wall:start -->\n## Everyone\n\n[](#everyone)\n\n433 people have code, co-authored commits, carried-forward pull requests or credited reports in Codewhale. GitHub's contributor list shows only commit authors whose email is linked to their account, so it counts 212 of them. If your commits are missing there, add your commit email to your GitHub account and they will link.\n\n[0gl20shk0sbt36](https://github.com/0gl20shk0sbt36) · [1Git2Clone](https://github.com/1Git2Clone) · [20bytes](https://github.com/20bytes) · [47Cid](https://github.com/47Cid) · [7jrxt42BxFZo4iAnN4CX](https://github.com/7jrxt42BxFZo4iAnN4CX) · [8sero](https://github.com/8sero) · [A-Corner](https://github.com/A-Corner) · [aboimpinto](https://github.com/aboimpinto) · [Aboysky](https://github.com/Aboysky) · [adity982](https://github.com/adity982) · [AdityaVG13](https://github.com/AdityaVG13) · [AGSaturn](https://github.com/AGSaturn) · [ai-dengwsh](https://github.com/ai-dengwsh) · [aiapienthusiast](https://github.com/aiapienthusiast) · [Aitensa](https://github.com/Aitensa) · [AiurArtanis](https://github.com/AiurArtanis) · [alitvak69](https://github.com/alitvak69) · [all-lopezg](https://github.com/all-lopezg) · [alvin1](https://github.com/alvin1) · [amuthantamil](https://github.com/amuthantamil) · [AnaheimEX](https://github.com/AnaheimEX) · [Andrea-Bruno](https://github.com/Andrea-Bruno) · [Angel-Hair](https://github.com/Angel-Hair) · [angziii](https://github.com/angziii) · [AnonymousUser443](https://github.com/AnonymousUser443) · [antarikshraya](https://github.com/antarikshraya) · [Anyexyz](https://github.com/Anyexyz) · [Apeiron0w0](https://github.com/Apeiron0w0) · [aqilaziz](https://github.com/aqilaziz) · [AresNing](https://github.com/AresNing) · [ArronAI007](https://github.com/ArronAI007) · [Artenx](https://github.com/Artenx) · [asdfg314284230](https://github.com/asdfg314284230) · [Asteria1028](https://github.com/Asteria1028) · [asto18089](https://github.com/asto18089) · [atmosuwiryo](https://github.com/atmosuwiryo) · [axobase001](https://github.com/axobase001) · [aznikline](https://github.com/aznikline) · [baendlorel](https://github.com/baendlorel) · [banqii](https://github.com/banqii) · [Beltran12138](https://github.com/Beltran12138) · [bevis-wong](https://github.com/bevis-wong) · [BH8GCJ](https://github.com/BH8GCJ) · [BigBenLabs](https://github.com/BigBenLabs) · [Biilow](https://github.com/Biilow) · [bistack](https://github.com/bistack) · [BrathonBai](https://github.com/BrathonBai) · [bruce6135](https://github.com/bruce6135) · [BryonGo](https://github.com/BryonGo) · [buiducnhat](https://github.com/buiducnhat) · [buko](https://github.com/buko) · [BX166](https://github.com/BX166) · [c020627](https://github.com/c020627) · [cacdcaecawae](https://github.com/cacdcaecawae) · [caeserchen](https://github.com/caeserchen) · [caiyilian](https://github.com/caiyilian) · [CCChisato](https://github.com/CCChisato) · [ccomma](https://github.com/ccomma) · [cenab](https://github.com/cenab) · [ChaceLyee2101](https://github.com/ChaceLyee2101) · [chatman-media](https://github.com/chatman-media) · [chennest](https://github.com/chennest) · [chinaqy110](https://github.com/chinaqy110) · [chnjames](https://github.com/chnjames) · [chuntseevolving](https://github.com/chuntseevolving) · [ci4ic4](https://github.com/ci4ic4) · [cian-lien](https://github.com/cian-lien) · [cmdcorp6534](https://github.com/cmdcorp6534) · [cmyyy](https://github.com/cmyyy) · [codepgq](https://github.com/codepgq) · [Colorful-glassblock](https://github.com/Colorful-glassblock) · [ComeFromTheMars](https://github.com/ComeFromTheMars) · [CrepuscularIRIS](https://github.com/CrepuscularIRIS) · [ct-jaryn](https://github.com/ct-jaryn) · [ctxyao](https://github.com/ctxyao) · [cy2311](https://github.com/cy2311) · [cyq1017](https://github.com/cyq1017) · [czf0718](https://github.com/czf0718) · [dajiaohuang](https://github.com/dajiaohuang) · [DarrellThomas](https://github.com/DarrellThomas) · [ddlw](https://github.com/ddlw) · [demian-welt](https://github.com/demian-welt) · [Dev-next-gen](https://github.com/Dev-next-gen) · [dfwqdyl-ui](https://github.com/dfwqdyl-ui) · [DI-HUO-MING-YI](https://github.com/DI-HUO-MING-YI) · [DieMoe233](https://github.Line truncated
|
||||
"sha256": "4368939ea860f6db9484fd86e613574b72eca71361f08a1130ec87c5f5c66c78",
|
||||
"content": "# Contributors\n\nCodewhale is built in the open with a growing community of contributors. Every\nissue report and pull request is real project work — welcome at any experience\nlevel. This is the full per-PR contributor record, grouped into release/time\nbands with the newest band first so it stays scannable. Expand any band to see\neveryone.\n\nFor the live list, see the\n[GitHub contributors page](https://github.com/codewhale-hq/CodeWhale/graphs/contributors),\n[`AUTHOR_MAP`](https://github.com/codewhale-hq/CodeWhale/blob/main/.github/AUTHOR_MAP),\nand [CHANGELOG.md](../CHANGELOG.md).\n\n<!-- contributor-wall:start -->\n## Everyone\n\n[](#everyone)\n\n433 people have code, co-authored commits, carried-forward pull requests or credited reports in Codewhale. GitHub's contributor list shows only commit authors whose email is linked to their account, so it counts 212 of them. If your commits are missing there, add your commit email to your GitHub account and they will link.\n\n[0gl20shk0sbt36](https://github.com/0gl20shk0sbt36) · [1Git2Clone](https://github.com/1Git2Clone) · [20bytes](https://github.com/20bytes) · [47Cid](https://github.com/47Cid) · [7jrxt42BxFZo4iAnN4CX](https://github.com/7jrxt42BxFZo4iAnN4CX) · [8sero](https://github.com/8sero) · [A-Corner](https://github.com/A-Corner) · [aboimpinto](https://github.com/aboimpinto) · [Aboysky](https://github.com/Aboysky) · [adity982](https://github.com/adity982) · [AdityaVG13](https://github.com/AdityaVG13) · [AGSaturn](https://github.com/AGSaturn) · [ai-dengwsh](https://github.com/ai-dengwsh) · [aiapienthusiast](https://github.com/aiapienthusiast) · [Aitensa](https://github.com/Aitensa) · [AiurArtanis](https://github.com/AiurArtanis) · [alitvak69](https://github.com/alitvak69) · [all-lopezg](https://github.com/all-lopezg) · [alvin1](https://github.com/alvin1) · [amuthantamil](https://github.com/amuthantamil) · [AnaheimEX](https://github.com/AnaheimEX) · [Andrea-Bruno](https://github.com/Andrea-Bruno) · [Angel-Hair](https://github.com/Angel-Hair) · [angziii](https://github.com/angziii) · [AnonymousUser443](https://github.com/AnonymousUser443) · [antarikshraya](https://github.com/antarikshraya) · [Anyexyz](https://github.com/Anyexyz) · [Apeiron0w0](https://github.com/Apeiron0w0) · [aqilaziz](https://github.com/aqilaziz) · [AresNing](https://github.com/AresNing) · [ArronAI007](https://github.com/ArronAI007) · [Artenx](https://github.com/Artenx) · [asdfg314284230](https://github.com/asdfg314284230) · [Asteria1028](https://github.com/Asteria1028) · [asto18089](https://github.com/asto18089) · [atmosuwiryo](https://github.com/atmosuwiryo) · [axobase001](https://github.com/axobase001) · [aznikline](https://github.com/aznikline) · [baendlorel](https://github.com/baendlorel) · [banqii](https://github.com/banqii) · [Beltran12138](https://github.com/Beltran12138) · [bevis-wong](https://github.com/bevis-wong) · [BH8GCJ](https://github.com/BH8GCJ) · [BigBenLabs](https://github.com/BigBenLabs) · [Biilow](https://github.com/Biilow) · [bistack](https://github.com/bistack) · [BrathonBai](https://github.com/BrathonBai) · [bruce6135](https://github.com/bruce6135) · [BryonGo](https://github.com/BryonGo) · [buiducnhat](https://github.com/buiducnhat) · [buko](https://github.com/buko) · [BX166](https://github.com/BX166) · [c020627](https://github.com/c020627) · [cacdcaecawae](https://github.com/cacdcaecawae) · [caeserchen](https://github.com/caeserchen) · [caiyilian](https://github.com/caiyilian) · [CCChisato](https://github.com/CCChisato) · [ccomma](https://github.com/ccomma) · [cenab](https://github.com/cenab) · [ChaceLyee2101](https://github.com/ChaceLyee2101) · [chatman-media](https://github.com/chatman-media) · [chennest](https://github.com/chennest) · [chinaqy110](https://github.com/chinaqy110) · [chnjames](https://github.com/chnjames) · [chuntseevolving](https://github.com/chuntseevolving) · [ci4ic4](https://github.com/ci4ic4) · [cian-lien](https://github.com/cian-lien) · [cmdcorp6534](https://github.com/cmdcorp6534) · [cmyyy](https://github.com/cmyyy) · [codepgq](https://github.com/codepgq) · [Colorful-glassblock](https://github.com/Colorful-glassblock) · [ComeFromTheMars](https://github.com/ComeFromTheMars) · [CrepuscularIRIS](https://github.com/CrepuscularIRIS) · [ct-jaryn](https://github.com/ct-jaryn) · [ctxyao](https://github.com/ctxyao) · [cy2311](https://github.com/cy2311) · [cyq1017](https://github.com/cyq1017) · [czf0718](https://github.com/czf0718) · [dajiaohuang](https://github.com/dajiaohuang) · [DarrellThomas](https://github.com/DarrellThomas) · [ddlw](https://github.com/ddlw) · [demian-welt](https://github.com/demian-welt) · [Dev-next-gen](https://github.com/Dev-next-gen) · [dfwqdyl-ui](https://github.com/dfwqdyl-ui) · [DI-HUO-MING-YI](https://github.com/DI-HUO-MING-YI) · [DieMoe233](https://github.Line truncated
|
||||
},
|
||||
{
|
||||
"path": "docs/RELEASE_CHECKLIST.md",
|
||||
@@ -403,8 +434,8 @@
|
||||
"sources": [
|
||||
{
|
||||
"path": "docs/RUNTIME_API.md",
|
||||
"sha256": "c4f348c27735a7df47a79faf11a6d5e3284a1e67a76c9372e76a8809236af45a",
|
||||
"content": "# Codewhale Engine Runtime API & Integration Contract\n\n> 阅读简体中文版:[zh_hans/RUNTIME_API.md](zh_hans/RUNTIME_API.md)。\n\n`codewhale app-server --http` exposes the [Codewhale Engine](ARCHITECTURE.md) through\nits canonical local Runtime API and control plane.\nLocal SDKs, mobile/remote-control clients, and editor integrations talk to it\ninstead of screen-scraping terminal output. It serves the full HTTP/SSE runtime\nAPI (`/v1/*`), a JSON-RPC control transport over stdio, and the phone-friendly\nmobile page. `codewhale doctor --json` provides machine-readable health, and\n`codewhale serve --acp` speaks the Agent Client Protocol over stdio for editors\nsuch as Zed.\n\n`codewhale serve --http` / `serve --mobile` remain as **compatibility aliases**\nfor `codewhale app-server --http` / `--mobile`; both launch the identical\nserver. Desktop, web, SDK and custom integrations should target\n`app-server --http`. `--stdio` and `--socket` expose compatibility controls.\nBare `app-server` selects initialized JSONL control over stdio. Local stdio,\nsocket and ACP owners do not bind a hidden TCP listener. Explicit `--http`\nretains HTTP/SSE and compatibility routes on the same Runtime owner.\n\n`codewhale exec` is the separate one-shot headless worker path (stream-json,\nfleet worker subprocess, CI primitive). It is not part of this API, but it\nshares the same runtime, provider/model resolution, permission profiles, and\nevent vocabulary.\n\nThis document is the stable integration contract for native workbench\napplications (and other local supervisors) that embed the Codewhale Engine.\n\n## Architecture\n\n```\nlocal supervisor / SDK / automation harness\n │\n ├─ codewhale app-server --http → HTTP/SSE runtime API (/v1/*) [canonical]\n ├─ codewhale app-server --mobile → runtime API + mobile control page\n ├─ codewhale app-server --stdio → JSON-RPC control transport over stdio\n ├─ codewhale app-server --socket → same JSON-RPC over a unix domain socket (desktop daemon)\n ├─ codewhale doctor --json → machine-readable health & capability\n ├─ codewhale serve --acp → ACP stdio agent for editors such as Zed\n ├─ codewhale serve --mcp → MCP stdio server\n ├─ codewhale serve --http/--mobile → legacy aliases for `app-server --http/--mobile`\n └─ codewhale exec [args] → one-shot headless worker (stream-json)\n```\n\nThe engine runs as a local-only process. All APIs bind to `localhost` by\ndefault. No hosted relay, no provider-token custody, no secret leakage.\n\nFor the read-only record of what a thread or turn did, see\n[`docs/RECEIPTS.md`](RECEIPTS.md): `codewhale receipts` on the CLI and the\n`/receipt` routes under **Threads** below.\n\n## Runtime API entrypoints\n\n| Entry | Transport | Use |\n|---|---|---|\n| `codewhale web [--port 7878]` | HTTP/SSE on `127.0.0.1:7878` + embedded client | First-class loopback-only browser client; opens the default browser |\n| `codewhale app-server --http` | HTTP/SSE on `127.0.0.1:7878` | Full `/v1/*` runtime API (canonical) |\n| `codewhale app-server --mobile` | HTTP/SSE on loopback + `/mobile` | Runtime API + local mobile control page |\n| `codewhale app-server --stdio` | Initialized JSONL over stdio | Compatibility controls and explicit tool-free turns through the held manager; no hidden TCP listener or full Codex interaction channel. |\n| `codewhale app-server --socket [--socket-path P]` | RPC text messages over WebSocket on a `0600` Unix domain socket (macOS/Linux) or an owner-only named pipe (Windows) | Desktop daemon: multi-client, kernel peer identity checked, `daemon/attach` claim handshake. Windows native-app qualification remains open. |\n| `codewhale app-server` | Initialized JSONL over stdio | Same selection as `--stdio`; HTTP requires explicit `--http`. |\n| `codewhale serve --http` / `--mobile` | same server as `app-server --http`/`--mobile` | Compatibility aliases |\n\n`app-server --http` and `--mobile` launch the same mature runtime API server\nhistorically reached through `serve --http` — no routes or behavior changed, so\nevery endpoint documented below is identical across both entrypoints. The\nruntime API token is read from `--auth-token`, then `CODEWHALE_RUNTIME_TOKEN`,\nthen `DEEPSEEK_RUNTIME_TOKEN`; use `--insecure-no-auth` only with a loopback\nbind. The `serve` compatibility aliases keep their `--insecure` flag.\nBare `app-server` does not open an HTTP port. Selected attached HTTP listeners\nretain their explicit bearer and non-loopback security checks.\n\nInitialized stdio/private-socket controls support `thread/loaded/list` with\n`{cursor?: string | null, limit?: number | null}`. The result is\n`{data: string[], nextCursor: string | null}` from the held manager's loaded\nengine snapshot, including idle and archived engines still in memory. It dLine truncated
|
||||
"sha256": "6f3b8885978ef2e1b33d0fdf66ad6ccf5c25464c1951b59658aec00745390db2",
|
||||
"content": "# Codewhale Engine Runtime API & Integration Contract\n\n> 阅读简体中文版:[zh_hans/RUNTIME_API.md](zh_hans/RUNTIME_API.md)。\n\n`codewhale app-server --http` exposes the [Codewhale Engine](ARCHITECTURE.md) through\nits canonical local Runtime API and control plane.\nLocal SDKs, mobile/remote-control clients, and editor integrations talk to it\ninstead of screen-scraping terminal output. It serves the full HTTP/SSE runtime\nAPI (`/v1/*`), a JSON-RPC control transport over stdio, and the phone-friendly\nmobile page. `codewhale doctor --json` provides machine-readable health, and\n`codewhale serve --acp` speaks the Agent Client Protocol over stdio for editors\nsuch as Zed.\n\n`codewhale serve --http` / `serve --mobile` remain as **compatibility aliases**\nfor `codewhale app-server --http` / `--mobile`; both launch the identical\nserver. Desktop, web, SDK and custom integrations should target\n`app-server --http`. `--stdio` and `--socket` expose compatibility controls.\nBare `app-server` selects initialized JSONL control over stdio. Local stdio,\nsocket and ACP owners do not bind a hidden TCP listener. Explicit `--http`\nretains HTTP/SSE and compatibility routes on the same Runtime owner.\n\n`codewhale exec` is the separate one-shot headless worker path (stream-json,\nfleet worker subprocess, CI primitive). It is not part of this API, but it\nshares the same runtime, provider/model resolution, permission profiles, and\nevent vocabulary.\n\nThis document is the stable integration contract for native workbench\napplications (and other local supervisors) that embed the Codewhale Engine.\n\n## Architecture\n\n```\nlocal supervisor / SDK / automation harness\n │\n ├─ codewhale app-server --http → HTTP/SSE runtime API (/v1/*) [canonical]\n ├─ codewhale app-server --mobile → runtime API + mobile control page\n ├─ codewhale app-server --stdio → JSON-RPC control transport over stdio\n ├─ codewhale app-server --socket → same JSON-RPC over a unix domain socket (desktop daemon)\n ├─ codewhale doctor --json → machine-readable health & capability\n ├─ codewhale serve --acp → ACP stdio agent for editors such as Zed\n ├─ codewhale serve --mcp → MCP stdio server\n ├─ codewhale serve --http/--mobile → legacy aliases for `app-server --http/--mobile`\n └─ codewhale exec [args] → one-shot headless worker (stream-json)\n```\n\nThe engine runs as a local-only process. All APIs bind to `localhost` by\ndefault. No hosted relay, no provider-token custody, no secret leakage.\n\nFor the read-only record of what a thread or turn did, see\n[`docs/RECEIPTS.md`](RECEIPTS.md): `codewhale receipts` on the CLI and the\n`/receipt` routes under **Threads** below.\n\n## Runtime API entrypoints\n\n| Entry | Transport | Use |\n|---|---|---|\n| `codewhale web [--port 7878]` | HTTP/SSE on `127.0.0.1:7878` + embedded client | First-class loopback-only browser client; opens the default browser |\n| `codewhale app-server --http` | HTTP/SSE on `127.0.0.1:7878` | Full `/v1/*` runtime API (canonical) |\n| `codewhale app-server --mobile` | HTTP/SSE on loopback + `/mobile` | Runtime API + local mobile control page |\n| `codewhale app-server --stdio` | Initialized JSONL over stdio | Compatibility controls and explicit tool-free turns through the held manager; no hidden TCP listener or full Codex interaction channel. |\n| `codewhale app-server --socket [--socket-path P]` | RPC text messages over WebSocket on a `0600` Unix domain socket (macOS/Linux) or an owner-only named pipe (Windows) | Desktop daemon: multi-client, kernel peer identity checked, `daemon/attach` claim handshake. Windows native-app qualification remains open. |\n| `codewhale app-server` | Initialized JSONL over stdio | Same selection as `--stdio`; HTTP requires explicit `--http`. |\n| `codewhale serve --http` / `--mobile` | same server as `app-server --http`/`--mobile` | Compatibility aliases |\n\n`app-server --http` and `--mobile` launch the same mature runtime API server\nhistorically reached through `serve --http` — no routes or behavior changed, so\nevery endpoint documented below is identical across both entrypoints. The\nruntime API token is read from `--auth-token`, then `CODEWHALE_RUNTIME_TOKEN`,\nthen `DEEPSEEK_RUNTIME_TOKEN`; use `--insecure-no-auth` only with a loopback\nbind. The `serve` compatibility aliases keep their `--insecure` flag.\nBare `app-server` does not open an HTTP port. Selected attached HTTP listeners\nretain their explicit bearer and non-loopback security checks.\n\nInitialized stdio/private-socket controls support `thread/loaded/list` with\n`{cursor?: string | null, limit?: number | null}`. The result is\n`{data: string[], nextCursor: string | null}` from the held manager's loaded\nengine snapshot, including idle and archived engines still in memory. It dLine truncated
|
||||
},
|
||||
{
|
||||
"path": "docs/RUNTIME_ROUTES.md",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"tag": "v0.10.2",
|
||||
"slugs": [
|
||||
"first-time",
|
||||
"install",
|
||||
"guide",
|
||||
"auth",
|
||||
|
||||
@@ -3,6 +3,38 @@
|
||||
"tag": "v0.10.2",
|
||||
"version": "0.10.2",
|
||||
"topics": [
|
||||
{
|
||||
"id": "first-time",
|
||||
"slug": "first-time",
|
||||
"sources": [
|
||||
{
|
||||
"path": "docs/GUIDE.md",
|
||||
"sha256": "43b307ea1fc95898f1ec2484f0b9073b7774d14504d4b12c41e4bf0d5af3b336",
|
||||
"content": "# Codewhale User Guide\n\nThis guide is for your first hour with Codewhale. It explains the main\nworkflow, the important safety controls, and where to go next when you need a\ncomplete reference.\n\nCodewhale has deeper reference documents for installation, configuration,\nproviders, modes, keybindings, tools, and operations. Use this page as a guided\nwalkthrough, then follow the \"Next\" links when you need every option.\n\n## 1. Welcome to Codewhale\n\nCodewhale is a terminal coding agent. You run it from a workspace, give it a\ntask, and it can use structured tools to inspect files, run commands, edit\ncode, and report back with evidence.\n\nThe important difference from a normal chat model is that Codewhale is built\naround a harness:\n\n- It keeps the active workspace and session visible.\n- It routes each turn through explicit modes and approval rules.\n- It shows tool calls in the transcript instead of hiding the work.\n- It can preserve sessions, fork conversations, and continue later.\n- It can run sub-agents for focused background work.\n\nYou can use Codewhale for small questions:\n\n```text\nExplain the authentication flow in this repository.\n```\n\nYou can also use it for multi-step work:\n\n```text\nFind the failing validation path, propose a fix, and wait for my approval\nbefore editing files.\n```\n\nFor a new repository, start conservatively. Ask Codewhale to explore and plan\nbefore asking it to change files. That gives you a reviewable path and makes it\neasier to catch wrong assumptions early.\n\nNext: [ARCHITECTURE.md](ARCHITECTURE.md) explains the internal harness and\nruntime model.\n\n## 2. First Launch\n\nFor a new macOS or Linux installation, use the official GitHub release.\nThe installer verifies the release checksums and provides the same runtime\nunder the `codewhale` and `codew` command names:\n\n```bash\ncurl -fsSL https://codewhale.net/install.sh | sh\n```\n\nWindows users should choose the matching installer or archive from\n[GitHub Releases](https://github.com/codewhale-hq/CodeWhale/releases/latest).\nFor an existing direct install, use `codewhale update --check`, then\n`codewhale update`. npm and Cargo remain secondary packaging routes; Cargo\nalso supports source builds where a compatible prebuilt is unavailable.\nFor occupied directories, package-managed installs, and PATH setup, follow\n[the installation and migration guide](INSTALL.md#recommended-official-github-releases).\nAndroid/Termux uses its own [preview archive or source-build path](INSTALL.md#android--termux-arm64).\n\nDocker is also available when you want an isolated runtime. The published\nimage is currently 0.10.0; to test current `main`, [build it from source](DOCKER.md#building-locally).\n\n```bash\ndocker volume create codewhale-home\ndocker run --rm -it \\\n -e DEEPSEEK_API_KEY=\"$DEEPSEEK_API_KEY\" \\\n -v codewhale-home:/home/codewhale/.codewhale \\\n -v \"$PWD:/workspace\" \\\n -w /workspace \\\n ghcr.io/hmbown/codewhale:latest\n```\n\nOnce the install directory is on PATH, launch Codewhale from the repository or\ndirectory you want it to work in:\n\n```bash\ncodewhale\n```\n\nFor the default GitHub installer destination, you can use\n`\"$HOME/.local/bin/codewhale\"` until that directory is on PATH.\n\nOn first launch, Codewhale opens the composer, even with no key or route.\nWith no key or route, workspace trust is asked once a provider is connected;\notherwise only when the folder requires a decision. A provider is asked for\nwhen you send your first message: the message stays in the box while you sign\nin or use your own key, and a local model is offered only when a local Ollama\nserver answers. The provider step also has an explicit offline route.\n\nEverything optional stays available after that. Use `/setup` for the\nprogressive setup and repair guide, `/settings` for the full typed editor, and\n`/constitution` when you want your own constitution on top of the bundled one.\nThe localized telemetry choice appears only after the workspace is ready and\ndoes not block the composer.\n\nDeepSeek is the default provider. If you want to configure its key before or\nafter the first launch, the most direct setup path is:\n\n```bash\ncodewhale auth set --provider deepseek\n```\n\nYou can also provide a key through the environment:\n\n```bash\nexport DEEPSEEK_API_KEY=\"your-key\"\ncodewhale\n```\n\nNew Codewhale config is stored under `~/.codewhale/config.toml`. Legacy\n`~/.deepseek/config.toml` files are still supported for users migrating from\nthe old name.\n\nUse `/constitution` to review or change standing guidance. After setup, run a\ndoctor check:\n\n```bash\ncodewhale doctor\n```\n\nUse the JSON form when you need a machine-readable report for an issue:\n\n```bash\ncodewhale doctor --json\n```\n\nBoth forms are offline by default. They report structural configuration and\nliteral unknown/not-probed credential states without loading workspace `.env`\ncredentials, opeLine truncated
|
||||
},
|
||||
{
|
||||
"path": "docs/INSTALL.md",
|
||||
"sha256": "e8d15c92107272146ad38f66c487bda9c821a4fd85b46c0a1c884907acc6803a",
|
||||
"content": "# Installing Codewhale\n\n> 阅读简体中文版:[zh_hans/INSTALL.md](zh_hans/INSTALL.md)\n\nCodewhale is an open-source coding agent that runs in your terminal. You give\nit a task (\"fix the failing test\", \"add a CLI flag\"). It reads your repository,\nedits files and runs commands. In the default **Ask** posture it applies file\nedits inside the workspace immediately (and shows you the diff), but asks before\nrunning shell commands, so commit or stash anything you care about first. It\nworks with many model providers. **DeepSeek** is the default.\n\nThe command is `codewhale`. `codew` is a shorter alias for the same program.\n\nThe installation paths were first checked with **v0.10.0** (released 2026-09-22) on a\nfresh **Ubuntu 24.04 x86_64** machine. The dated\n[install receipts](https://github.com/codewhale-hq/Codewhale/blob/37ecdfcc49bc68a9b0d058b97c3946e62c34bd31/docs/install-report/v0.10.0-2026-09-23/RECEIPTS.md) record the commands and output checked at that time. Steps that\ncould not be run on that machine are marked **(untested on this VM: reason)**.\nmacOS, Windows and Android are out of scope, apart from a few notes. A second\npass re-ran the installer, manual-download, archive and npm paths, the no-key\nchecks and zsh completion on **macOS 26.1 (Apple silicon)**; see\n[macOS notes](#macos-notes). Steps that need a model call were not re-run\nthere.\n\nInstall commands that use `latest` resolve to the latest **published** GitHub\nRelease or package. Between releases, `main` may already describe the next\nversion (for example a v0.10.2 source candidate before its tag). A\nprebuilt candidate is available through the official installer only after its\ntag, checksums and release assets exist. Contributors can build `main` now.\n\n## No account needed\n\nCodewhale needs no account. Keys you save with `/provider` or\n`codewhale auth set` stay on your machine; see\n[API keys and providers](#8-api-keys-and-providers). New Codewhale accounts\nare not open, so this guide does not cover account key storage.\n\n## Test and contribute to current source\n\n`latest` selects the latest published release. To test unreleased repairs,\nbuild canonical GitHub `main`, or CNB `main` after verifying its commit matches.\nThe source version number alone does not establish a published package.\n\n```bash\ngit clone --branch main https://github.com/codewhale-hq/CodeWhale.git\ncd CodeWhale\ncargo build --release --locked -p codewhale-cli\ntarget/release/codewhale --version\n```\n\nSee the Cargo section for build prerequisites; use the repository's stable Rust\nselection (declared minimum 1.89). Windows uses `target/release/codewhale.exe`\nand needs MSVC. A development build is separate from a signed release package.\nFor mainland-China downloads and source validation, see [CNB mirror](CNB_MIRROR.md).\nThe [Tencent Lighthouse bootstrap](../scripts/tencent-lighthouse/README.md) is\nbilingual. An npm registry mirror serves the wrapper; native binary downloads\nstill need an available release asset source.\n\n---\n\n## 60-second quickstart (Linux or macOS)\n\n```bash\n# 1. Install. Downloads two checksum-verified binaries into ~/.local/bin (no sudo).\ncurl -fsSL https://codewhale.net/install.sh | sh\n\n# 2. Make sure ~/.local/bin is on your PATH, now and in future terminals.\necho 'export PATH=\"$HOME/.local/bin:$PATH\"' >> ~/.bashrc # zsh: use ~/.zshrc\nexport PATH=\"$HOME/.local/bin:$PATH\"\ncodewhale --version # -> codewhale 0.10.0 (1be1a703b975)\n\n# 3. Give it a DeepSeek API key (from https://platform.deepseek.com/api_keys).\ncodewhale auth set --provider deepseek # prompts for the key; nothing is echoed\ncodewhale auth status --provider deepseek # \"active source: secret store\"\n\n# 4. Run your first task inside a git repository.\ncd ~/your-project\ncodewhale\n```\n\nIn the TUI, type something concrete:\n\n```text\ncreate a Python file primes.py that prints the first 10 primes, run it, and show me the output\n```\n\nCodewhale writes the file, shows you a diff, then asks **APPROVAL: bash\npython3 primes.py – Do you want to proceed?** Press `y` to allow it once. It\nruns the command and reports the output. Press `Ctrl-D` (with an empty input\nbox) to quit. It prints the command to resume the session later.\n\n> **If nothing happens after you send your first message,** you have no key\n> configured. v0.10.0 doesn't warn you in that case. Press **F3**. If DeepSeek\n> shows `missing key`, press Enter, paste the key, then pick a model and\n> confirm.\n\n---\n\n## Contents\n\n1. [Before you start](#1-before-you-start)\n2. [Install: recommended installer](#2-recommended-installer-curl--sh)\n3. [Install: manual download from GitHub Releases](#3-manual-download-from-github-releases)\n4. [Install: npm](#4-npm)\n5. [Install: Cargo / build from source](#5-cargo-and-building-from-source)\n6. [Install: Homebrew (Linux) and Nix](#6-homebrew-on-linux-and-nix)\n7. [Updating and rolling back]Line truncated
|
||||
},
|
||||
{
|
||||
"path": "docs/MODES.md",
|
||||
"sha256": "ea1e371421770a8582195f4c80c70fe9d067b3800ef143c7fea1fdcf444f0ecd",
|
||||
"content": "# Modes and Permission Postures\n\n> 阅读简体中文版:[zh_hans/MODES.md](zh_hans/MODES.md)\n\nCodewhale has three related concepts:\n\n- **TUI mode**: what kind of visible interaction you're in (Plan/Work/Operate).\n- **Permission posture**: how aggressively the UI asks before executing tools.\n- **Workflow**: named steps that coordinate sub-agents, with dependencies and\n results, available in any TUI mode.\n\nModel selection is separate. `--model auto` and `/model auto` route each turn to\na concrete model and thinking level; they are not TUI modes and are not part of\nthe `Tab` cycle.\n\nWorkflow runs named steps through the same sub-agent runtime; Fleet manages\ntheir saved roles and model assignments. A workflow adds ordering, result\nhandoffs, gates and a progress view. The active mode and permission posture\nstill control what each step may execute.\n\nBefore assigning steps, query `agent(action=\"roster\")` for the saved Fleet\nmodels and roles. A plan child can select a listed model with `model`, or use\nthe saved `role`/`profile` assignment. An Exact Fleet fixes each member's route.\n\n## TUI Modes\n\nPress `Tab` to complete composer menus or cycle through the visible modes\nwhen the composer is empty: **Plan → Work → Operate → Plan**. `Tab` never sends\nor queues composer text; use `Enter` to send or queue it.\nPress `Shift+Tab` to cycle permission posture (Ask → Auto-Review → Full Access).\nEvery change of effective permission writes a `Permissions changed to …` line\nin the transcript.\nPress `Ctrl+T` to cycle reasoning effort.\nRun `/mode` to open the mode picker, or switch directly with `/mode work`,\n`/mode plan`, or `/mode operate`.\n\n- **Plan**: design-first prompting. The stable primitive names remain familiar, but the runtime centrally refuses file mutation and shell execution. Read-only inspection and policy-allowed research, including deferred Web search/fetch, remain available. When a Plan turn successfully finishes a nonempty response, including a prose-only plan, the TUI asks how to continue: Work (Ask), Work (Auto-Review), or keep planning. Work sends the exact approved response as a normal follow-up and preserves that turn's existing To-dos, or adds one pending plan item without replacing unrelated work.\n- **Work** (internally `agent`): ordinary multi-step execution. The first-turn toolbox includes `read`, `write`, `edit`, `file_search`, `bash`, `agent`, `workflow`, `todo_write`, and `load_skill`, plus `create_goal`, `get_goal`, `update_goal`, and `task_shell_wait` so goal and background-shell controls are available without discovery. The agent calls `create_goal` when a request is a durable objective; the host never infers a goal from wording, and the contract must not tell the model otherwise. Approval, sandbox, repository law, and managed policy decide what may execute.\n- **Operate**: Work at full strength, the \"ultra\" mode. Same tools and authority as Work; the difference is how they are used. Each substantive request becomes a goal (`create_goal`), the plan stays visible (`todo_write`), multi-part work runs through `workflow` and parallel `agent` workers, non-trivial changes get an independent reviewer before they are called done, long commands keep running in the background, and recurring or watch-type work is offered as an `automation` (created after approval). It keeps going until the goal is verified, blocked on you, or paused.\n\n`Act` and `/mode act` remain compatibility aliases for Work. Saved settings\nstill normalize to the internal value `agent`.\n\n### Tool availability by mode\n\n| Tool family | Plan | Work | Operate |\n|:---|:---:|:---:|:---:|\n| `read` and policy-allowed deferred research tools | yes | yes | yes |\n| `write` and `edit` | visible names; execution denied | approval- and policy-gated | same as Work |\n| `bash` | visible name; execution denied | approval- and policy-gated | same as Work; delegation is preferred when parallelism or isolation helps |\n| `agent` | yes, subject to child-depth authority | yes, subject to child-depth authority | yes, subject to child-depth authority |\n| Deferred native, MCP, and plugin tools | discoverable through `tool_search` when policy permits | same | same |\n| Paid or external-service tools | follows permission posture | follows permission posture | follows permission posture |\n| Access outside the workspace root | explicit trusted paths only | only through trusted paths or trust mode | same trusted-path/trust policy as Work; fleet profiles never widen it |\n\nOperate changes scheduling emphasis, not authority. It neither adds a\nmode-specific tool denial nor bypasses the active approval, sandbox, shell,\nask-rule, repository-law, or managed-policy boundary. Plan remains the\nmode-specific execution boundary for shell and write-capable tools; that\nauthority difference does not require a different primitive vocabulary.\n\n### Operate loop (one screen)\n\n```text\nUseLine truncated
|
||||
},
|
||||
{
|
||||
"path": "docs/SANDBOX.md",
|
||||
"sha256": "1626a34e894f491c15959ebe82b87d2f669c792f9c9457fb912023dcee1a5a33",
|
||||
"content": "# Sandbox threat model\n\n> 阅读简体中文版:[zh_hans/SANDBOX.md](zh_hans/SANDBOX.md)。\n\nCodewhale can launch shell commands proposed by a model. Approval policy,\nworkspace-aware tools, and an operating-system command wrapper are separate\ncontrols: an approval is not a sandbox, and selecting `workspace-write` does\nnot prove that the current platform has an OS wrapper available.\n\nThis document describes only behavior wired into the command execution path.\nSee [Authorization order](AUTHORIZATION_ORDER.md) for the policy layers that\nrun before execution reaches this boundary.\n\n## Platform overview\n\n| Mechanism | Platform | Selection | What Codewhale reports |\n|---|---|---|---|\n| Seatbelt (`sandbox-exec`) | macOS | Automatic when the runtime probe succeeds | `macos-seatbelt` |\n| Bubblewrap (`/usr/bin/bwrap`) | Linux | Default when installed and a wrapped probe run succeeds; `prefer_bwrap = false` opts out | `linux-bwrap` |\n| No OS wrapper | Linux without usable bwrap, or opted out | `prefer_bwrap = false`, or bwrap absent/unusable | `none` |\n| No OS wrapper | Windows | Current implementation | `none` |\n| OpenSandbox-compatible service | Any supported host | `sandbox_backend = \"opensandbox\"` | External execution path |\n\nThe repository contains a seccomp implementation module plus a future Windows\nhelper contract. They are not wired into child-command launch, so Codewhale\ndoes not advertise them as active sandboxes. Source-only sandbox code is not\nevidence that a command was restricted.\n\n## macOS: Seatbelt\n\nCodewhale probes `/usr/bin/sandbox-exec` by running a minimal profile. When the\nprobe succeeds and the selected `SandboxPolicy` requests a sandbox, the child\ncommand is wrapped with a generated Seatbelt profile.\n\nThe profile can provide:\n\n- broad filesystem reads;\n- writes limited by the selected policy, including the workspace and specific\n runtime/cache paths needed by supported tools;\n- network access only when the policy enables it. A `read-only` policy with\n network (the agent `read_only: true` shell mode) gets outbound IPv4 and the\n system DNS resolver only. IPv6 and the host's own addresses, loopback\n included, are denied, except the port of a local proxy named in\n `HTTP_PROXY`, `HTTPS_PROXY` or `ALL_PROXY`, and other local sockets stay\n denied.\n\nIf the probe fails or `sandbox-exec` is unavailable, Codewhale reports no OS\nsandbox and launches the command without a Seatbelt wrapper. It does not set a\nSeatbelt marker on that fallback.\n\n## Linux: default bubblewrap\n\nLinux command sandboxing is on by default whenever bubblewrap works. Set the\ntop-level configuration key to opt out:\n\n```toml\nprefer_bwrap = false\n```\n\nCodewhale selects bubblewrap only when `/usr/bin/bwrap` is a regular\nexecutable file AND a wrapped probe run proves it can create its namespaces\non this host — the exec bit alone lies where user namespaces are restricted\n(e.g. Ubuntu 24.04 with `kernel.apparmor_restrict_unprivileged_userns`),\nwhere every wrapped command would fail rather than run unsandboxed. The\nwrapper derives its mounts and network namespace from the resolved\n`SandboxPolicy`:\n\n```text\n/usr/bin/bwrap \\\n --unshare-all \\\n [--share-net] \\\n --ro-bind / / \\\n --dev /dev \\\n --proc /proc \\\n --tmpfs /tmp \\\n [--dev-bind <device-root> <device-root> ...] \\\n --bind <writable-root> <writable-root> ... \\\n --ro-bind <protected-descendant> <protected-descendant> ... \\\n [--ro-bind <extra-ro-root> <extra-ro-root> ...] \\\n --chdir <cwd> \\\n -- <program> <args>\n```\n\nThe sandbox always gets a private `/dev` (fresh device nodes, so `>/dev/null`\nworks), a private `/proc`, and a tmpfs `/tmp` (#5410). Two optional top-level\nconfig keys extend the mounts: `bwrap_ro_roots` (extra host paths bind-mounted\nread-only, applied last so they can narrow a policy-writable path) and\n`bwrap_dev_roots` (host character/block device nodes bind-mounted read-write;\ndirectories are never honored). Missing paths are skipped silently.\n\nThat gives the child a read-only root view. For `workspace-write`, every safe,\nexisting policy root is mounted read-write: the working directory, configured\nadditional roots, `/tmp` and `TMPDIR` unless excluded, and verified Git\nworktree metadata roots. Existing `.codewhale` and `.deepseek` descendants are\nremounted read-only after their writable parent. Missing paths, non-directory\npaths, and `/` are not promoted to writable mounts.\n\nFor `read-only`, there are no writable binds, so the working directory remains\ninside the read-only root view. `--unshare-all` isolates the network namespace\nby default. Codewhale adds `--share-net` only when the policy's\n`network_access` is true. The agent `read_only: true` shell mode turns it on\nonly in Full Access (see [Subagents](SUBAGENTS.md)); it then shares the host\nnetwork namespace, including loopback and abstract unix sockets, like a\nnetworLine truncated
|
||||
},
|
||||
{
|
||||
"path": "docs/KEYBINDINGS.md",
|
||||
"sha256": "26d213c47e899e376f240d2f9f08efeac49911a7472ee7d7ed11fea249509ab3",
|
||||
"content": "# Keybindings\n\n> 阅读简体中文版:[zh_hans/KEYBINDINGS.md](zh_hans/KEYBINDINGS.md)\n\nThis is the source-of-truth catalog of every keyboard shortcut the TUI recognizes. Bindings are grouped by **context** — the focus or modal state they fire in. A binding listed under \"Composer\" only takes effect when the composer is focused; one under \"Transcript\" only when the transcript has focus; and so on.\n\nGlobal key chords are not yet user-configurable — tracked for a future release (#436, #437). Hotbar slot actions are configurable with `[[hotbar]]` and `/hotbar`; the Hotbar activation chord remains `Alt-1` through `Alt-8`.\n\n## Global (any context)\n\n| Chord | Action |\n|----------------------|---------------------------------------------------------------|\n| `/help`, `F1` or `Ctrl-/` | Toggle the help overlay. `/help` is what the chrome advertises: it is the one route every terminal delivers, where `F1` is eaten by tmux and some emulators and `Ctrl-/` encoding varies |\n| `F2` | Toggle the typed Settings editor |\n| `F3` | Open the provider/model picker (same as `/provider`) |\n| `Ctrl-K` | Open the command palette (slash-command finder) |\n| `Ctrl-C` | Cancel current turn / dismiss modal / arm-then-confirm quit |\n| `Ctrl-B` | Release the current shell wait so the turn can continue; foreground commands move into `/jobs`, and background tasks keep running. Use `/jobs` or `Bash` with `action: \"wait\"` to inspect output |\n| `Ctrl-D` | Quit (only when the composer is empty) |\n| `Tab` | When the composer is empty, cycle TUI mode: Plan → Work → Operate → Plan |\n| `Shift+Tab` | Cycle permission posture: Ask → Auto-Review → Full Access. Live regardless of composer contents or whether a turn is running (suppressed only while a modal other than Config is open) |\n| `Ctrl-T` | Cycle reasoning effort for the active model. Walks the same ladder as `/model` and `/effort` (catalog or documented route dialect). Always-thinking models omit `off`; Grok 4.6 includes `xhigh`. |\n| `Ctrl-Shift-T` | Toggle live transcript overlay (sticky-tail auto-scroll) |\n| `Ctrl-R` | Open the resume-session picker |\n| `Ctrl-L` | Compact the conversation context (status line shows progress; no-op while a compaction is already running) |\n| `Ctrl-O` | Open the reasoning detail for the selected or current turn, regardless of composer contents |\n| `Ctrl-Alt-O` | Open the whole-turn Turn Inspector, regardless of composer contents |\n| `Alt-V` / `Option-V` (macOS) | Open the details pager for the selected tool/sub-agent card; with nothing selected, the newest visible error, else the first visible card with details, else the most recent one. Click a card first to keep Alt-V on it while newer cards arrive. Terminals that emit the legacy Option-V glyph are also handled |\n| `Ctrl-Shift-E` / `Cmd-Shift-E` | Toggle the file tree in the workbar |\n| `Ctrl-]` or `Ctrl-Tab` | Cycle the pet view while the pet main view is showing; otherwise cycle the work dock: TODO → Agents → Jobs → Background. `Ctrl-]` is portable; `Ctrl-Tab` requires the kitty keyboard protocol. Terminal.app uses it to switch terminal tabs |\n| `Ctrl-Shift-Tab` | Cycle the pet view or work dock backwards where the terminal sends the kitty keyboard chord. Terminal.app uses it to switch terminal tabs |\n| `Alt-G` / `Alt-Shift-G` | Scroll transcript to top / bottom when the composer is empty |\n| `Alt-1`-`Alt-8` | Dispatch Hotbar slots 1-8 when no modal or inline picker is open |\n| `Alt-!` / `Alt-@` / `Alt-#` / `Alt-$` | Select the workbar panel: Tasks / Agents / Context / Files |\n| `Ctrl-Alt-0` | Toggle the workbar off / back to the bottom placement |\n| `Alt-L` | Open the pager for the last message (composer empty) |\n| `Alt-P` / `Alt-A` / `Alt-Y` | Jump to Plan / Work, or request Full Access (`Alt-Y` is the legacy permission channel — Work + Full Access — not a separate mode; it honors a locked approval policy) |\n| `Ctrl-X` (Activity workbar) | Cancel all running background shell jobs |\n| `Esc` | Close topmost modal · cancel slash menu · dismiss toast. During a compact that is serving an in-flight turn, Esc stops the turn (the compact is collateral). |\n\n## Composer\n\nEditing the message you're about to send.\n\n| Chord | Action |\n|-----------------------------|---------------------------------------------------------|\n| `Enter` | Send when idle; queue while busLine truncated
|
||||
}
|
||||
],
|
||||
"missing": []
|
||||
},
|
||||
{
|
||||
"id": "install",
|
||||
"slug": "install",
|
||||
|
||||
@@ -2299,6 +2299,279 @@
|
||||
],
|
||||
"sourceNote": "Source documents: docs/public-surface-facts.json (trust), docs/TELEMETRY.md, docs/SANDBOX.md · Update docs-map.ts when changing."
|
||||
},
|
||||
"docs-first-time": {
|
||||
"metaTitle": "Use Codewhale for the first time · Codewhale Docs",
|
||||
"metaDescription": "Open a terminal, install Codewhale, pick the folder it works in, connect a model and finish a first task, with no terminal experience needed.",
|
||||
"bodyClassName": "text-ink-soft leading-relaxed",
|
||||
"title": "Use Codewhale for the first time",
|
||||
"lede": "This page takes you from opening a terminal for the first time to finishing a small task with Codewhale. It also explains which folder to start in, because that folder decides what Codewhale can change.",
|
||||
"sections": [
|
||||
{
|
||||
"id": "what-you-need",
|
||||
"title": "Check what you need",
|
||||
"blocks": [
|
||||
{
|
||||
"p": "You need a Mac, a Windows x64 PC or a Linux computer, and an internet connection for the install. To get answers you also need a model: an API key from a hosted provider such as DeepSeek, or a model running on your own computer with Ollama."
|
||||
},
|
||||
{
|
||||
"p": "Codewhale is free and needs no account. A hosted provider bills you directly for what you use, and a model you run yourself needs no key."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "open-terminal",
|
||||
"title": "Open a terminal",
|
||||
"blocks": [
|
||||
{
|
||||
"p": "A terminal is a window where you type commands instead of clicking. A command runs when you press Return, which is Enter on Windows."
|
||||
},
|
||||
{
|
||||
"rows": [
|
||||
[
|
||||
"macOS",
|
||||
"Press Cmd+Space, type Terminal and press Return."
|
||||
],
|
||||
[
|
||||
"Windows",
|
||||
"Open the Start menu, type PowerShell and press Enter."
|
||||
],
|
||||
[
|
||||
"Linux",
|
||||
"Open your applications menu and search for Terminal. On Ubuntu, Ctrl+Alt+T opens one."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"note": "Paste with Cmd+V on a Mac and Ctrl+V on Windows. Most Linux terminals paste with Ctrl+Shift+V."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "install",
|
||||
"title": "Install Codewhale",
|
||||
"blocks": [
|
||||
{
|
||||
"p": "On a Mac or Linux, paste this command and press Return. It downloads Codewhale, checks the download, and adds Codewhale to your PATH, the list of folders your terminal searches for commands."
|
||||
},
|
||||
{
|
||||
"code": "curl -fsSL https://codewhale.net/install.sh | sh",
|
||||
"lang": "macOS · Linux"
|
||||
},
|
||||
{
|
||||
"p": "On Windows, paste this command and press Enter:"
|
||||
},
|
||||
{
|
||||
"code": "winget install --id HunterBown.CodeWhale --exact --source winget",
|
||||
"lang": "Windows"
|
||||
},
|
||||
{
|
||||
"p": "When it finishes, open a new terminal window so it picks up the new PATH. Then check that it worked: a version number means Codewhale is installed."
|
||||
},
|
||||
{
|
||||
"code": "codewhale --version",
|
||||
"lang": "Terminal"
|
||||
},
|
||||
{
|
||||
"p": "If the terminal says `command not found`, follow [the PATH steps on the install page](/install#install-path)."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "choose-folder",
|
||||
"title": "Choose the folder to work in",
|
||||
"blocks": [
|
||||
{
|
||||
"p": "Codewhale works in the folder your terminal is in when you start it. That folder is its workspace: Codewhale reads and changes files there, and its file tools cannot reach other folders unless you allow it."
|
||||
},
|
||||
{
|
||||
"p": "On a Mac, an operating-system sandbox also stops its commands from writing outside the workspace, apart from temporary files. Linux gets the same sandbox when bubblewrap is installed. Windows has no such sandbox yet, so Codewhale relies on asking you first."
|
||||
},
|
||||
{
|
||||
"rows": [
|
||||
[
|
||||
"A folder for one project",
|
||||
"The best choice for most work. Codewhale sees only that project, and `/undo` can take back what it changed."
|
||||
],
|
||||
[
|
||||
"A new, empty folder",
|
||||
"A good start for something new, such as a website or a script. The next section shows how to make one."
|
||||
],
|
||||
[
|
||||
"A folder that holds several projects",
|
||||
"Lets one task read and change files across all of them. Pick the smallest folder that holds everything the task needs; in a very large folder Codewhale turns snapshots off and tells you."
|
||||
],
|
||||
[
|
||||
"Your home folder, Desktop, Documents or Downloads",
|
||||
"Codewhale runs there, but `/undo` and snapshots are off in these folders themselves because they are too broad to snapshot safely. A folder inside one of them works normally."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"note": "Snapshots are Codewhale's own copies of the files it changes. It keeps them in its settings folder, not in your project, and they work without git."
|
||||
},
|
||||
{
|
||||
"p": "To let Codewhale reach one more folder, type `/trust add` and that folder's path in Codewhale. Full Access mode reaches every folder and runs commands without the sandbox, so use it only when you want Codewhale to work across your whole computer."
|
||||
},
|
||||
{
|
||||
"p": "By default, Codewhale makes file edits in a git repository without asking and shows you what changed. In a folder without git, it asks before each edit."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "open-in-folder",
|
||||
"title": "Open a terminal in that folder",
|
||||
"blocks": [
|
||||
{
|
||||
"rows": [
|
||||
[
|
||||
"macOS Finder",
|
||||
"Control-click the folder and choose Services > New Terminal at Folder. If it is missing, turn it on in System Settings > Keyboard > Keyboard Shortcuts > Services > Files and Folders."
|
||||
],
|
||||
[
|
||||
"Windows File Explorer",
|
||||
"Right-click the folder and choose Open in Terminal. On Windows 10, hold Shift while you right-click and choose Open PowerShell window here."
|
||||
],
|
||||
[
|
||||
"Any terminal",
|
||||
"Type `cd` and a space, drag the folder from Finder or File Explorer into the window, and press Return."
|
||||
],
|
||||
[
|
||||
"Without moving",
|
||||
"Start Codewhale with `-C` and the folder's path, for example `codewhale -C ~/Projects/my-site` on a Mac."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"p": "These commands find your way around from inside a terminal. They work on a Mac, on Linux and in PowerShell."
|
||||
},
|
||||
{
|
||||
"codeTerms": true,
|
||||
"rows": [
|
||||
[
|
||||
"pwd",
|
||||
"Shows the folder you are in."
|
||||
],
|
||||
[
|
||||
"ls",
|
||||
"Lists what is in it."
|
||||
],
|
||||
[
|
||||
"cd Documents",
|
||||
"Moves into the Documents folder inside the current one."
|
||||
],
|
||||
[
|
||||
"cd ..",
|
||||
"Moves up one folder."
|
||||
],
|
||||
[
|
||||
"mkdir my-site",
|
||||
"Makes a new folder called my-site."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"note": "Type the first letters of a folder name and press Tab, and the terminal finishes the name for you."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "connect",
|
||||
"title": "Start Codewhale and connect a model",
|
||||
"blocks": [
|
||||
{
|
||||
"code": "codewhale",
|
||||
"lang": "Terminal"
|
||||
},
|
||||
{
|
||||
"p": "Codewhale opens a message box right away. When you send your first message, it asks where your model runs: pick a hosted provider and paste its API key, or pick Ollama when it is running on your computer."
|
||||
},
|
||||
{
|
||||
"p": "Keys you enter stay on your computer, in Codewhale's settings folder `~/.codewhale`. Change the provider later with `/provider`."
|
||||
},
|
||||
{
|
||||
"p": "If Codewhale asks whether to work with the instructions and files in this folder, answer yes for a folder you made yourself."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "first-task",
|
||||
"title": "Give it a first task",
|
||||
"blocks": [
|
||||
{
|
||||
"p": "Type what you want in plain words and press Return. A safe first task changes nothing, for example: `Explain what is in this folder and what each file is for. Do not change anything.`"
|
||||
},
|
||||
{
|
||||
"p": "Then try one that makes a file, for example: `Make a file called packing-list.md with a packing list for a weekend trip.`"
|
||||
},
|
||||
{
|
||||
"p": "By default, Codewhale asks before it runs a command that is not read-only. Read what the approval says will run before you allow it."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "stop-undo-quit",
|
||||
"title": "Stop, undo and quit",
|
||||
"blocks": [
|
||||
{
|
||||
"codeTerms": true,
|
||||
"rows": [
|
||||
[
|
||||
"Esc",
|
||||
"Stops the task that is running. Ctrl+C does the same."
|
||||
],
|
||||
[
|
||||
"/undo",
|
||||
"Takes back your last request and the file changes it made."
|
||||
],
|
||||
[
|
||||
"/diff",
|
||||
"Shows what changed in this folder."
|
||||
],
|
||||
[
|
||||
"/receipts",
|
||||
"Lists what Codewhale did: files, commands and approvals."
|
||||
],
|
||||
[
|
||||
"/exit",
|
||||
"Quits Codewhale. Ctrl+D also quits when the message box is empty."
|
||||
],
|
||||
[
|
||||
"codewhale --continue",
|
||||
"Run it in the same folder to pick up your last conversation there."
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "help",
|
||||
"title": "Get help",
|
||||
"blocks": [
|
||||
{
|
||||
"p": "Type `/help` in Codewhale to list its commands. [Ask in the Codewhale Discord](https://discord.gg/37gfS3ksug), or read [what to do when something goes wrong](/docs/troubleshooting)."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"next": [
|
||||
{
|
||||
"href": "/docs/guide",
|
||||
"label": "Start your first task",
|
||||
"note": "More first prompts and how to check the answer."
|
||||
},
|
||||
{
|
||||
"href": "/docs/auth",
|
||||
"label": "Connect a provider",
|
||||
"note": "Save a key, check which key is used, or run a local model."
|
||||
},
|
||||
{
|
||||
"href": "/docs/modes",
|
||||
"label": "Set modes and approvals",
|
||||
"note": "Decide which commands and edits stop for your approval."
|
||||
}
|
||||
],
|
||||
"sourceNote": "Source documents: docs/GUIDE.md, docs/INSTALL.md, docs/MODES.md, docs/SANDBOX.md, docs/KEYBINDINGS.md · Update docs-map.ts when changing."
|
||||
},
|
||||
"states": {
|
||||
"loadingLabel": "Loading…",
|
||||
"emptyTitle": "Nothing here yet",
|
||||
|
||||
@@ -2296,6 +2296,279 @@
|
||||
],
|
||||
"sourceNote": "来源文档:docs/public-surface-facts.json(trust)、docs/TELEMETRY.md、docs/SANDBOX.md · 修改时同步更新 docs-map.ts。"
|
||||
},
|
||||
"docs-first-time": {
|
||||
"metaTitle": "第一次使用 Codewhale · Codewhale 文档",
|
||||
"metaDescription": "打开终端、安装 Codewhale、选择它工作的文件夹、连接模型并完成第一个任务,不需要任何终端经验。",
|
||||
"bodyClassName": "text-ink-soft leading-[1.9] tracking-wide",
|
||||
"title": "第一次使用 Codewhale",
|
||||
"lede": "这一页带你从第一次打开终端开始,一直到用 Codewhale 完成一个小任务。它还会说明应该从哪个文件夹启动,因为这个文件夹决定了 Codewhale 能修改什么。",
|
||||
"sections": [
|
||||
{
|
||||
"id": "what-you-need",
|
||||
"title": "确认你需要准备什么",
|
||||
"blocks": [
|
||||
{
|
||||
"p": "你需要一台 Mac、一台 Windows x64 电脑或一台 Linux 电脑,安装时还需要联网。要得到回答,你还需要一个模型:来自 DeepSeek 等托管提供商的 API 密钥,或者用 Ollama 在你自己电脑上运行的模型。"
|
||||
},
|
||||
{
|
||||
"p": "Codewhale 免费,也不需要账号。托管提供商会按你的用量直接向你收费;你自己运行的模型不需要密钥。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "open-terminal",
|
||||
"title": "打开终端",
|
||||
"blocks": [
|
||||
{
|
||||
"p": "终端是一个用输入命令代替点击的窗口。按下回车键后,命令就会运行。"
|
||||
},
|
||||
{
|
||||
"rows": [
|
||||
[
|
||||
"macOS",
|
||||
"按 Cmd+空格键,输入 Terminal,然后按回车键。"
|
||||
],
|
||||
[
|
||||
"Windows",
|
||||
"打开“开始”菜单,输入 PowerShell,然后按回车键。"
|
||||
],
|
||||
[
|
||||
"Linux",
|
||||
"打开应用程序菜单并搜索“终端”。在 Ubuntu 上,按 Ctrl+Alt+T 也能打开。"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"note": "在 Mac 上用 Cmd+V 粘贴,在 Windows 上用 Ctrl+V 粘贴。大多数 Linux 终端用 Ctrl+Shift+V 粘贴。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "install",
|
||||
"title": "安装 Codewhale",
|
||||
"blocks": [
|
||||
{
|
||||
"p": "在 Mac 或 Linux 上,粘贴下面这条命令并按回车键。它会下载 Codewhale、校验下载内容,并把 Codewhale 加入 PATH,也就是终端查找命令时会搜索的文件夹列表。"
|
||||
},
|
||||
{
|
||||
"code": "curl -fsSL https://codewhale.net/install.sh | sh",
|
||||
"lang": "macOS · Linux"
|
||||
},
|
||||
{
|
||||
"p": "在 Windows 上,粘贴下面这条命令并按回车键:"
|
||||
},
|
||||
{
|
||||
"code": "winget install --id HunterBown.CodeWhale --exact --source winget",
|
||||
"lang": "Windows"
|
||||
},
|
||||
{
|
||||
"p": "安装完成后,打开一个新的终端窗口,让它读取新的 PATH。然后确认安装成功:显示版本号就说明 Codewhale 已经安装好了。"
|
||||
},
|
||||
{
|
||||
"code": "codewhale --version",
|
||||
"lang": "终端"
|
||||
},
|
||||
{
|
||||
"p": "如果终端提示 `command not found`,请按照[安装页面上的 PATH 步骤](/install#install-path)操作。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "choose-folder",
|
||||
"title": "选择工作的文件夹",
|
||||
"blocks": [
|
||||
{
|
||||
"p": "Codewhale 在你启动它时终端所在的文件夹里工作。这个文件夹就是它的工作区:Codewhale 在这里读取和修改文件,除非你允许,它的文件工具无法访问其他文件夹。"
|
||||
},
|
||||
{
|
||||
"p": "在 Mac 上,操作系统沙箱还会阻止它的命令写入工作区以外的位置(临时文件除外)。在 Linux 上,安装 bubblewrap 后也有同样的沙箱。Windows 目前还没有这种沙箱,因此 Codewhale 依靠先征求你的同意。"
|
||||
},
|
||||
{
|
||||
"rows": [
|
||||
[
|
||||
"只放一个项目的文件夹",
|
||||
"大多数情况下的最佳选择。Codewhale 只看到这个项目,`/undo` 可以撤回它做的修改。"
|
||||
],
|
||||
[
|
||||
"一个新的空文件夹",
|
||||
"适合开始做新东西,比如一个网站或一个脚本。下一节会说明如何创建。"
|
||||
],
|
||||
[
|
||||
"包含多个项目的文件夹",
|
||||
"让一个任务可以读取和修改所有这些项目里的文件。请选择能装下任务所需全部内容的最小文件夹;文件夹非常大时,Codewhale 会关闭快照并告诉你。"
|
||||
],
|
||||
[
|
||||
"你的主文件夹、桌面、文稿或下载",
|
||||
"Codewhale 可以在这里运行,但在这些文件夹本身中 `/undo` 和快照是关闭的,因为它们范围太大,无法安全地做快照。它们里面的子文件夹可以正常使用。"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"note": "快照是 Codewhale 为它修改的文件保存的副本。它们保存在 Codewhale 的设置文件夹里,而不是你的项目里,并且不需要 git。"
|
||||
},
|
||||
{
|
||||
"p": "要让 Codewhale 多访问一个文件夹,请在 Codewhale 中输入 `/trust add` 和该文件夹的路径。Full Access 模式可以访问所有文件夹,并且在没有沙箱的情况下运行命令,所以只有在你希望 Codewhale 在整台电脑范围内工作时才使用它。"
|
||||
},
|
||||
{
|
||||
"p": "默认情况下,在 git 仓库中 Codewhale 会直接修改文件,并向你展示改了什么。在没有 git 的文件夹中,它会在每次修改前询问你。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "open-in-folder",
|
||||
"title": "在那个文件夹中打开终端",
|
||||
"blocks": [
|
||||
{
|
||||
"rows": [
|
||||
[
|
||||
"macOS 访达",
|
||||
"按住 Control 点按该文件夹,选择“服务”>“新建位于文件夹位置的终端窗口”。如果没有这一项,请在“系统设置”>“键盘”>“键盘快捷键”>“服务”>“文件和文件夹”中打开它。"
|
||||
],
|
||||
[
|
||||
"Windows 文件资源管理器",
|
||||
"右键点击该文件夹,选择“在终端中打开”。在 Windows 10 上,按住 Shift 再右键点击,选择“在此处打开 PowerShell 窗口”。"
|
||||
],
|
||||
[
|
||||
"任意终端",
|
||||
"输入 `cd` 和一个空格,把文件夹从访达或文件资源管理器拖进窗口,然后按回车键。"
|
||||
],
|
||||
[
|
||||
"不切换文件夹",
|
||||
"启动 Codewhale 时加上 `-C` 和文件夹路径,例如在 Mac 上输入 `codewhale -C ~/Projects/my-site`。"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"p": "下面这些命令可以在终端里切换和查看文件夹。它们在 Mac、Linux 和 PowerShell 中都能用。"
|
||||
},
|
||||
{
|
||||
"codeTerms": true,
|
||||
"rows": [
|
||||
[
|
||||
"pwd",
|
||||
"显示你当前所在的文件夹。"
|
||||
],
|
||||
[
|
||||
"ls",
|
||||
"列出其中的内容。"
|
||||
],
|
||||
[
|
||||
"cd Documents",
|
||||
"进入当前文件夹里的 Documents 文件夹。"
|
||||
],
|
||||
[
|
||||
"cd ..",
|
||||
"回到上一级文件夹。"
|
||||
],
|
||||
[
|
||||
"mkdir my-site",
|
||||
"新建一个名为 my-site 的文件夹。"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"note": "输入文件夹名的前几个字母后按 Tab 键,终端会帮你补全名字。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "connect",
|
||||
"title": "启动 Codewhale 并连接模型",
|
||||
"blocks": [
|
||||
{
|
||||
"code": "codewhale",
|
||||
"lang": "终端"
|
||||
},
|
||||
{
|
||||
"p": "Codewhale 会立即打开一个输入框。你发送第一条消息时,它会询问模型在哪里运行:选择一个托管提供商并粘贴它的 API 密钥,或者在你的电脑上运行着 Ollama 时选择 Ollama。"
|
||||
},
|
||||
{
|
||||
"p": "你输入的密钥保存在你自己的电脑上,位于 Codewhale 的设置文件夹 `~/.codewhale` 中。之后可以用 `/provider` 更换提供商。"
|
||||
},
|
||||
{
|
||||
"p": "如果 Codewhale 询问是否使用这个文件夹中的说明和文件,对于你自己创建的文件夹,回答“是”即可。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "first-task",
|
||||
"title": "交给它第一个任务",
|
||||
"blocks": [
|
||||
{
|
||||
"p": "用平常的话写下你想做的事,然后按回车键。安全的第一个任务不修改任何东西,例如:`说明这个文件夹里有什么,以及每个文件的用途。不要修改任何内容。`"
|
||||
},
|
||||
{
|
||||
"p": "然后试一个会创建文件的任务,例如:`创建一个名为 packing-list.md 的文件,写一份周末出游的行李清单。`"
|
||||
},
|
||||
{
|
||||
"p": "默认情况下,Codewhale 在运行非只读命令前会先询问你。在允许之前,请先看清审批里写明要运行什么。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "stop-undo-quit",
|
||||
"title": "停止、撤回和退出",
|
||||
"blocks": [
|
||||
{
|
||||
"codeTerms": true,
|
||||
"rows": [
|
||||
[
|
||||
"Esc",
|
||||
"停止正在运行的任务。Ctrl+C 的作用相同。"
|
||||
],
|
||||
[
|
||||
"/undo",
|
||||
"撤回你的上一个请求及其造成的文件修改。"
|
||||
],
|
||||
[
|
||||
"/diff",
|
||||
"显示这个文件夹中改了什么。"
|
||||
],
|
||||
[
|
||||
"/receipts",
|
||||
"列出 Codewhale 做过的事:文件、命令和审批。"
|
||||
],
|
||||
[
|
||||
"/exit",
|
||||
"退出 Codewhale。输入框为空时,按 Ctrl+D 也会退出。"
|
||||
],
|
||||
[
|
||||
"codewhale --continue",
|
||||
"在同一个文件夹中运行它,可以继续你在那里的上一次对话。"
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "help",
|
||||
"title": "获取帮助",
|
||||
"blocks": [
|
||||
{
|
||||
"p": "在 Codewhale 中输入 `/help` 可以列出它的命令。你也可以[在 Codewhale Discord 中提问](https://discord.gg/37gfS3ksug),或阅读[出现问题时怎么办](/docs/troubleshooting)。"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"next": [
|
||||
{
|
||||
"href": "/docs/guide",
|
||||
"label": "开始第一个任务",
|
||||
"note": "更多第一个提示词的示例,以及如何检查回答。"
|
||||
},
|
||||
{
|
||||
"href": "/docs/auth",
|
||||
"label": "连接模型提供商",
|
||||
"note": "保存密钥、查看当前使用的密钥,或运行本地模型。"
|
||||
},
|
||||
{
|
||||
"href": "/docs/modes",
|
||||
"label": "设置模式与审批",
|
||||
"note": "决定哪些命令和修改需要你批准。"
|
||||
}
|
||||
],
|
||||
"sourceNote": "来源文档:docs/GUIDE.md、docs/INSTALL.md、docs/MODES.md、docs/SANDBOX.md、docs/KEYBINDINGS.md · 修改时请同步更新 docs-map.ts。"
|
||||
},
|
||||
"states": {
|
||||
"loadingLabel": "加载中…",
|
||||
"emptyTitle": "这里还没有内容",
|
||||
|
||||
@@ -48,6 +48,30 @@ export const INSTALL_COPY = {
|
||||
scriptLink: { en: "View install script", zh: "查看安装脚本" },
|
||||
releaseLine: { en: "This installs the latest published release, v{version}.", zh: "这会安装最新已发布版本 v{version}。" },
|
||||
|
||||
newTitle: { en: "New to the terminal?", zh: "第一次使用终端?" },
|
||||
newLead: {
|
||||
en: "You install Codewhale by pasting one command into a terminal. These steps show how to open one and how to check that it worked.",
|
||||
zh: "你在终端中粘贴一条命令来安装 Codewhale。下面的步骤说明如何打开终端,以及如何确认安装成功。",
|
||||
},
|
||||
newMacTitle: { en: "macOS", zh: "macOS" },
|
||||
newMac1: { en: "Press Cmd+Space, type Terminal and press Return.", zh: "按 Cmd+空格键,输入 Terminal,然后按回车键。" },
|
||||
newMac2: { en: "Paste this command with Cmd+V and press Return:", zh: "用 Cmd+V 粘贴下面这条命令,然后按回车键:" },
|
||||
newMac3: {
|
||||
en: "When it finishes, open a new window with Cmd+N, type `codewhale --version` and press Return. A version number means it worked.",
|
||||
zh: "安装完成后,按 Cmd+N 打开一个新窗口,输入 `codewhale --version` 并按回车键。显示版本号就说明安装成功了。",
|
||||
},
|
||||
newWinTitle: { en: "Windows", zh: "Windows" },
|
||||
newWin1: { en: "Open the Start menu, type PowerShell and press Enter.", zh: "打开“开始”菜单,输入 PowerShell,然后按回车键。" },
|
||||
newWin2: { en: "Paste this command with Ctrl+V and press Enter:", zh: "用 Ctrl+V 粘贴下面这条命令,然后按回车键:" },
|
||||
newWin3: {
|
||||
en: "When it finishes, close the window, open PowerShell again, type `codewhale --version` and press Enter. A version number means it worked.",
|
||||
zh: "安装完成后,关闭窗口,重新打开 PowerShell,输入 `codewhale --version` 并按回车键。显示版本号就说明安装成功了。",
|
||||
},
|
||||
newNext: {
|
||||
en: "Read the first-time guide: which folder to work in, your first task, and how to undo",
|
||||
zh: "阅读首次使用指南:在哪个文件夹工作、第一个任务,以及如何撤回",
|
||||
},
|
||||
|
||||
pathTitle: { en: "What the installer does to your PATH", zh: "安装程序对 PATH 做了什么" },
|
||||
pathPlaces: {
|
||||
en: "The installer puts `codewhale` and `codew` in `~/.local/bin`. When that folder is not on your PATH, it adds three marked lines to the startup file for your shell:",
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
getDocsAuth, getDocsClaudeCode, getDocsConfiguration, getDocsFleet,
|
||||
getDocsHooks, getDocsMcp, getDocsModes, getDocsPet, getDocsReview,
|
||||
getDocsRuntimeApi, getDocsSandbox, getDocsSkills, getDocsSubagents,
|
||||
getDocsTroubleshooting, getDocsTrust, getDocsWeb, getDocsWork,
|
||||
getDocsTroubleshooting, getDocsTrust, getDocsWeb, getDocsWork, getDocsFirstTime,
|
||||
getDocsGuide, getDocsVocabulary, getConstitution, getChrome, getHome, fill, pickText,
|
||||
} from "./i18n/dictionaries";
|
||||
import type { DocsBlock, DocsPageDict } from "./i18n/dictionaries/types";
|
||||
@@ -31,7 +31,7 @@ const ARTICLES: Record<string, (locale: string) => DocsPageDict> = {
|
||||
pet: getDocsPet, review: getDocsReview, "runtime-api": getDocsRuntimeApi,
|
||||
sandbox: getDocsSandbox, skills: getDocsSkills, subagents: getDocsSubagents,
|
||||
troubleshooting: getDocsTroubleshooting, trust: getDocsTrust, web: getDocsWeb,
|
||||
work: getDocsWork,
|
||||
work: getDocsWork, "first-time": getDocsFirstTime,
|
||||
};
|
||||
|
||||
function blockText(block: DocsBlock): string {
|
||||
|
||||
@@ -40,6 +40,18 @@ export const DOC_CATEGORY_LABELS: Record<DocTopic["category"], { en: string; zh:
|
||||
};
|
||||
|
||||
export const DOC_TOPICS: DocTopic[] = [
|
||||
{
|
||||
id: "first-time",
|
||||
slug: "first-time",
|
||||
label: { en: "Use Codewhale for the first time", zh: "第一次使用 Codewhale" },
|
||||
description: {
|
||||
en: "Open a terminal, install, pick the folder Codewhale works in, connect a model and finish a first task.",
|
||||
zh: "打开终端、安装、选择 Codewhale 工作的文件夹、连接模型并完成第一个任务。",
|
||||
},
|
||||
repoSource: ["docs/GUIDE.md", "docs/INSTALL.md", "docs/MODES.md", "docs/SANDBOX.md", "docs/KEYBINDINGS.md"],
|
||||
hasPage: true,
|
||||
category: "getting-started",
|
||||
},
|
||||
{
|
||||
id: "install",
|
||||
slug: "install",
|
||||
|
||||
@@ -8,7 +8,7 @@ import { FEATURE_CATALOGUE, type Feature } from "./features";
|
||||
import {
|
||||
getDocsAuth, getDocsClaudeCode, getDocsConfiguration, getDocsFleet, getDocsHooks, getDocsMcp, getDocsModes, getDocsPet,
|
||||
getDocsReview, getDocsRuntimeApi, getDocsSandbox, getDocsSkills, getDocsSubagents, getDocsTroubleshooting, getDocsTrust,
|
||||
getDocsWeb, getDocsWork,
|
||||
getDocsWeb, getDocsWork, getDocsFirstTime,
|
||||
} from "./i18n/dictionaries";
|
||||
import type { DocsBlock, DocsPageDict } from "./i18n/dictionaries/types";
|
||||
import { DOC_TOPICS } from "./docs-map";
|
||||
@@ -50,6 +50,7 @@ const PAGES: Record<string, (locale: string) => DocsPageDict> = {
|
||||
fleet: getDocsFleet, hooks: getDocsHooks, mcp: getDocsMcp, modes: getDocsModes, pet: getDocsPet, review: getDocsReview,
|
||||
"runtime-api": getDocsRuntimeApi, sandbox: getDocsSandbox, skills: getDocsSkills, subagents: getDocsSubagents,
|
||||
troubleshooting: getDocsTroubleshooting, trust: getDocsTrust, web: getDocsWeb, work: getDocsWork,
|
||||
"first-time": getDocsFirstTime,
|
||||
};
|
||||
|
||||
describe("docs describe the published release first", () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
EN_DOCS_PET,
|
||||
EN_DOCS_AUTH,
|
||||
EN_DOCS_TRUST,
|
||||
EN_DOCS_FIRST_TIME,
|
||||
EN_DOCS_REVIEW,
|
||||
EN_DOCS_VOCABULARY,
|
||||
EN_COMPUTER_USE,
|
||||
@@ -39,6 +40,7 @@ import {
|
||||
getDocsPet,
|
||||
getDocsAuth,
|
||||
getDocsTrust,
|
||||
getDocsFirstTime,
|
||||
getComputerUse,
|
||||
getChangelog,
|
||||
getDocsShell,
|
||||
@@ -285,6 +287,7 @@ describe("website dictionaries", () => {
|
||||
["docs-pet", getDocsPet, EN_DOCS_PET],
|
||||
["docs-auth", getDocsAuth, EN_DOCS_AUTH],
|
||||
["docs-trust", getDocsTrust, EN_DOCS_TRUST],
|
||||
["docs-first-time", getDocsFirstTime, EN_DOCS_FIRST_TIME],
|
||||
["docs-review", getDocsReview, EN_DOCS_REVIEW],
|
||||
["docs-vocabulary", getDocsVocabulary, EN_DOCS_VOCABULARY],
|
||||
["changelog", getChangelog, EN_CHANGELOG],
|
||||
@@ -347,6 +350,7 @@ describe("website dictionaries", () => {
|
||||
["docs-subagents", getDocsSubagents],
|
||||
["docs-troubleshooting", getDocsTroubleshooting],
|
||||
["docs-trust", getDocsTrust],
|
||||
["docs-first-time", getDocsFirstTime],
|
||||
["docs-web", getDocsWeb],
|
||||
["docs-work", getDocsWork],
|
||||
["docs-pet", getDocsPet],
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import type { DocsFirstTimeDict } from "../types";
|
||||
|
||||
export const docsFirstTime: DocsFirstTimeDict = {
|
||||
metaTitle: "Use Codewhale for the first time · Codewhale Docs",
|
||||
metaDescription:
|
||||
"Open a terminal, install Codewhale, pick the folder it works in, connect a model and finish a first task, with no terminal experience needed.",
|
||||
bodyClassName: "text-ink-soft leading-relaxed",
|
||||
title: "Use Codewhale for the first time",
|
||||
lede:
|
||||
"This page takes you from opening a terminal for the first time to finishing a small task with Codewhale. It also explains which folder to start in, because that folder decides what Codewhale can change.",
|
||||
sections: [
|
||||
{
|
||||
id: "what-you-need",
|
||||
title: "Check what you need",
|
||||
blocks: [
|
||||
{
|
||||
p: "You need a Mac, a Windows x64 PC or a Linux computer, and an internet connection for the install. To get answers you also need a model: an API key from a hosted provider such as DeepSeek, or a model running on your own computer with Ollama.",
|
||||
},
|
||||
{
|
||||
p: "Codewhale is free and needs no account. A hosted provider bills you directly for what you use, and a model you run yourself needs no key.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "open-terminal",
|
||||
title: "Open a terminal",
|
||||
blocks: [
|
||||
{
|
||||
p: "A terminal is a window where you type commands instead of clicking. A command runs when you press Return, which is Enter on Windows.",
|
||||
},
|
||||
{
|
||||
rows: [
|
||||
["macOS", "Press Cmd+Space, type Terminal and press Return."],
|
||||
["Windows", "Open the Start menu, type PowerShell and press Enter."],
|
||||
["Linux", "Open your applications menu and search for Terminal. On Ubuntu, Ctrl+Alt+T opens one."],
|
||||
],
|
||||
},
|
||||
{
|
||||
note: "Paste with Cmd+V on a Mac and Ctrl+V on Windows. Most Linux terminals paste with Ctrl+Shift+V.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "install",
|
||||
title: "Install Codewhale",
|
||||
blocks: [
|
||||
{
|
||||
p: "On a Mac or Linux, paste this command and press Return. It downloads Codewhale, checks the download, and adds Codewhale to your PATH, the list of folders your terminal searches for commands.",
|
||||
},
|
||||
{ code: "curl -fsSL https://codewhale.net/install.sh | sh", lang: "macOS · Linux" },
|
||||
{ p: "On Windows, paste this command and press Enter:" },
|
||||
{ code: "winget install --id HunterBown.CodeWhale --exact --source winget", lang: "Windows" },
|
||||
{
|
||||
p: "When it finishes, open a new terminal window so it picks up the new PATH. Then check that it worked: a version number means Codewhale is installed.",
|
||||
},
|
||||
{ code: "codewhale --version", lang: "Terminal" },
|
||||
{
|
||||
p: "If the terminal says `command not found`, follow [the PATH steps on the install page](/install#install-path).",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "choose-folder",
|
||||
title: "Choose the folder to work in",
|
||||
blocks: [
|
||||
{
|
||||
p: "Codewhale works in the folder your terminal is in when you start it. That folder is its workspace: Codewhale reads and changes files there, and its file tools cannot reach other folders unless you allow it.",
|
||||
},
|
||||
{
|
||||
p: "On a Mac, an operating-system sandbox also stops its commands from writing outside the workspace, apart from temporary files. Linux gets the same sandbox when bubblewrap is installed. Windows has no such sandbox yet, so Codewhale relies on asking you first.",
|
||||
},
|
||||
{
|
||||
rows: [
|
||||
["A folder for one project", "The best choice for most work. Codewhale sees only that project, and `/undo` can take back what it changed."],
|
||||
["A new, empty folder", "A good start for something new, such as a website or a script. The next section shows how to make one."],
|
||||
["A folder that holds several projects", "Lets one task read and change files across all of them. Pick the smallest folder that holds everything the task needs; in a very large folder Codewhale turns snapshots off and tells you."],
|
||||
["Your home folder, Desktop, Documents or Downloads", "Codewhale runs there, but `/undo` and snapshots are off in these folders themselves because they are too broad to snapshot safely. A folder inside one of them works normally."],
|
||||
],
|
||||
},
|
||||
{
|
||||
note: "Snapshots are Codewhale's own copies of the files it changes. It keeps them in its settings folder, not in your project, and they work without git.",
|
||||
},
|
||||
{
|
||||
p: "To let Codewhale reach one more folder, type `/trust add` and that folder's path in Codewhale. Full Access mode reaches every folder and runs commands without the sandbox, so use it only when you want Codewhale to work across your whole computer.",
|
||||
},
|
||||
{
|
||||
p: "By default, Codewhale makes file edits in a git repository without asking and shows you what changed. In a folder without git, it asks before each edit.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "open-in-folder",
|
||||
title: "Open a terminal in that folder",
|
||||
blocks: [
|
||||
{
|
||||
rows: [
|
||||
["macOS Finder", "Control-click the folder and choose Services > New Terminal at Folder. If it is missing, turn it on in System Settings > Keyboard > Keyboard Shortcuts > Services > Files and Folders."],
|
||||
["Windows File Explorer", "Right-click the folder and choose Open in Terminal. On Windows 10, hold Shift while you right-click and choose Open PowerShell window here."],
|
||||
["Any terminal", "Type `cd` and a space, drag the folder from Finder or File Explorer into the window, and press Return."],
|
||||
["Without moving", "Start Codewhale with `-C` and the folder's path, for example `codewhale -C ~/Projects/my-site` on a Mac."],
|
||||
],
|
||||
},
|
||||
{ p: "These commands find your way around from inside a terminal. They work on a Mac, on Linux and in PowerShell." },
|
||||
{
|
||||
codeTerms: true,
|
||||
rows: [
|
||||
["pwd", "Shows the folder you are in."],
|
||||
["ls", "Lists what is in it."],
|
||||
["cd Documents", "Moves into the Documents folder inside the current one."],
|
||||
["cd ..", "Moves up one folder."],
|
||||
["mkdir my-site", "Makes a new folder called my-site."],
|
||||
],
|
||||
},
|
||||
{
|
||||
note: "Type the first letters of a folder name and press Tab, and the terminal finishes the name for you.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "connect",
|
||||
title: "Start Codewhale and connect a model",
|
||||
blocks: [
|
||||
{ code: "codewhale", lang: "Terminal" },
|
||||
{
|
||||
p: "Codewhale opens a message box right away. When you send your first message, it asks where your model runs: pick a hosted provider and paste its API key, or pick Ollama when it is running on your computer.",
|
||||
},
|
||||
{
|
||||
p: "Keys you enter stay on your computer, in Codewhale's settings folder `~/.codewhale`. Change the provider later with `/provider`.",
|
||||
},
|
||||
{
|
||||
p: "If Codewhale asks whether to work with the instructions and files in this folder, answer yes for a folder you made yourself.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "first-task",
|
||||
title: "Give it a first task",
|
||||
blocks: [
|
||||
{
|
||||
p: "Type what you want in plain words and press Return. A safe first task changes nothing, for example: `Explain what is in this folder and what each file is for. Do not change anything.`",
|
||||
},
|
||||
{
|
||||
p: "Then try one that makes a file, for example: `Make a file called packing-list.md with a packing list for a weekend trip.`",
|
||||
},
|
||||
{
|
||||
p: "By default, Codewhale asks before it runs a command that is not read-only. Read what the approval says will run before you allow it.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "stop-undo-quit",
|
||||
title: "Stop, undo and quit",
|
||||
blocks: [
|
||||
{
|
||||
codeTerms: true,
|
||||
rows: [
|
||||
["Esc", "Stops the task that is running. Ctrl+C does the same."],
|
||||
["/undo", "Takes back your last request and the file changes it made."],
|
||||
["/diff", "Shows what changed in this folder."],
|
||||
["/receipts", "Lists what Codewhale did: files, commands and approvals."],
|
||||
["/exit", "Quits Codewhale. Ctrl+D also quits when the message box is empty."],
|
||||
["codewhale --continue", "Run it in the same folder to pick up your last conversation there."],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "help",
|
||||
title: "Get help",
|
||||
blocks: [
|
||||
{
|
||||
p: "Type `/help` in Codewhale to list its commands. [Ask in the Codewhale Discord](https://discord.gg/37gfS3ksug), or read [what to do when something goes wrong](/docs/troubleshooting).",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
next: [
|
||||
{
|
||||
href: "/docs/guide",
|
||||
label: "Start your first task",
|
||||
note: "More first prompts and how to check the answer.",
|
||||
},
|
||||
{
|
||||
href: "/docs/auth",
|
||||
label: "Connect a provider",
|
||||
note: "Save a key, check which key is used, or run a local model.",
|
||||
},
|
||||
{
|
||||
href: "/docs/modes",
|
||||
label: "Set modes and approvals",
|
||||
note: "Decide which commands and edits stop for your approval.",
|
||||
},
|
||||
],
|
||||
sourceNote:
|
||||
"Source documents: docs/GUIDE.md, docs/INSTALL.md, docs/MODES.md, docs/SANDBOX.md, docs/KEYBINDINGS.md · Update docs-map.ts when changing.",
|
||||
};
|
||||
@@ -37,6 +37,7 @@ import type {
|
||||
DocsSubagentsDict,
|
||||
DocsTroubleshootingDict,
|
||||
DocsTrustDict,
|
||||
DocsFirstTimeDict,
|
||||
DocsWebDict,
|
||||
DocsWorkDict,
|
||||
DocsPetDict,
|
||||
@@ -100,6 +101,8 @@ import { docsAuth as enDocsAuth } from "./en/docs-auth";
|
||||
import { docsAuth as zhDocsAuth } from "./zh/docs-auth";
|
||||
import { docsTrust as enDocsTrust } from "./en/docs-trust";
|
||||
import { docsTrust as zhDocsTrust } from "./zh/docs-trust";
|
||||
import { docsFirstTime as enDocsFirstTime } from "./en/docs-first-time";
|
||||
import { docsFirstTime as zhDocsFirstTime } from "./zh/docs-first-time";
|
||||
import { docsReview as enDocsReview } from "./en/docs-review";
|
||||
import { docsReview as zhDocsReview } from "./zh/docs-review";
|
||||
import { docsVocabulary as enDocsVocabulary } from "./en/docs-vocabulary";
|
||||
@@ -337,6 +340,10 @@ const DOCS_TRUST: Record<string, DocsTrustDict> = {
|
||||
zh: zhDocsTrust,
|
||||
};
|
||||
|
||||
const DOCS_FIRST_TIME: Record<string, DocsFirstTimeDict> = {
|
||||
zh: zhDocsFirstTime,
|
||||
};
|
||||
|
||||
const DOCS_REVIEW: Record<string, DocsReviewDict> = {
|
||||
zh: zhDocsReview,
|
||||
};
|
||||
@@ -492,6 +499,10 @@ export function getDocsTrust(locale: string): DocsTrustDict {
|
||||
return DOCS_TRUST[locale] ?? enDocsTrust;
|
||||
}
|
||||
|
||||
export function getDocsFirstTime(locale: string): DocsFirstTimeDict {
|
||||
return DOCS_FIRST_TIME[locale] ?? enDocsFirstTime;
|
||||
}
|
||||
|
||||
export function getDocsReview(locale: string): DocsReviewDict {
|
||||
return DOCS_REVIEW[locale] ?? enDocsReview;
|
||||
}
|
||||
@@ -574,6 +585,7 @@ export const EN_DOCS_WORK = enDocsWork;
|
||||
export const EN_DOCS_PET = enDocsPet;
|
||||
export const EN_DOCS_AUTH = enDocsAuth;
|
||||
export const EN_DOCS_TRUST = enDocsTrust;
|
||||
export const EN_DOCS_FIRST_TIME = enDocsFirstTime;
|
||||
export const EN_DOCS_REVIEW = enDocsReview;
|
||||
export const EN_DOCS_VOCABULARY = enDocsVocabulary;
|
||||
export const EN_COMPUTER_USE = enComputerUse;
|
||||
|
||||
@@ -442,6 +442,8 @@ export type DocsAuthDict = DocsPageDict;
|
||||
|
||||
export type DocsTrustDict = DocsPageDict;
|
||||
|
||||
export type DocsFirstTimeDict = DocsPageDict;
|
||||
|
||||
/** `app/[locale]/changelog/page.tsx` — version-aware release record. */
|
||||
export interface ChangelogDict {
|
||||
metaTitle: string;
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import type { DocsFirstTimeDict } from "../types";
|
||||
|
||||
export const docsFirstTime: DocsFirstTimeDict = {
|
||||
metaTitle: "第一次使用 Codewhale · Codewhale 文档",
|
||||
metaDescription:
|
||||
"打开终端、安装 Codewhale、选择它工作的文件夹、连接模型并完成第一个任务,不需要任何终端经验。",
|
||||
bodyClassName: "text-ink-soft leading-[1.9] tracking-wide",
|
||||
title: "第一次使用 Codewhale",
|
||||
lede:
|
||||
"这一页带你从第一次打开终端开始,一直到用 Codewhale 完成一个小任务。它还会说明应该从哪个文件夹启动,因为这个文件夹决定了 Codewhale 能修改什么。",
|
||||
sections: [
|
||||
{
|
||||
id: "what-you-need",
|
||||
title: "确认你需要准备什么",
|
||||
blocks: [
|
||||
{
|
||||
p: "你需要一台 Mac、一台 Windows x64 电脑或一台 Linux 电脑,安装时还需要联网。要得到回答,你还需要一个模型:来自 DeepSeek 等托管提供商的 API 密钥,或者用 Ollama 在你自己电脑上运行的模型。",
|
||||
},
|
||||
{
|
||||
p: "Codewhale 免费,也不需要账号。托管提供商会按你的用量直接向你收费;你自己运行的模型不需要密钥。",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "open-terminal",
|
||||
title: "打开终端",
|
||||
blocks: [
|
||||
{
|
||||
p: "终端是一个用输入命令代替点击的窗口。按下回车键后,命令就会运行。",
|
||||
},
|
||||
{
|
||||
rows: [
|
||||
["macOS", "按 Cmd+空格键,输入 Terminal,然后按回车键。"],
|
||||
["Windows", "打开“开始”菜单,输入 PowerShell,然后按回车键。"],
|
||||
["Linux", "打开应用程序菜单并搜索“终端”。在 Ubuntu 上,按 Ctrl+Alt+T 也能打开。"],
|
||||
],
|
||||
},
|
||||
{
|
||||
note: "在 Mac 上用 Cmd+V 粘贴,在 Windows 上用 Ctrl+V 粘贴。大多数 Linux 终端用 Ctrl+Shift+V 粘贴。",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "install",
|
||||
title: "安装 Codewhale",
|
||||
blocks: [
|
||||
{
|
||||
p: "在 Mac 或 Linux 上,粘贴下面这条命令并按回车键。它会下载 Codewhale、校验下载内容,并把 Codewhale 加入 PATH,也就是终端查找命令时会搜索的文件夹列表。",
|
||||
},
|
||||
{ code: "curl -fsSL https://codewhale.net/install.sh | sh", lang: "macOS · Linux" },
|
||||
{ p: "在 Windows 上,粘贴下面这条命令并按回车键:" },
|
||||
{ code: "winget install --id HunterBown.CodeWhale --exact --source winget", lang: "Windows" },
|
||||
{
|
||||
p: "安装完成后,打开一个新的终端窗口,让它读取新的 PATH。然后确认安装成功:显示版本号就说明 Codewhale 已经安装好了。",
|
||||
},
|
||||
{ code: "codewhale --version", lang: "终端" },
|
||||
{
|
||||
p: "如果终端提示 `command not found`,请按照[安装页面上的 PATH 步骤](/install#install-path)操作。",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "choose-folder",
|
||||
title: "选择工作的文件夹",
|
||||
blocks: [
|
||||
{
|
||||
p: "Codewhale 在你启动它时终端所在的文件夹里工作。这个文件夹就是它的工作区:Codewhale 在这里读取和修改文件,除非你允许,它的文件工具无法访问其他文件夹。",
|
||||
},
|
||||
{
|
||||
p: "在 Mac 上,操作系统沙箱还会阻止它的命令写入工作区以外的位置(临时文件除外)。在 Linux 上,安装 bubblewrap 后也有同样的沙箱。Windows 目前还没有这种沙箱,因此 Codewhale 依靠先征求你的同意。",
|
||||
},
|
||||
{
|
||||
rows: [
|
||||
["只放一个项目的文件夹", "大多数情况下的最佳选择。Codewhale 只看到这个项目,`/undo` 可以撤回它做的修改。"],
|
||||
["一个新的空文件夹", "适合开始做新东西,比如一个网站或一个脚本。下一节会说明如何创建。"],
|
||||
["包含多个项目的文件夹", "让一个任务可以读取和修改所有这些项目里的文件。请选择能装下任务所需全部内容的最小文件夹;文件夹非常大时,Codewhale 会关闭快照并告诉你。"],
|
||||
["你的主文件夹、桌面、文稿或下载", "Codewhale 可以在这里运行,但在这些文件夹本身中 `/undo` 和快照是关闭的,因为它们范围太大,无法安全地做快照。它们里面的子文件夹可以正常使用。"],
|
||||
],
|
||||
},
|
||||
{
|
||||
note: "快照是 Codewhale 为它修改的文件保存的副本。它们保存在 Codewhale 的设置文件夹里,而不是你的项目里,并且不需要 git。",
|
||||
},
|
||||
{
|
||||
p: "要让 Codewhale 多访问一个文件夹,请在 Codewhale 中输入 `/trust add` 和该文件夹的路径。Full Access 模式可以访问所有文件夹,并且在没有沙箱的情况下运行命令,所以只有在你希望 Codewhale 在整台电脑范围内工作时才使用它。",
|
||||
},
|
||||
{
|
||||
p: "默认情况下,在 git 仓库中 Codewhale 会直接修改文件,并向你展示改了什么。在没有 git 的文件夹中,它会在每次修改前询问你。",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "open-in-folder",
|
||||
title: "在那个文件夹中打开终端",
|
||||
blocks: [
|
||||
{
|
||||
rows: [
|
||||
["macOS 访达", "按住 Control 点按该文件夹,选择“服务”>“新建位于文件夹位置的终端窗口”。如果没有这一项,请在“系统设置”>“键盘”>“键盘快捷键”>“服务”>“文件和文件夹”中打开它。"],
|
||||
["Windows 文件资源管理器", "右键点击该文件夹,选择“在终端中打开”。在 Windows 10 上,按住 Shift 再右键点击,选择“在此处打开 PowerShell 窗口”。"],
|
||||
["任意终端", "输入 `cd` 和一个空格,把文件夹从访达或文件资源管理器拖进窗口,然后按回车键。"],
|
||||
["不切换文件夹", "启动 Codewhale 时加上 `-C` 和文件夹路径,例如在 Mac 上输入 `codewhale -C ~/Projects/my-site`。"],
|
||||
],
|
||||
},
|
||||
{ p: "下面这些命令可以在终端里切换和查看文件夹。它们在 Mac、Linux 和 PowerShell 中都能用。" },
|
||||
{
|
||||
codeTerms: true,
|
||||
rows: [
|
||||
["pwd", "显示你当前所在的文件夹。"],
|
||||
["ls", "列出其中的内容。"],
|
||||
["cd Documents", "进入当前文件夹里的 Documents 文件夹。"],
|
||||
["cd ..", "回到上一级文件夹。"],
|
||||
["mkdir my-site", "新建一个名为 my-site 的文件夹。"],
|
||||
],
|
||||
},
|
||||
{
|
||||
note: "输入文件夹名的前几个字母后按 Tab 键,终端会帮你补全名字。",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "connect",
|
||||
title: "启动 Codewhale 并连接模型",
|
||||
blocks: [
|
||||
{ code: "codewhale", lang: "终端" },
|
||||
{
|
||||
p: "Codewhale 会立即打开一个输入框。你发送第一条消息时,它会询问模型在哪里运行:选择一个托管提供商并粘贴它的 API 密钥,或者在你的电脑上运行着 Ollama 时选择 Ollama。",
|
||||
},
|
||||
{
|
||||
p: "你输入的密钥保存在你自己的电脑上,位于 Codewhale 的设置文件夹 `~/.codewhale` 中。之后可以用 `/provider` 更换提供商。",
|
||||
},
|
||||
{
|
||||
p: "如果 Codewhale 询问是否使用这个文件夹中的说明和文件,对于你自己创建的文件夹,回答“是”即可。",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "first-task",
|
||||
title: "交给它第一个任务",
|
||||
blocks: [
|
||||
{
|
||||
p: "用平常的话写下你想做的事,然后按回车键。安全的第一个任务不修改任何东西,例如:`说明这个文件夹里有什么,以及每个文件的用途。不要修改任何内容。`",
|
||||
},
|
||||
{
|
||||
p: "然后试一个会创建文件的任务,例如:`创建一个名为 packing-list.md 的文件,写一份周末出游的行李清单。`",
|
||||
},
|
||||
{
|
||||
p: "默认情况下,Codewhale 在运行非只读命令前会先询问你。在允许之前,请先看清审批里写明要运行什么。",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "stop-undo-quit",
|
||||
title: "停止、撤回和退出",
|
||||
blocks: [
|
||||
{
|
||||
codeTerms: true,
|
||||
rows: [
|
||||
["Esc", "停止正在运行的任务。Ctrl+C 的作用相同。"],
|
||||
["/undo", "撤回你的上一个请求及其造成的文件修改。"],
|
||||
["/diff", "显示这个文件夹中改了什么。"],
|
||||
["/receipts", "列出 Codewhale 做过的事:文件、命令和审批。"],
|
||||
["/exit", "退出 Codewhale。输入框为空时,按 Ctrl+D 也会退出。"],
|
||||
["codewhale --continue", "在同一个文件夹中运行它,可以继续你在那里的上一次对话。"],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "help",
|
||||
title: "获取帮助",
|
||||
blocks: [
|
||||
{
|
||||
p: "在 Codewhale 中输入 `/help` 可以列出它的命令。你也可以[在 Codewhale Discord 中提问](https://discord.gg/37gfS3ksug),或阅读[出现问题时怎么办](/docs/troubleshooting)。",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
next: [
|
||||
{
|
||||
href: "/docs/guide",
|
||||
label: "开始第一个任务",
|
||||
note: "更多第一个提示词的示例,以及如何检查回答。",
|
||||
},
|
||||
{
|
||||
href: "/docs/auth",
|
||||
label: "连接模型提供商",
|
||||
note: "保存密钥、查看当前使用的密钥,或运行本地模型。",
|
||||
},
|
||||
{
|
||||
href: "/docs/modes",
|
||||
label: "设置模式与审批",
|
||||
note: "决定哪些命令和修改需要你批准。",
|
||||
},
|
||||
],
|
||||
sourceNote:
|
||||
"来源文档:docs/GUIDE.md、docs/INSTALL.md、docs/MODES.md、docs/SANDBOX.md、docs/KEYBINDINGS.md · 修改时请同步更新 docs-map.ts。",
|
||||
};
|
||||
@@ -16,6 +16,7 @@ export const DOCS_TOPICS = [
|
||||
"computers",
|
||||
"configuration",
|
||||
"constitution",
|
||||
"first-time",
|
||||
"fleet",
|
||||
"guide",
|
||||
"hooks",
|
||||
|
||||
@@ -96,6 +96,23 @@ function installLines(facts: RepoFacts): string[] {
|
||||
"",
|
||||
...link(I.scriptLink, `${SITE_URL}/install.sh`),
|
||||
...(version ? p(I.releaseLine) : []),
|
||||
...h(I.newTitle),
|
||||
...p(I.newLead),
|
||||
...h(I.newMacTitle, 3),
|
||||
`1. ${t(I.newMac1)}`,
|
||||
`2. ${t(I.newMac2)}`,
|
||||
"",
|
||||
...code(INSTALL_COMMANDS.shell),
|
||||
`3. ${t(I.newMac3)}`,
|
||||
"",
|
||||
...h(I.newWinTitle, 3),
|
||||
`1. ${t(I.newWin1)}`,
|
||||
`2. ${t(I.newWin2)}`,
|
||||
"",
|
||||
...code(INSTALL_COMMANDS.windows),
|
||||
`3. ${t(I.newWin3)}`,
|
||||
"",
|
||||
...link(I.newNext, `${SITE_URL}/en/docs/first-time`),
|
||||
...h(I.pathTitle),
|
||||
...p(I.pathPlaces),
|
||||
...bullets([I.pathZsh, I.pathBashLinux, I.pathBashMac, I.pathOther, I.pathFish]),
|
||||
|
||||
@@ -42,7 +42,7 @@ export const RELEASE_CONTRIBUTORS: string[] = [
|
||||
* one. scripts/check-contributor-credit.py requires them here now; the release
|
||||
* cut moves them into RELEASE_CONTRIBUTORS with that release's changelog block.
|
||||
*/
|
||||
export const UNRELEASED_CONTRIBUTORS: string[] = ["@luochen211", "@Lstarsky0", "@SamhandsomeLee", "@gaord"];
|
||||
export const UNRELEASED_CONTRIBUTORS: string[] = ["@luochen211", "@Lstarsky0", "@SamhandsomeLee", "@gaord", "@MannXo"];
|
||||
|
||||
/**
|
||||
* Contributors who helped with reports, reproductions, and verification.
|
||||
|
||||
@@ -49,6 +49,7 @@ const STEMS = {
|
||||
"docs-web": { exportName: "docsWeb", typeName: "DocsWebDict" },
|
||||
"docs-auth": { exportName: "docsAuth", typeName: "DocsAuthDict" },
|
||||
"docs-trust": { exportName: "docsTrust", typeName: "DocsTrustDict" },
|
||||
"docs-first-time": { exportName: "docsFirstTime", typeName: "DocsFirstTimeDict" },
|
||||
states: { exportName: "states", typeName: "StatesDict" },
|
||||
changelog: { exportName: "changelog", typeName: "ChangelogDict" },
|
||||
"computer-use": { exportName: "computerUse", typeName: "ComputerUseDict" },
|
||||
|
||||
Reference in new issue
Block a user