mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-13 13:41:28 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14cd110703 | ||
|
|
ef02f41a8c | ||
|
|
b0361216c2 |
No files matched your search
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **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).
|
||||
- **Qwen Code as an external agent:** Codewhale now finds an installed Qwen Code CLI (`qwen`, 0.6.1 or newer) and can run it as a session over ACP with `qwen --acp`. `GET /v1/agents/external` lists it, an older install is reported as needing `npm i -g @qwen-code/qwen-code@latest`, and sign-in stays inside Qwen Code (`/auth`). Thanks [@MannXo](https://github.com/MannXo) (PR 7293).
|
||||
- **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.
|
||||
|
||||
@@ -3115,6 +3115,10 @@ fn write_exec_stdout(text: &str) -> Result<()> {
|
||||
.and_then(|()| stdout.flush())
|
||||
{
|
||||
Err(err) if err.kind() == io::ErrorKind::BrokenPipe => {
|
||||
let _ = codewhale_telemetry::report_termination(
|
||||
codewhale_telemetry::ExitClass::Signal,
|
||||
codewhale_telemetry::SIGNAL_FLUSH_TIMEOUT,
|
||||
);
|
||||
// SAFETY: restores the default disposition and re-raises the
|
||||
// signal the write would have delivered without SIG_IGN.
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -293,11 +293,17 @@ fn dispatch_and_read_telemetry_with_endpoint(
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(error) => panic!("read telemetry dry-run sink: {error}"),
|
||||
};
|
||||
let pending = match fs::read_to_string(telemetry_dir.join("buffer.jsonl")) {
|
||||
Ok(contents) => Some(contents),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(error) => panic!("read telemetry pending buffer: {error}"),
|
||||
};
|
||||
let pending = fs::read_dir(telemetry_dir.join("spool"))
|
||||
.ok()
|
||||
.and_then(|entries| {
|
||||
let bodies: Vec<String> = entries
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| path.extension().is_some_and(|ext| ext == "jsonl"))
|
||||
.filter_map(|path| fs::read_to_string(path).ok())
|
||||
.collect();
|
||||
(!bodies.is_empty()).then(|| bodies.join("\n"))
|
||||
});
|
||||
DispatchEvidence {
|
||||
telemetry_dir_exists: telemetry_dir.exists(),
|
||||
dry_run,
|
||||
|
||||
@@ -605,7 +605,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
|
||||
def(
|
||||
"show_thinking",
|
||||
SettingKind::Bool(SHOW_THINKING),
|
||||
"true",
|
||||
"false",
|
||||
ui(
|
||||
TAB_APPEARANCE,
|
||||
"display",
|
||||
@@ -1546,12 +1546,6 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
|
||||
"false",
|
||||
None,
|
||||
),
|
||||
def(
|
||||
"account_preferences_notice_shown",
|
||||
SettingKind::String,
|
||||
"",
|
||||
None,
|
||||
),
|
||||
// Round 3 work-bar placement migration ran once (`top` → `bottom`).
|
||||
def(
|
||||
"work_surface_bottom_migrated",
|
||||
|
||||
@@ -244,38 +244,22 @@ pub async fn capture(
|
||||
if let Some(snapshot) = supplied {
|
||||
return snapshot.render().map(Some);
|
||||
}
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
{
|
||||
if let Some(message) = TEST_ACCOUNT_LOAD_FAILURE.get() {
|
||||
anyhow::bail!(message);
|
||||
}
|
||||
if !account_api_base_is_loopback() {
|
||||
return Ok(crate::prompts::load_user_constitution_block());
|
||||
}
|
||||
}
|
||||
// Unit tests never read an operator's account credentials. Transport tests
|
||||
// exercise load_from with an explicitly isolated store instead.
|
||||
#[cfg(not(any(test, feature = "test-support")))]
|
||||
if let Some(snapshot) = load(profile).await? {
|
||||
return snapshot.render().map(Some);
|
||||
}
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
{
|
||||
let _ = profile;
|
||||
if let Some(message) = TEST_ACCOUNT_LOAD_FAILURE.get() {
|
||||
anyhow::bail!(message);
|
||||
}
|
||||
}
|
||||
Ok(crate::prompts::load_user_constitution_block())
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
fn account_api_base_is_loopback() -> bool {
|
||||
std::env::var(codewhale_secrets::account::ACCOUNT_API_BASE_ENV)
|
||||
.ok()
|
||||
.and_then(|value| normalize_runtime_account_api_base(&value))
|
||||
.and_then(|base| reqwest::Url::parse(&base).ok())
|
||||
.and_then(|url| url.host_str().map(str::to_owned))
|
||||
.is_some_and(|host| {
|
||||
host.eq_ignore_ascii_case("localhost")
|
||||
|| host
|
||||
.trim_start_matches('[')
|
||||
.trim_end_matches(']')
|
||||
.parse::<std::net::IpAddr>()
|
||||
.is_ok_and(|address| address.is_loopback())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
thread_local! {
|
||||
static TEST_ACCOUNT_LOAD_FAILURE: std::cell::Cell<Option<&'static str>> =
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
"TerminalDockLive": "actiu",
|
||||
"ExtensionPromptUnavailable": "Les instruccions de les extensions no estan disponibles; consulta /plugin show.",
|
||||
"ProfileConstitutionUnavailableLocal": "No s'han pogut carregar les preferències del compte de Codewhale; aquest torn fa servir la configuració local. La clau del proveïdor continua funcionant; executa codewhale login per recuperar les preferències del compte.",
|
||||
"AccountPreferencesNotLoaded": "Compte no carregat. Executa {command}. La clau del proveïdor continua funcionant.",
|
||||
"ComposerOversizedSubmitHeld": "No s’ha enviat: el missatge supera els {limit} caràcters i no s’ha pogut desar en un fitxer de text enganxat ({error}). El text complet continua al camp d’entrada: escurça’l o habilita l’escriptura a .codewhale/pastes i torna a prémer Enter.",
|
||||
"ComposerOversizedSubmitHeldShort": "No s’ha enviat: supera els {limit} caràcters; no s’ha desat el fitxer d’enganxat.",
|
||||
"CmdPluginOwnerReport": "Amfitrió d’extensions:\n Estat: {state}\n Eines actives ({count}): {tools}",
|
||||
@@ -1668,7 +1667,7 @@
|
||||
"PhaseIdle": "inactiu",
|
||||
"PhaseDraft": "esborrany",
|
||||
"PhaseWorking": "treballant",
|
||||
"PhaseReasoning": "pensant",
|
||||
"PhaseReasoning": "raonant",
|
||||
"PhaseReading": "llegint",
|
||||
"PhaseUsingTool": "usant una eina",
|
||||
"PhaseSubagents": "subagents en execució",
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
"TerminalDockLive": "aktiv",
|
||||
"ExtensionPromptUnavailable": "Anweisungen von Erweiterungen sind nicht verfügbar; prüfe /plugin show.",
|
||||
"ProfileConstitutionUnavailableLocal": "Die Codewhale-Kontoeinstellungen konnten nicht geladen werden; dieser Durchlauf nutzt deine lokalen Einstellungen. Dein Anbieter-Schlüssel funktioniert weiter; führe codewhale login aus, um die Kontoeinstellungen wiederherzustellen.",
|
||||
"AccountPreferencesNotLoaded": "Konto nicht geladen. Führe {command} aus. Dein Anbieter-Schlüssel funktioniert weiter.",
|
||||
"ComposerOversizedSubmitHeld": "Nicht gesendet: Diese Nachricht enthält mehr als {limit} Zeichen und konnte nicht als Einfügedatei gespeichert werden ({error}). Der vollständige Text steht noch im Eingabefeld: Kürze ihn oder mache .codewhale/pastes beschreibbar und drücke dann erneut Enter.",
|
||||
"ComposerOversizedSubmitHeldShort": "Nicht gesendet: über {limit} Zeichen; Einfügedatei nicht gespeichert.",
|
||||
"CmdPluginOwnerReport": "Erweiterungshost:\n Status: {state}\n Aktive Werkzeuge ({count}): {tools}",
|
||||
@@ -1668,7 +1667,7 @@
|
||||
"PhaseIdle": "ruhend",
|
||||
"PhaseDraft": "entwurf",
|
||||
"PhaseWorking": "arbeitet",
|
||||
"PhaseReasoning": "denkt nach",
|
||||
"PhaseReasoning": "überlegt",
|
||||
"PhaseReading": "liest",
|
||||
"PhaseUsingTool": "nutzt Tool",
|
||||
"PhaseSubagents": "Subagenten laufen",
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
"TerminalDockLive": "live",
|
||||
"ExtensionPromptUnavailable": "Extension instructions are unavailable; inspect /plugin show.",
|
||||
"ProfileConstitutionUnavailableLocal": "Codewhale account preferences could not be loaded, so this turn uses your local settings. Your provider key still works; run codewhale login to restore account preferences.",
|
||||
"AccountPreferencesNotLoaded": "Account not loaded. Run {command}. Your provider key still works.",
|
||||
"ComposerOversizedSubmitHeld": "Not sent: this message is over {limit} characters and could not be saved as a paste file ({error}). The full text is still in the composer: shorten it, or make .codewhale/pastes writable, then press Enter again.",
|
||||
"ComposerOversizedSubmitHeldShort": "Not sent: over {limit} characters; paste file not saved.",
|
||||
"CmdPluginOwnerReport": "Extension host:\n State: {state}\n Live tools ({count}): {tools}",
|
||||
@@ -1691,7 +1690,7 @@
|
||||
"PhaseIdle": "idle",
|
||||
"PhaseDraft": "draft",
|
||||
"PhaseWorking": "working",
|
||||
"PhaseReasoning": "thinking",
|
||||
"PhaseReasoning": "reasoning",
|
||||
"PhaseReading": "reading",
|
||||
"PhaseUsingTool": "using tool",
|
||||
"PhaseSubagents": "agents underway",
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
"TerminalDockLive": "activo",
|
||||
"ExtensionPromptUnavailable": "Las instrucciones de las extensiones no están disponibles; revisa /plugin show.",
|
||||
"ProfileConstitutionUnavailableLocal": "No se pudieron cargar las preferencias de la cuenta de Codewhale; este turno usa tu configuración local. Tu clave del proveedor sigue funcionando; ejecuta codewhale login para restaurar las preferencias de la cuenta.",
|
||||
"AccountPreferencesNotLoaded": "Cuenta no cargada. Ejecuta {command}. Tu clave del proveedor sigue funcionando.",
|
||||
"ComposerOversizedSubmitHeld": "No se envió: este mensaje supera los {limit} caracteres y no se pudo guardar en un archivo de texto pegado ({error}). El texto completo sigue en el campo de entrada: acórtalo o habilita la escritura en .codewhale/pastes y vuelve a presionar Enter.",
|
||||
"ComposerOversizedSubmitHeldShort": "No se envió: supera los {limit} caracteres; no se guardó el archivo pegado.",
|
||||
"CmdPluginOwnerReport": "Host de extensiones:\n Estado: {state}\n Herramientas activas ({count}): {tools}",
|
||||
@@ -1691,7 +1690,7 @@
|
||||
"PhaseIdle": "inactivo",
|
||||
"PhaseDraft": "borrador",
|
||||
"PhaseWorking": "trabajando",
|
||||
"PhaseReasoning": "pensando",
|
||||
"PhaseReasoning": "razonando",
|
||||
"PhaseReading": "leyendo",
|
||||
"PhaseUsingTool": "usando herramienta",
|
||||
"PhaseSubagents": "subagentes en curso",
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
"TerminalDockLive": "actif",
|
||||
"ExtensionPromptUnavailable": "Les instructions des extensions sont indisponibles ; consultez /plugin show.",
|
||||
"ProfileConstitutionUnavailableLocal": "Les préférences du compte Codewhale n'ont pas pu être chargées ; ce tour utilise vos réglages locaux. Votre clé de fournisseur fonctionne toujours ; lancez codewhale login pour rétablir les préférences du compte.",
|
||||
"AccountPreferencesNotLoaded": "Compte non chargé. Lancez {command}. Votre clé de fournisseur fonctionne toujours.",
|
||||
"ComposerOversizedSubmitHeld": "Non envoyé : ce message dépasse {limit} caractères et n’a pas pu être enregistré dans un fichier de texte collé ({error}). Le texte intégral reste dans la zone de saisie : raccourcissez-le ou autorisez l’écriture dans .codewhale/pastes, puis appuyez à nouveau sur Enter.",
|
||||
"ComposerOversizedSubmitHeldShort": "Non envoyé : plus de {limit} caractères ; fichier collé non enregistré.",
|
||||
"CmdPluginOwnerReport": "Hôte des extensions :\n État : {state}\n Outils actifs ({count}) : {tools}",
|
||||
@@ -1668,7 +1667,7 @@
|
||||
"PhaseIdle": "inactif",
|
||||
"PhaseDraft": "brouillon",
|
||||
"PhaseWorking": "travail en cours",
|
||||
"PhaseReasoning": "réflexion",
|
||||
"PhaseReasoning": "raisonnement",
|
||||
"PhaseReading": "lecture",
|
||||
"PhaseUsingTool": "utilisation d'outil",
|
||||
"PhaseSubagents": "sous-agents en cours",
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
"TerminalDockLive": "सक्रिय",
|
||||
"ExtensionPromptUnavailable": "एक्सटेंशन के निर्देश उपलब्ध नहीं हैं; /plugin show से जाँचें।",
|
||||
"ProfileConstitutionUnavailableLocal": "Codewhale खाते की प्राथमिकताएँ लोड नहीं हो सकीं, इसलिए यह टर्न आपकी स्थानीय सेटिंग्स का उपयोग करता है। आपकी प्रदाता कुंजी काम करती रहेगी; खाते की प्राथमिकताएँ वापस लाने के लिए codewhale login चलाएँ।",
|
||||
"AccountPreferencesNotLoaded": "खाता लोड नहीं हुआ। {command} चलाएँ। आपकी प्रदाता कुंजी काम करती रहेगी।",
|
||||
"ComposerOversizedSubmitHeld": "नहीं भेजा गया: इस संदेश में {limit} से अधिक वर्ण हैं और इसे पेस्ट फ़ाइल के रूप में सहेजा नहीं जा सका ({error})। पूरा पाठ अभी भी इनपुट बॉक्स में है: इसे छोटा करें या .codewhale/pastes में लिखने की अनुमति दें, फिर Enter दबाएँ।",
|
||||
"ComposerOversizedSubmitHeldShort": "नहीं भेजा गया: {limit} से अधिक वर्ण; पेस्ट फ़ाइल सहेजी नहीं गई।",
|
||||
"CmdPluginOwnerReport": "एक्सटेंशन होस्ट:\n स्थिति: {state}\n सक्रिय टूल ({count}): {tools}",
|
||||
@@ -1668,7 +1667,7 @@
|
||||
"PhaseIdle": "निष्क्रिय",
|
||||
"PhaseDraft": "मसौदा",
|
||||
"PhaseWorking": "काम चल रहा",
|
||||
"PhaseReasoning": "सोच रहा",
|
||||
"PhaseReasoning": "तर्क चल रहा",
|
||||
"PhaseReading": "पढ़ रहा",
|
||||
"PhaseUsingTool": "टूल इस्तेमाल हो रहा",
|
||||
"PhaseSubagents": "सबएजेंट चल रहे हैं",
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
"TerminalDockLive": "aktif",
|
||||
"ExtensionPromptUnavailable": "Instruksi ekstensi tidak tersedia; periksa /plugin show.",
|
||||
"ProfileConstitutionUnavailableLocal": "Preferensi akun Codewhale tidak dapat dimuat, jadi giliran ini memakai pengaturan lokal. Kunci penyedia tetap berfungsi; jalankan codewhale login untuk memulihkan preferensi akun.",
|
||||
"AccountPreferencesNotLoaded": "Akun tidak dimuat. Jalankan {command}. Kunci penyedia tetap berfungsi.",
|
||||
"ComposerOversizedSubmitHeld": "Belum terkirim: pesan ini melebihi {limit} karakter dan tidak dapat disimpan sebagai berkas teks tempelan ({error}). Teks lengkap masih ada di kolom input: persingkat teks atau izinkan penulisan ke .codewhale/pastes, lalu tekan Enter lagi.",
|
||||
"ComposerOversizedSubmitHeldShort": "Belum terkirim: melebihi {limit} karakter; berkas tempelan tidak tersimpan.",
|
||||
"CmdPluginOwnerReport": "Host ekstensi:\n Status: {state}\n Alat aktif ({count}): {tools}",
|
||||
@@ -1668,7 +1667,7 @@
|
||||
"PhaseIdle": "diam",
|
||||
"PhaseDraft": "draf",
|
||||
"PhaseWorking": "bekerja",
|
||||
"PhaseReasoning": "berpikir",
|
||||
"PhaseReasoning": "menalar",
|
||||
"PhaseReading": "membaca",
|
||||
"PhaseUsingTool": "memakai tool",
|
||||
"PhaseSubagents": "subagen berjalan",
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
"TerminalDockLive": "稼働中",
|
||||
"ExtensionPromptUnavailable": "拡張機能の指示を利用できません。/plugin show で確認してください。",
|
||||
"ProfileConstitutionUnavailableLocal": "Codewhale アカウントの設定を読み込めなかったため、このターンはローカル設定を使います。プロバイダーキーはそのまま使えます。アカウント設定を戻すには codewhale login を実行してください。",
|
||||
"AccountPreferencesNotLoaded": "アカウントを読み込めませんでした。{command} を実行してください。プロバイダーキーはそのまま使えます。",
|
||||
"ComposerOversizedSubmitHeld": "未送信:メッセージが {limit} 文字を超えており、貼り付けファイルに保存できませんでした({error})。全文は入力欄に残っています。短くするか .codewhale/pastes を書き込み可能にしてから、もう一度 Enter を押してください。",
|
||||
"ComposerOversizedSubmitHeldShort": "未送信:{limit} 文字超。貼り付けファイルは未保存です。",
|
||||
"CmdPluginOwnerReport": "拡張機能ホスト:\n 状態: {state}\n 有効なツール ({count}): {tools}",
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
"TerminalDockLive": "실행 중",
|
||||
"ExtensionPromptUnavailable": "확장 기능의 지침을 사용할 수 없습니다. /plugin show에서 확인하세요.",
|
||||
"ProfileConstitutionUnavailableLocal": "Codewhale 계정 설정을 불러오지 못해 이번 턴은 로컬 설정을 사용합니다. 공급자 키는 계속 사용할 수 있으며, 계정 설정을 복원하려면 codewhale login을 실행하세요.",
|
||||
"AccountPreferencesNotLoaded": "계정을 불러오지 못했습니다. {command}을 실행하세요. 공급자 키는 계속 사용할 수 있습니다.",
|
||||
"ComposerOversizedSubmitHeld": "전송하지 못했습니다. 메시지가 {limit}자를 초과하며 붙여넣기 파일로 저장할 수 없습니다({error}). 전체 텍스트는 입력란에 그대로 있습니다. 내용을 줄이거나 .codewhale/pastes에 쓰기 권한을 부여한 뒤 Enter를 다시 누르세요.",
|
||||
"ComposerOversizedSubmitHeldShort": "전송 안 됨: {limit}자 초과, 붙여넣기 파일 저장 실패.",
|
||||
"CmdPluginOwnerReport": "확장 호스트:\n 상태: {state}\n 활성 도구 ({count}): {tools}",
|
||||
@@ -1691,7 +1690,7 @@
|
||||
"PhaseIdle": "유휴",
|
||||
"PhaseDraft": "초안",
|
||||
"PhaseWorking": "작업 중",
|
||||
"PhaseReasoning": "생각 중",
|
||||
"PhaseReasoning": "추론 중",
|
||||
"PhaseReading": "읽는 중",
|
||||
"PhaseUsingTool": "도구 사용 중",
|
||||
"PhaseSubagents": "서브에이전트 실행 중",
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
"TerminalDockLive": "ativo",
|
||||
"ExtensionPromptUnavailable": "As instruções das extensões não estão disponíveis; confira /plugin show.",
|
||||
"ProfileConstitutionUnavailableLocal": "Não foi possível carregar as preferências da conta Codewhale; este turno usa suas configurações locais. Sua chave do provedor continua funcionando; execute codewhale login para restaurar as preferências da conta.",
|
||||
"AccountPreferencesNotLoaded": "Conta não carregada. Execute {command}. Sua chave do provedor continua funcionando.",
|
||||
"ComposerOversizedSubmitHeld": "Não enviado: esta mensagem excede {limit} caracteres e não pôde ser salva em um arquivo de texto colado ({error}). O texto completo continua no campo de entrada: reduza-o ou permita a gravação em .codewhale/pastes e pressione Enter novamente.",
|
||||
"ComposerOversizedSubmitHeldShort": "Não enviado: acima de {limit} caracteres; arquivo colado não salvo.",
|
||||
"CmdPluginOwnerReport": "Host de extensões:\n Estado: {state}\n Ferramentas ativas ({count}): {tools}",
|
||||
@@ -1691,7 +1690,7 @@
|
||||
"PhaseIdle": "ocioso",
|
||||
"PhaseDraft": "rascunho",
|
||||
"PhaseWorking": "trabalhando",
|
||||
"PhaseReasoning": "pensando",
|
||||
"PhaseReasoning": "raciocinando",
|
||||
"PhaseReading": "lendo",
|
||||
"PhaseUsingTool": "usando ferramenta",
|
||||
"PhaseSubagents": "subagentes em andamento",
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
"TerminalDockLive": "активен",
|
||||
"ExtensionPromptUnavailable": "Инструкции расширений недоступны; проверьте /plugin show.",
|
||||
"ProfileConstitutionUnavailableLocal": "Не удалось загрузить настройки аккаунта Codewhale, поэтому этот ход использует локальные настройки. Ключ провайдера продолжает работать; выполните codewhale login, чтобы вернуть настройки аккаунта.",
|
||||
"AccountPreferencesNotLoaded": "Аккаунт не загружен. Выполните {command}. Ключ провайдера продолжает работать.",
|
||||
"ComposerOversizedSubmitHeld": "Не отправлено: сообщение длиннее {limit} символов, и его не удалось сохранить в файл вставленного текста ({error}). Полный текст остался в поле ввода: сократите его или разрешите запись в .codewhale/pastes, затем снова нажмите Enter.",
|
||||
"ComposerOversizedSubmitHeldShort": "Не отправлено: больше {limit} символов; файл вставки не сохранён.",
|
||||
"CmdPluginOwnerReport": "Хост расширений:\n Состояние: {state}\n Активные инструменты ({count}): {tools}",
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
"TerminalDockLive": "активний",
|
||||
"ExtensionPromptUnavailable": "Інструкції розширень недоступні; перевірте /plugin show.",
|
||||
"ProfileConstitutionUnavailableLocal": "Не вдалося завантажити налаштування облікового запису Codewhale, тому цей хід використовує локальні налаштування. Ключ провайдера й далі працює; виконайте codewhale login, щоб повернути налаштування облікового запису.",
|
||||
"AccountPreferencesNotLoaded": "Обліковий запис не завантажено. Виконайте {command}. Ключ провайдера й далі працює.",
|
||||
"ComposerOversizedSubmitHeld": "Не надіслано: повідомлення перевищує {limit} символів, і його не вдалося зберегти у файл вставленого тексту ({error}). Повний текст залишається в полі введення: скоротіть його або дозвольте запис у .codewhale/pastes, а потім знову натисніть Enter.",
|
||||
"ComposerOversizedSubmitHeldShort": "Не надіслано: понад {limit} символів; файл вставки не збережено.",
|
||||
"CmdPluginOwnerReport": "Хост розширень:\n Стан: {state}\n Активні інструменти ({count}): {tools}",
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
"TerminalDockLive": "đang chạy",
|
||||
"ExtensionPromptUnavailable": "Không thể tải hướng dẫn của tiện ích mở rộng; kiểm tra /plugin show.",
|
||||
"ProfileConstitutionUnavailableLocal": "Không thể tải tùy chọn tài khoản Codewhale nên lượt này dùng cài đặt cục bộ. Khóa nhà cung cấp của bạn vẫn hoạt động; chạy codewhale login để khôi phục tùy chọn tài khoản.",
|
||||
"AccountPreferencesNotLoaded": "Chưa tải được tài khoản. Chạy {command}. Khóa nhà cung cấp của bạn vẫn hoạt động.",
|
||||
"ComposerOversizedSubmitHeld": "Chưa gửi: tin nhắn vượt quá {limit} ký tự và không thể lưu thành tệp văn bản đã dán ({error}). Toàn bộ nội dung vẫn còn trong ô nhập: hãy rút ngắn hoặc cho phép ghi vào .codewhale/pastes, rồi nhấn Enter lần nữa.",
|
||||
"ComposerOversizedSubmitHeldShort": "Chưa gửi: vượt quá {limit} ký tự; chưa lưu tệp dán.",
|
||||
"CmdPluginOwnerReport": "Máy chủ tiện ích mở rộng:\n Trạng thái: {state}\n Công cụ đang hoạt động ({count}): {tools}",
|
||||
@@ -1691,7 +1690,7 @@
|
||||
"PhaseIdle": "rảnh",
|
||||
"PhaseDraft": "nháp",
|
||||
"PhaseWorking": "đang xử lý",
|
||||
"PhaseReasoning": "đang suy nghĩ",
|
||||
"PhaseReasoning": "đang suy luận",
|
||||
"PhaseReading": "đang đọc",
|
||||
"PhaseUsingTool": "đang dùng công cụ",
|
||||
"PhaseSubagents": "đang chạy tác tử con",
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
"TerminalDockLive": "运行中",
|
||||
"ExtensionPromptUnavailable": "扩展指令不可用;请通过 /plugin show 查看。",
|
||||
"ProfileConstitutionUnavailableLocal": "无法加载 Codewhale 账号偏好,本轮改用本地设置。你的提供商密钥仍可正常使用;运行 codewhale login 可恢复账号偏好。",
|
||||
"AccountPreferencesNotLoaded": "账号未加载。请运行 {command}。你的提供商密钥仍可正常使用。",
|
||||
"ComposerOversizedSubmitHeld": "未发送:消息超过 {limit} 个字符,无法保存为粘贴文本文件({error})。完整文本仍保留在输入框中:请缩短内容,或允许写入 .codewhale/pastes,然后再次按 Enter。",
|
||||
"ComposerOversizedSubmitHeldShort": "未发送:超过 {limit} 个字符;粘贴文件未保存。",
|
||||
"CmdPluginOwnerReport": "扩展宿主:\n 状态:{state}\n 活动工具({count}):{tools}",
|
||||
@@ -1691,7 +1690,7 @@
|
||||
"PhaseIdle": "空闲",
|
||||
"PhaseDraft": "草稿",
|
||||
"PhaseWorking": "工作中",
|
||||
"PhaseReasoning": "思考中",
|
||||
"PhaseReasoning": "推理中",
|
||||
"PhaseReading": "读取中",
|
||||
"PhaseUsingTool": "使用工具中",
|
||||
"PhaseSubagents": "智能体工作中",
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
"TerminalDockLive": "執行中",
|
||||
"ExtensionPromptUnavailable": "擴充功能指令無法使用;請透過 /plugin show 查看。",
|
||||
"ProfileConstitutionUnavailableLocal": "無法載入 Codewhale 帳號偏好,本輪改用本機設定。你的服務商金鑰仍可正常使用;執行 codewhale login 可恢復帳號偏好。",
|
||||
"AccountPreferencesNotLoaded": "帳號未載入。請執行 {command}。你的服務商金鑰仍可正常使用。",
|
||||
"ComposerOversizedSubmitHeld": "未傳送:訊息超過 {limit} 個字元,無法儲存為貼上文字檔案({error})。完整文字仍保留在輸入框中:請縮短內容,或允許寫入 .codewhale/pastes,然後再次按 Enter。",
|
||||
"ComposerOversizedSubmitHeldShort": "未傳送:超過 {limit} 個字元;貼上檔案未儲存。",
|
||||
"CmdPluginOwnerReport": "擴充功能主機:\n 狀態:{state}\n 活動工具({count}):{tools}",
|
||||
@@ -1689,7 +1688,7 @@
|
||||
"PhaseIdle": "閒置",
|
||||
"PhaseDraft": "草稿",
|
||||
"PhaseWorking": "工作中",
|
||||
"PhaseReasoning": "思考中",
|
||||
"PhaseReasoning": "推理中",
|
||||
"PhaseReading": "讀取中",
|
||||
"PhaseUsingTool": "使用工具中",
|
||||
"PhaseSubagents": "代理程式工作中",
|
||||
|
||||
@@ -714,7 +714,6 @@ pub enum MessageId {
|
||||
ExtensionsInventorySkills,
|
||||
ExtensionPromptUnavailable,
|
||||
ProfileConstitutionUnavailableLocal,
|
||||
AccountPreferencesNotLoaded,
|
||||
ExtensionsMarketplaceDetail,
|
||||
ExtensionsMarketplaceUnavailable,
|
||||
ExtensionsMcpEmpty,
|
||||
@@ -3652,7 +3651,6 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
|
||||
MessageId::ExtensionsInventorySkills,
|
||||
MessageId::ExtensionPromptUnavailable,
|
||||
MessageId::ProfileConstitutionUnavailableLocal,
|
||||
MessageId::AccountPreferencesNotLoaded,
|
||||
MessageId::ExtensionsMarketplaceDetail,
|
||||
MessageId::ExtensionsMarketplaceUnavailable,
|
||||
MessageId::ExtensionsMcpEmpty,
|
||||
|
||||
@@ -492,8 +492,6 @@ pub struct Settings {
|
||||
/// One-time Operate-retirement toast has been shown. Same suppression
|
||||
/// contract as `yolo_deprecation_shown`.
|
||||
pub operate_deprecation_shown: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub account_preferences_notice_shown: Option<String>,
|
||||
/// Round 3 (2026-09-01) moved the work bar under the composer. Every
|
||||
/// settings.toml saved before that carries `work_surface_placement =
|
||||
/// "top"` — the old default, persisted verbatim by ordinary saves, not a
|
||||
@@ -652,7 +650,9 @@ impl Default for Settings {
|
||||
mention_menu_limit: 128,
|
||||
mention_walk_depth: 10,
|
||||
mention_menu_behavior: "fuzzy".to_string(),
|
||||
show_thinking: true,
|
||||
// Reasoning is useful when explicitly requested, but it should
|
||||
// never displace the actual conversation in the default TUI.
|
||||
show_thinking: false,
|
||||
thinking_default_expanded: false,
|
||||
thinking_preview_lines: default_thinking_preview_lines(),
|
||||
thinking_highlight: true,
|
||||
@@ -694,7 +694,6 @@ impl Default for Settings {
|
||||
feature_intro_shown: false,
|
||||
yolo_deprecation_shown: false,
|
||||
operate_deprecation_shown: false,
|
||||
account_preferences_notice_shown: None,
|
||||
legacy_operate_default: false,
|
||||
work_surface_bottom_migrated: false,
|
||||
behavioral_tip_impressions: std::collections::BTreeMap::new(),
|
||||
@@ -3748,36 +3747,6 @@ mod tests {
|
||||
assert_eq!(reloaded.permission_posture.as_deref(), Some("ask"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thinking_shows_by_default_and_a_saved_choice_survives_reload_and_other_saves() {
|
||||
let _g = config_path_test_guard();
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let settings_path = tmp.path().join("settings.toml");
|
||||
let _config_override =
|
||||
EnvVarRestore::set("DEEPSEEK_CONFIG_PATH", tmp.path().join("config.toml"));
|
||||
|
||||
let absent = Settings::load().expect("load without a settings file");
|
||||
assert!(absent.show_thinking);
|
||||
assert!(!absent.thinking_default_expanded);
|
||||
assert!(!absent.is_set("show_thinking"));
|
||||
|
||||
std::fs::write(&settings_path, "theme = \"light\"\n").expect("settings without the key");
|
||||
let unnamed = Settings::load().expect("load settings without the key");
|
||||
assert!(unnamed.show_thinking);
|
||||
assert!(!unnamed.is_set("show_thinking"));
|
||||
|
||||
for saved in [false, true] {
|
||||
std::fs::write(&settings_path, format!("show_thinking = {saved}\n"))
|
||||
.expect("saved choice");
|
||||
let loaded = Settings::load().expect("load the saved choice");
|
||||
assert_eq!(loaded.show_thinking, saved);
|
||||
assert!(loaded.is_set("show_thinking"));
|
||||
Settings::transact(|settings| settings.set("locale", "en")).expect("another save");
|
||||
let reloaded = Settings::load().expect("reload after another save");
|
||||
assert_eq!(reloaded.show_thinking, saved);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_load_keeps_top_placement_chosen_after_the_bottom_migration() {
|
||||
let _g = config_path_test_guard();
|
||||
@@ -3917,7 +3886,7 @@ mod tests {
|
||||
let mut settings = Settings::default();
|
||||
// Density is calm by default; motion is an independent axis.
|
||||
assert!(settings.calm_mode);
|
||||
settings.show_thinking = false;
|
||||
assert!(!settings.show_thinking);
|
||||
|
||||
let changed = settings.apply_preset("CALM").expect("calm preset applies");
|
||||
assert_eq!(
|
||||
@@ -3947,8 +3916,8 @@ mod tests {
|
||||
assert!(settings.fancy_animations);
|
||||
assert_eq!(settings.transcript_spacing, "spacious");
|
||||
assert_eq!(settings.tool_collapse_mode, "compact");
|
||||
assert!(settings.show_thinking);
|
||||
assert!(!settings.thinking_default_expanded);
|
||||
// Thinking is opt-in so the transcript stays focused on the chat.
|
||||
assert!(!settings.show_thinking);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+131
-14
@@ -23,6 +23,7 @@ use crate::client::{self, SendOutcome};
|
||||
use crate::decision::{self, TelemetryDecision};
|
||||
use crate::envelope;
|
||||
use crate::event::{Batch, Event, SCHEMA_VERSION, Surface};
|
||||
use crate::spool::{self, SpoolContext};
|
||||
|
||||
/// Events per batch.
|
||||
pub const BATCH_MAX_EVENTS: usize = 200;
|
||||
@@ -64,6 +65,19 @@ pub(crate) struct Context {
|
||||
pub app_version: String,
|
||||
pub git_sha: Option<String>,
|
||||
pub tty: bool,
|
||||
pub spool: PathBuf,
|
||||
pub nonce: String,
|
||||
}
|
||||
|
||||
impl Context {
|
||||
fn header(&self) -> SpoolContext {
|
||||
SpoolContext::new(
|
||||
self.surface,
|
||||
self.app_version.clone(),
|
||||
self.git_sha.clone(),
|
||||
self.tty,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A handle to the writer thread.
|
||||
@@ -209,8 +223,7 @@ fn append(context: &Context, event: &Event) {
|
||||
let Ok(line) = serde_json::to_string(event) else {
|
||||
return;
|
||||
};
|
||||
let path = buffer::buffer_path(&context.root);
|
||||
let _ = buffer::append(&context.root, &path, &line);
|
||||
let _ = buffer::append(&context.root, &context.spool, &line);
|
||||
}
|
||||
|
||||
/// Seal every event queued before this message without reaching the network.
|
||||
@@ -219,7 +232,7 @@ fn append(context: &Context, event: &Event) {
|
||||
/// runs. Re-deciding here preserves the same mid-session opt-out boundary as a
|
||||
/// full flush. An explicitly empty endpoint is the local dry-run sink and can
|
||||
/// therefore be finalized immediately; a configured endpoint leaves the
|
||||
/// events in `buffer.jsonl` for the next interactive flush.
|
||||
/// events in this process's spool for a later start to send.
|
||||
fn persist_local(context: &Context) -> FlushOutcome {
|
||||
match decision::re_decide(context.config_path.as_deref(), context.surface) {
|
||||
TelemetryDecision::Enabled(_) => {}
|
||||
@@ -233,7 +246,10 @@ fn persist_local(context: &Context) -> FlushOutcome {
|
||||
if context.endpoint.is_none() {
|
||||
return flush(context);
|
||||
}
|
||||
if buffer::read_lines(&buffer::buffer_path(&context.root)).is_empty() {
|
||||
if buffer::read_lines(&context.spool)
|
||||
.iter()
|
||||
.all(|line| spool::is_header_line(line))
|
||||
{
|
||||
FlushOutcome::Empty
|
||||
} else {
|
||||
FlushOutcome::Buffered
|
||||
@@ -257,7 +273,7 @@ fn flush(context: &Context) -> FlushOutcome {
|
||||
return FlushOutcome::Suppressed;
|
||||
}
|
||||
|
||||
let lines = buffer::drain(&context.root);
|
||||
let lines = spool::take_own(&context.root, &context.spool);
|
||||
if lines.is_empty() {
|
||||
return FlushOutcome::Empty;
|
||||
}
|
||||
@@ -277,28 +293,94 @@ fn flush(context: &Context) -> FlushOutcome {
|
||||
// attempts at most once per interval rather than on every launch.
|
||||
let _ = envelope::write_state(&context.root, &state);
|
||||
|
||||
deliver(
|
||||
&context.root,
|
||||
context.endpoint.as_deref(),
|
||||
&context.header(),
|
||||
install.install_id,
|
||||
events,
|
||||
)
|
||||
}
|
||||
|
||||
fn deliver(
|
||||
root: &std::path::Path,
|
||||
endpoint: Option<&str>,
|
||||
header: &SpoolContext,
|
||||
install_id: String,
|
||||
events: Vec<Event>,
|
||||
) -> FlushOutcome {
|
||||
let batch = Batch {
|
||||
schema_version: SCHEMA_VERSION,
|
||||
notice_version: crate::event::NOTICE_VERSION,
|
||||
schema_version: header.schema_version,
|
||||
notice_version: header.notice_version,
|
||||
sent_at: envelope::now_rfc3339(),
|
||||
install_id: install.install_id,
|
||||
app_version: context.app_version.clone(),
|
||||
git_sha: context.git_sha.clone(),
|
||||
surface: context.surface,
|
||||
install_id,
|
||||
app_version: header.app_version.clone(),
|
||||
git_sha: header.git_sha.clone(),
|
||||
surface: header.surface,
|
||||
os: envelope::current_os(),
|
||||
arch: envelope::current_arch(),
|
||||
libc: envelope::current_libc(),
|
||||
tty: context.tty,
|
||||
tty: header.tty,
|
||||
events,
|
||||
};
|
||||
|
||||
match client::send(&context.root, context.endpoint.as_deref(), &batch) {
|
||||
match client::send(root, endpoint, &batch) {
|
||||
SendOutcome::DryRun => FlushOutcome::DryRun,
|
||||
SendOutcome::Accepted => FlushOutcome::Sent,
|
||||
SendOutcome::Dropped => FlushOutcome::Dropped,
|
||||
}
|
||||
}
|
||||
|
||||
/// Send what dead processes left behind, once each, on their own thread so a
|
||||
/// slow request never holds up this session's writer.
|
||||
///
|
||||
/// Consent and the tombstone are decided before anything is read, and again by
|
||||
/// the sender for every batch. Each spool is deleted before its request.
|
||||
pub(crate) fn spawn_recovery(context: Context) {
|
||||
if !matches!(
|
||||
context.surface,
|
||||
Surface::Tui | Surface::Exec | Surface::Serve
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("codewhale-telemetry-recover".to_string())
|
||||
.spawn(move || {
|
||||
let _ = catch_unwind(AssertUnwindSafe(|| recover(&context)));
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn recover(context: &Context) -> Vec<FlushOutcome> {
|
||||
match decision::re_decide(context.config_path.as_deref(), context.surface) {
|
||||
TelemetryDecision::Enabled(_) => {}
|
||||
TelemetryDecision::OptedOut | TelemetryDecision::ForcedOff => return Vec::new(),
|
||||
}
|
||||
send_dead(context)
|
||||
}
|
||||
|
||||
pub(crate) fn send_dead(context: &Context) -> Vec<FlushOutcome> {
|
||||
if buffer::tombstone_present(&context.root) {
|
||||
return Vec::new();
|
||||
}
|
||||
let Ok(install) = envelope::read_or_create_install_id(&context.root) else {
|
||||
return Vec::new();
|
||||
};
|
||||
spool::take_dead(&context.root, &context.nonce, std::time::SystemTime::now())
|
||||
.into_iter()
|
||||
.filter_map(|recovered| {
|
||||
let events = parse_events(&recovered.lines);
|
||||
(!events.is_empty()).then(|| {
|
||||
deliver(
|
||||
&context.root,
|
||||
context.endpoint.as_deref(),
|
||||
&recovered.context,
|
||||
install.install_id.clone(),
|
||||
events,
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Parse drained lines into events, capped at [`BATCH_MAX_EVENTS`] and
|
||||
/// [`BATCH_MAX_BYTES`], skipping anything that does not parse **or does not
|
||||
/// satisfy its declared string bounds**.
|
||||
@@ -373,4 +455,39 @@ mod tests {
|
||||
};
|
||||
assert_eq!(handle.persist_local(), FlushOutcome::TimedOut);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hung_writer_cannot_hold_a_termination_send_past_its_deadline() {
|
||||
let (tx, rx) = sync_channel::<Message>(buffer::MAX_EVENTS + 1);
|
||||
let handle = Handle {
|
||||
tx,
|
||||
pending: Arc::new(AtomicUsize::new(0)),
|
||||
closing: AtomicBool::new(false),
|
||||
};
|
||||
let started = Instant::now();
|
||||
let outcome = handle.shutdown(Duration::from_millis(50));
|
||||
assert_eq!(outcome, FlushOutcome::TimedOut);
|
||||
assert!(started.elapsed() < Duration::from_secs(5));
|
||||
assert!(matches!(rx.try_recv(), Ok(Message::Shutdown(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_termination_send_does_not_wait() {
|
||||
let (tx, _rx) = sync_channel::<Message>(buffer::MAX_EVENTS + 1);
|
||||
let handle = Handle {
|
||||
tx,
|
||||
pending: Arc::new(AtomicUsize::new(0)),
|
||||
closing: AtomicBool::new(false),
|
||||
};
|
||||
assert_eq!(
|
||||
handle.shutdown(Duration::from_millis(20)),
|
||||
FlushOutcome::TimedOut
|
||||
);
|
||||
let started = Instant::now();
|
||||
assert_eq!(
|
||||
handle.shutdown(Duration::from_secs(30)),
|
||||
FlushOutcome::TimedOut
|
||||
);
|
||||
assert!(started.elapsed() < Duration::from_secs(5));
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,10 @@
|
||||
//!
|
||||
//! | file | role |
|
||||
//! |---|---|
|
||||
//! | `buffer.jsonl` | one JSON event per line, awaiting a flush |
|
||||
//! | `buffer.jsonl.lock` | a **sibling** lock file; never the data file |
|
||||
//! | `spool/<nonce>.jsonl` | one process's header line, then one JSON event per line, awaiting its flush |
|
||||
//! | `spool/<nonce>.lock` | held exclusively by that process while it lives |
|
||||
//! | `buffer.jsonl` | the pre-spool sink, only ever truncated now |
|
||||
//! | `buffer.jsonl.lock` | the **privacy** lock, a sibling file; never a data file |
|
||||
//! | `dryrun.jsonl` | the sink when the endpoint resolves empty, same ring policy |
|
||||
//! | `state.json` | last version seen and last flush attempt |
|
||||
//! | `install_id.json` | the random install id |
|
||||
@@ -218,6 +220,39 @@ fn append_under_lock(root: &Path, path: &Path, buf: &[u8]) -> Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Write a spool header line, while the caller holds the blocking privacy lock.
|
||||
pub(crate) fn append_header(root: &Path, path: &Path, line: &str) -> Result<bool> {
|
||||
let mut buf = Vec::with_capacity(line.len() + 1);
|
||||
buf.extend_from_slice(line.as_bytes());
|
||||
buf.push(b'\n');
|
||||
append_under_lock(root, path, &buf)
|
||||
}
|
||||
|
||||
/// Re-attach the spool header that a ring cut dropped, keeping the newest events.
|
||||
fn keep_spool_header(path: &Path, mut lines: Vec<String>) -> Vec<String> {
|
||||
use std::io::BufRead as _;
|
||||
let Ok(file) = File::open(path) else {
|
||||
return lines;
|
||||
};
|
||||
let mut first = String::new();
|
||||
if std::io::BufReader::new(file.take(MAX_LINE_BYTES as u64 + 2))
|
||||
.read_line(&mut first)
|
||||
.is_err()
|
||||
{
|
||||
return lines;
|
||||
}
|
||||
let first = first.trim_end().to_string();
|
||||
if first.is_empty() || !crate::spool::is_header_line(&first) || lines.first() == Some(&first) {
|
||||
return lines;
|
||||
}
|
||||
lines.insert(0, first);
|
||||
let size = |lines: &[String]| lines.iter().map(|l| l.len() as u64 + 1).sum::<u64>();
|
||||
while lines.len() > 1 && (lines.len() > MAX_EVENTS || size(&lines) > MAX_BYTES) {
|
||||
lines.remove(1);
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// Keep the newest [`MAX_EVENTS`] lines and at most [`MAX_BYTES`], under the
|
||||
/// compaction lock. On lock contention this cycle is skipped: the next append
|
||||
/// tries again, and the cap is a ceiling on disk footprint, not an invariant
|
||||
@@ -243,7 +278,7 @@ fn enforce_ring(root: &Path, path: &Path) {
|
||||
if len < PROBE_BYTES {
|
||||
return Ok(());
|
||||
}
|
||||
let lines = read_ring_tail(path)?;
|
||||
let lines = keep_spool_header(path, read_ring_tail(path)?);
|
||||
let mut body = lines.join("\n");
|
||||
if !body.is_empty() {
|
||||
body.push('\n');
|
||||
@@ -371,33 +406,6 @@ pub fn read_lines(path: &Path) -> Vec<String> {
|
||||
read_ring_tail(path).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Take every buffered line and truncate the buffer, under the compaction lock.
|
||||
///
|
||||
/// Returns an empty vector when the tombstone is present or the lock is held
|
||||
/// elsewhere. Truncates rather than unlinks — `crates/engine/src/fleet/ledger.rs`
|
||||
/// documents the rule: replacing the file leaves appenders holding the old
|
||||
/// inode.
|
||||
#[must_use]
|
||||
pub fn drain(root: &Path) -> Vec<String> {
|
||||
if tombstone_present(root) {
|
||||
return Vec::new();
|
||||
}
|
||||
let path = buffer_path(root);
|
||||
let drained = try_with_lock(root, || {
|
||||
// Re-check under the lock: a wipe may have landed between the check
|
||||
// above and the acquisition.
|
||||
if tombstone_present(root) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let lines = read_lines(&path);
|
||||
if !lines.is_empty() {
|
||||
truncate(&path)?;
|
||||
}
|
||||
Ok(lines)
|
||||
});
|
||||
drained.ok().flatten().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Truncate a file to zero length, leaving the inode in place. A missing file
|
||||
/// is not an error.
|
||||
pub fn truncate(path: &Path) -> Result<()> {
|
||||
@@ -420,7 +428,7 @@ pub fn truncate(path: &Path) -> Result<()> {
|
||||
/// 1. take the blocking lock — this is not an exit path;
|
||||
/// 2. write the tombstone **first**, and never remove it here;
|
||||
/// 3. truncate `buffer.jsonl` and `dryrun.jsonl` — do **not** unlink them, and
|
||||
/// never unlink the lock file;
|
||||
/// never unlink the privacy lock file — and delete every spool;
|
||||
/// 4. remove `install_id.json` and `state.json`.
|
||||
///
|
||||
/// If any step after the tombstone fails, the error is returned and the caller
|
||||
@@ -466,6 +474,11 @@ pub fn wipe(root: &Path) -> Result<()> {
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Err(error) = fs::remove_dir_all(crate::spool::dir(root))
|
||||
&& error.kind() != std::io::ErrorKind::NotFound
|
||||
{
|
||||
failure.get_or_insert(anyhow::Error::new(error).context("failed to remove the spools"));
|
||||
}
|
||||
match failure {
|
||||
Some(error) => Err(error),
|
||||
None => Ok(()),
|
||||
@@ -473,8 +486,8 @@ pub fn wipe(root: &Path) -> Result<()> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Clear the tombstone and drop anything buffered before this process was
|
||||
/// permitted.
|
||||
/// Clear the tombstone, deleting every spool when there was one, and truncate
|
||||
/// the pre-spool buffer.
|
||||
///
|
||||
/// Called by `init` on every arming. Both the exact tombstone generation and a
|
||||
/// fresh durable permission check must still match while the wipe lock is held.
|
||||
@@ -496,6 +509,7 @@ pub(crate) fn arm(
|
||||
}
|
||||
let tombstone = tombstone_path(root);
|
||||
if tombstone.exists() {
|
||||
let _ = fs::remove_dir_all(crate::spool::dir(root));
|
||||
fs::remove_file(&tombstone)
|
||||
.with_context(|| format!("failed to remove {}", tombstone.display()))?;
|
||||
}
|
||||
|
||||
+136
-6
@@ -21,8 +21,8 @@
|
||||
//! construction empty until resolution completes. A disabled user's panic
|
||||
//! therefore writes nothing and creates no directory.
|
||||
//!
|
||||
//! Arming also **truncates** any stale buffer before a newly permitted process
|
||||
//! begins recording.
|
||||
//! Arming also gives the process its own spool file and, when the machine was
|
||||
//! opted out until now, deletes every spool left from before.
|
||||
//!
|
||||
//! # Failure posture
|
||||
//!
|
||||
@@ -41,13 +41,14 @@ pub mod decision;
|
||||
pub mod envelope;
|
||||
pub mod event;
|
||||
pub mod notice;
|
||||
pub mod spool;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
use std::time::Duration;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub use actor::{BATCH_MAX_BYTES, BATCH_MAX_EVENTS, FlushOutcome};
|
||||
pub use counters::{Counter, ErrorCounter, SessionCounters, TurnOutcome, TurnTokens};
|
||||
@@ -69,11 +70,27 @@ pub use event::{
|
||||
/// handshake would hold a user's terminal past exit.
|
||||
pub const SHUTDOWN_FLUSH_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
|
||||
/// How long the send attempt on a terminating signal or a broken pipe may hold
|
||||
/// the process.
|
||||
///
|
||||
/// Shorter than [`SHUTDOWN_FLUSH_TIMEOUT`] so that, run alongside the runtime's
|
||||
/// two second signal drain, it fits inside the two second grace a supervisor
|
||||
/// gives SIGTERM before it escalates.
|
||||
pub const SIGNAL_FLUSH_TIMEOUT: Duration = Duration::from_millis(1500);
|
||||
|
||||
const COLD_START_UNSET: u64 = u64::MAX;
|
||||
|
||||
static COLD_START_MS: AtomicU64 = AtomicU64::new(COLD_START_UNSET);
|
||||
|
||||
/// Everything a write path needs once the process is armed.
|
||||
struct Armed {
|
||||
handle: actor::Handle,
|
||||
root: std::path::PathBuf,
|
||||
spool: std::path::PathBuf,
|
||||
surface: Surface,
|
||||
started: Instant,
|
||||
exit_class: AtomicU8,
|
||||
session_end_recorded: AtomicBool,
|
||||
}
|
||||
|
||||
/// The one gate. Unset means every write path is a hard no-op.
|
||||
@@ -107,6 +124,14 @@ pub fn init(consent: TelemetryConsent) {
|
||||
|
||||
notice::show_startup_disclosure(consent.surface());
|
||||
|
||||
let header = spool::SpoolContext::new(
|
||||
consent.surface(),
|
||||
env!("CARGO_PKG_VERSION").to_string(),
|
||||
envelope::release_build_sha(),
|
||||
envelope::current_tty(),
|
||||
);
|
||||
let (spool_path, nonce) = spool::open(&root, &header);
|
||||
|
||||
let context = actor::Context {
|
||||
root: root.clone(),
|
||||
endpoint: consent.endpoint().map(str::to_string),
|
||||
@@ -115,12 +140,19 @@ pub fn init(consent: TelemetryConsent) {
|
||||
app_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
git_sha: envelope::release_build_sha(),
|
||||
tty: envelope::current_tty(),
|
||||
spool: spool_path.clone(),
|
||||
nonce,
|
||||
};
|
||||
actor::spawn_recovery(context.clone());
|
||||
|
||||
let _ = ARMED.set(Armed {
|
||||
handle: actor::Handle::spawn(context),
|
||||
root: root.clone(),
|
||||
spool: spool_path,
|
||||
surface: consent.surface(),
|
||||
started: Instant::now(),
|
||||
exit_class: AtomicU8::new(ExitClass::Clean.as_u8()),
|
||||
session_end_recorded: AtomicBool::new(false),
|
||||
});
|
||||
|
||||
record_install_or_upgrade(&root);
|
||||
@@ -241,8 +273,7 @@ pub fn record_blocking(event: Event) {
|
||||
let Ok(line) = serde_json::to_string(&event) else {
|
||||
return;
|
||||
};
|
||||
let path = buffer::buffer_path(&armed.root);
|
||||
let _ = buffer::append(&armed.root, &path, &line);
|
||||
let _ = buffer::append(&armed.root, &armed.spool, &line);
|
||||
}
|
||||
|
||||
/// Record how this process is ending.
|
||||
@@ -266,6 +297,105 @@ pub fn exit_class() -> ExitClass {
|
||||
})
|
||||
}
|
||||
|
||||
/// Report how long the interactive event loop took to start. The terminal UI is
|
||||
/// the only caller; every other surface leaves the cold-start bucket absent.
|
||||
pub fn set_cold_start_ms(millis: u64) {
|
||||
let _ = COLD_START_MS.compare_exchange(
|
||||
COLD_START_UNSET,
|
||||
millis,
|
||||
Ordering::Relaxed,
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
}
|
||||
|
||||
/// Build `session_end` from what this process actually accumulated.
|
||||
///
|
||||
/// The exit class is read from the process-wide atomic and never derived from
|
||||
/// an exit code. Building the event writes nothing; an unarmed process reports a
|
||||
/// zero duration and nothing reaches a sink.
|
||||
#[must_use]
|
||||
pub fn session_end_event() -> Event {
|
||||
let counters = session_counters();
|
||||
let cold_start = COLD_START_MS.load(Ordering::Relaxed);
|
||||
Event::SessionEnd {
|
||||
duration_bucket: DurationBucket::from_secs(
|
||||
ARMED
|
||||
.get()
|
||||
.map_or(0, |armed| armed.started.elapsed().as_secs()),
|
||||
),
|
||||
exit_class: exit_class(),
|
||||
cold_start_bucket: (cold_start != COLD_START_UNSET)
|
||||
.then(|| ColdStartBucket::from_millis(cold_start)),
|
||||
providers: counters.providers(),
|
||||
counters: counters.counters(),
|
||||
errors: counters.errors(),
|
||||
turn_wall: counters.turn_wall(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue this session's usage rows and `session_end` once.
|
||||
///
|
||||
/// Whichever of the clean path, the signal path or a broken pipe gets here
|
||||
/// first reports; later callers do nothing, so one session never ends twice.
|
||||
/// A no-op when unarmed.
|
||||
pub fn record_session_end() {
|
||||
let Some(armed) = ARMED.get() else {
|
||||
return;
|
||||
};
|
||||
if armed.session_end_recorded.swap(true, Ordering::AcqRel) {
|
||||
return;
|
||||
}
|
||||
for event in session_counters().usage_events() {
|
||||
record(event);
|
||||
}
|
||||
record(session_end_event());
|
||||
}
|
||||
|
||||
/// Close the session synchronously, for a path that cannot wait for the writer.
|
||||
///
|
||||
/// States `class`, then appends the usage rows and `session_end` to the local
|
||||
/// buffer with the same lock-free-for-the-caller write as [`record_blocking`].
|
||||
/// A no-op when unarmed, and a no-op for the events when the session already
|
||||
/// ended.
|
||||
pub fn record_session_end_blocking(class: ExitClass) {
|
||||
let Some(armed) = ARMED.get() else {
|
||||
return;
|
||||
};
|
||||
set_exit_class(class);
|
||||
if armed.session_end_recorded.swap(true, Ordering::AcqRel) {
|
||||
return;
|
||||
}
|
||||
for event in session_counters().usage_events() {
|
||||
record_blocking(event);
|
||||
}
|
||||
record_blocking(session_end_event());
|
||||
}
|
||||
|
||||
/// One bounded network attempt for a process that is being terminated.
|
||||
///
|
||||
/// Runs the ordinary flush, so consent, the opt-out tombstone and the run kill
|
||||
/// switch are re-checked immediately beforehand and a person who has opted out
|
||||
/// sends nothing. Short CLI commands stay disk-only. Never retried, and the
|
||||
/// caller is released after `deadline` whether or not the request finished.
|
||||
#[must_use]
|
||||
pub fn flush_for_termination(deadline: Duration) -> FlushOutcome {
|
||||
let Some(armed) = ARMED.get() else {
|
||||
return FlushOutcome::Empty;
|
||||
};
|
||||
if armed.surface == Surface::Cli {
|
||||
return FlushOutcome::Buffered;
|
||||
}
|
||||
armed.handle.shutdown(deadline)
|
||||
}
|
||||
|
||||
/// Record the session end and make the one bounded send attempt, for a process
|
||||
/// that is about to be ended by a signal. Returns [`FlushOutcome::Empty`] when
|
||||
/// unarmed.
|
||||
pub fn report_termination(class: ExitClass, deadline: Duration) -> FlushOutcome {
|
||||
record_session_end_blocking(class);
|
||||
flush_for_termination(deadline)
|
||||
}
|
||||
|
||||
/// Final flush, then stop the writer thread.
|
||||
///
|
||||
/// Returns [`FlushOutcome::Empty`] when unarmed.
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
//! Per-process spools and the recovery of the ones a dead process left behind.
|
||||
//!
|
||||
//! Each armed process owns `telemetry/spool/<nonce>.jsonl`, whose first line is
|
||||
//! a context header (the envelope facts that belong to that process), and holds
|
||||
//! an exclusive lock on `<nonce>.lock` for as long as it lives. Recording and
|
||||
//! the clean flush touch only the process's own spool, so one process can
|
||||
//! neither truncate nor send another's events under the wrong surface or
|
||||
//! version.
|
||||
//!
|
||||
//! A spool whose lock can be taken belongs to a process that is gone. A later
|
||||
//! TUI, exec or serve start sends at most [`MAX_RECOVERED`] of them, each once:
|
||||
//! the file is deleted **before** the request, so a crash or a failed send never
|
||||
//! causes a second attempt.
|
||||
//!
|
||||
//! The nonce is local only. It names a file and is never sent.
|
||||
//!
|
||||
//! Known limitation: a spool is only as trustworthy as the user account, like
|
||||
//! every other file here. Recovery therefore re-validates the header and every
|
||||
//! event as untrusted input instead of inheriting any bound.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::buffer;
|
||||
use crate::event::{NOTICE_VERSION, SCHEMA_VERSION, Surface, is_release_version_string};
|
||||
|
||||
/// Dead spools sent at one start. Older or surplus ones are deleted unsent.
|
||||
pub const MAX_RECOVERED: usize = 4;
|
||||
/// A dead spool older than this is deleted unsent.
|
||||
pub const MAX_AGE: Duration = Duration::from_secs(7 * 24 * 60 * 60);
|
||||
|
||||
const HEADER_VERSION: u32 = 1;
|
||||
const MAX_VERSION_BYTES: usize = 64;
|
||||
|
||||
/// The envelope facts one process stamps on everything it spools.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct SpoolContext {
|
||||
spool: u32,
|
||||
pub schema_version: u32,
|
||||
pub notice_version: u32,
|
||||
pub app_version: String,
|
||||
pub git_sha: Option<String>,
|
||||
pub surface: Surface,
|
||||
pub tty: bool,
|
||||
}
|
||||
|
||||
impl SpoolContext {
|
||||
pub(crate) fn new(
|
||||
surface: Surface,
|
||||
app_version: String,
|
||||
git_sha: Option<String>,
|
||||
tty: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
spool: HEADER_VERSION,
|
||||
schema_version: SCHEMA_VERSION,
|
||||
notice_version: NOTICE_VERSION,
|
||||
app_version,
|
||||
git_sha,
|
||||
surface,
|
||||
tty,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a header read from disk may be sent as an envelope.
|
||||
pub(crate) fn is_sendable(&self) -> bool {
|
||||
self.spool == HEADER_VERSION
|
||||
&& self.schema_version == SCHEMA_VERSION
|
||||
&& self.notice_version == NOTICE_VERSION
|
||||
&& self.app_version.len() <= MAX_VERSION_BYTES
|
||||
&& is_release_version_string(&self.app_version)
|
||||
&& self.git_sha.as_deref().is_none_or(|sha| {
|
||||
sha.len() == 12 && sha.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `line` is a spool header rather than an event.
|
||||
pub(crate) fn is_header_line(line: &str) -> bool {
|
||||
serde_json::from_str::<SpoolContext>(line).is_ok()
|
||||
}
|
||||
|
||||
/// `$CODEWHALE_HOME/telemetry/spool`.
|
||||
#[must_use]
|
||||
pub fn dir(root: &Path) -> PathBuf {
|
||||
root.join("spool")
|
||||
}
|
||||
|
||||
/// Every spool data file under `root`, for tests and diagnostics.
|
||||
#[must_use]
|
||||
pub fn data_files(root: &Path) -> Vec<PathBuf> {
|
||||
let Ok(entries) = fs::read_dir(dir(root)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut files: Vec<PathBuf> = entries
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| path.extension().is_some_and(|ext| ext == "jsonl"))
|
||||
.collect();
|
||||
files.sort();
|
||||
files
|
||||
}
|
||||
|
||||
fn data_path(root: &Path, nonce: &str) -> PathBuf {
|
||||
dir(root).join(format!("{nonce}.jsonl"))
|
||||
}
|
||||
|
||||
fn lock_file_path(root: &Path, nonce: &str) -> PathBuf {
|
||||
dir(root).join(format!("{nonce}.lock"))
|
||||
}
|
||||
|
||||
fn is_nonce(value: &str) -> bool {
|
||||
value.len() == 32
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
|
||||
}
|
||||
|
||||
fn open_lock_file(path: &Path) -> Option<File> {
|
||||
OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.truncate(false)
|
||||
.open(path)
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Create this process's spool and hold its lock until the process ends.
|
||||
///
|
||||
/// Runs at arming, where blocking on the privacy lock is allowed, so the header
|
||||
/// is on disk before any other writer, including the signal path, can append.
|
||||
/// Returns the data path and the nonce.
|
||||
pub(crate) fn open(root: &Path, context: &SpoolContext) -> (PathBuf, String) {
|
||||
let nonce = uuid::Uuid::new_v4().simple().to_string();
|
||||
let path = data_path(root, &nonce);
|
||||
if buffer::ensure_dir(&dir(root)).is_ok() {
|
||||
if let Some(file) = open_lock_file(&lock_file_path(root, &nonce)) {
|
||||
let lock = Box::leak(Box::new(fd_lock::RwLock::new(file)));
|
||||
if let Ok(guard) = lock.try_write() {
|
||||
std::mem::forget(guard);
|
||||
}
|
||||
}
|
||||
if let Ok(header) = serde_json::to_string(context) {
|
||||
let _ = buffer::with_lock(root, || buffer::append_header(root, &path, &header));
|
||||
}
|
||||
}
|
||||
(path, nonce)
|
||||
}
|
||||
|
||||
/// Take this process's own spool for delivery: read it and delete it, under the
|
||||
/// privacy lock.
|
||||
///
|
||||
/// Empty when the tombstone is present, the lock is held elsewhere, or the file
|
||||
/// could not be removed. A spool that cannot be removed is never sent, because
|
||||
/// a later start would send it again.
|
||||
#[must_use]
|
||||
pub(crate) fn take_own(root: &Path, path: &Path) -> Vec<String> {
|
||||
if buffer::tombstone_present(root) {
|
||||
return Vec::new();
|
||||
}
|
||||
buffer::try_with_lock(root, || {
|
||||
if buffer::tombstone_present(root) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let lines = buffer::read_lines(path);
|
||||
if lines.is_empty() {
|
||||
return Ok(lines);
|
||||
}
|
||||
if remove_or_truncate(path) {
|
||||
Ok(lines)
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
})
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn remove_or_truncate(path: &Path) -> bool {
|
||||
fs::remove_file(path).is_ok() || buffer::truncate(path).is_ok_and(|()| !path.exists())
|
||||
}
|
||||
|
||||
/// A dead process's spool, already deleted from disk.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Recovered {
|
||||
pub context: SpoolContext,
|
||||
pub lines: Vec<String>,
|
||||
}
|
||||
|
||||
/// Claim the dead spools that may be sent, newest first, deleting every dead
|
||||
/// spool it looks at.
|
||||
///
|
||||
/// Live spools are skipped by their lock, and `own_nonce` is never touched.
|
||||
/// Unknown file names, symbolic links and anything without a valid header are
|
||||
/// deleted or ignored, never read as events.
|
||||
pub(crate) fn take_dead(root: &Path, own_nonce: &str, now: SystemTime) -> Vec<Recovered> {
|
||||
let Ok(entries) = fs::read_dir(dir(root)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut stems = BTreeSet::new();
|
||||
for entry in entries.filter_map(Result::ok) {
|
||||
let name = entry.file_name();
|
||||
let Some(name) = name.to_str() else { continue };
|
||||
if let Some((stem, ext)) = name.rsplit_once('.')
|
||||
&& matches!(ext, "jsonl" | "lock")
|
||||
&& is_nonce(stem)
|
||||
&& stem != own_nonce
|
||||
{
|
||||
stems.insert(stem.to_string());
|
||||
}
|
||||
}
|
||||
let mut ranked: Vec<(SystemTime, String)> = stems
|
||||
.into_iter()
|
||||
.map(|stem| {
|
||||
let modified = [data_path(root, &stem), lock_file_path(root, &stem)]
|
||||
.iter()
|
||||
.find_map(|path| fs::symlink_metadata(path).and_then(|m| m.modified()).ok())
|
||||
.unwrap_or(SystemTime::UNIX_EPOCH);
|
||||
(modified, stem)
|
||||
})
|
||||
.collect();
|
||||
ranked.sort_by(|a, b| b.cmp(a));
|
||||
|
||||
let mut recovered = Vec::new();
|
||||
for (modified, stem) in ranked {
|
||||
if buffer::tombstone_present(root) {
|
||||
break;
|
||||
}
|
||||
let data = data_path(root, &stem);
|
||||
let lock_path = lock_file_path(root, &stem);
|
||||
let eligible = recovered.len() < MAX_RECOVERED
|
||||
&& now.duration_since(modified).is_ok_and(|age| age <= MAX_AGE);
|
||||
let claimed = with_dead_lock(&lock_path, || {
|
||||
let claimed = if eligible { claim(&data) } else { None };
|
||||
if claimed.is_none() {
|
||||
let _ = fs::remove_file(&data);
|
||||
}
|
||||
let _ = fs::remove_file(&lock_path);
|
||||
claimed
|
||||
});
|
||||
if let Some(Some(claimed)) = claimed {
|
||||
recovered.push(claimed);
|
||||
}
|
||||
}
|
||||
recovered
|
||||
}
|
||||
|
||||
fn with_dead_lock<T>(lock_path: &Path, operation: impl FnOnce() -> T) -> Option<T> {
|
||||
let file = open_lock_file(lock_path)?;
|
||||
let mut lock = fd_lock::RwLock::new(file);
|
||||
let _guard = lock.try_write().ok()?;
|
||||
Some(operation())
|
||||
}
|
||||
|
||||
fn claim(data: &Path) -> Option<Recovered> {
|
||||
let meta = fs::symlink_metadata(data).ok()?;
|
||||
if !meta.is_file() {
|
||||
return None;
|
||||
}
|
||||
let lines = buffer::read_lines(data);
|
||||
let (header, events) = lines.split_first()?;
|
||||
let context = serde_json::from_str::<SpoolContext>(header).ok()?;
|
||||
if !context.is_sendable() || events.is_empty() || !remove_or_truncate(data) {
|
||||
return None;
|
||||
}
|
||||
Some(Recovered {
|
||||
context,
|
||||
lines: events.to_vec(),
|
||||
})
|
||||
}
|
||||
@@ -1074,7 +1074,7 @@ fn failed_wipe_fails_closed() {
|
||||
assert!(result.is_err(), "a failed truncate must be reported");
|
||||
}
|
||||
assert!(
|
||||
buffer::drain(&root).is_empty(),
|
||||
crate::spool::take_own(&root, &buffer_path).is_empty(),
|
||||
"a tombstoned buffer must never drain, wipe failure or not"
|
||||
);
|
||||
assert!(
|
||||
@@ -1262,14 +1262,14 @@ fn drain_skips_a_torn_trailing_line() {
|
||||
.expect("open");
|
||||
file.write_all(b"{\"event\":\"pan").expect("tear");
|
||||
}
|
||||
let drained = buffer::drain(&root);
|
||||
let drained = crate::spool::take_own(&root, &path);
|
||||
assert_eq!(drained.len(), 3, "the drain returns raw lines");
|
||||
let parsed: Vec<Event> = drained
|
||||
.iter()
|
||||
.filter_map(|l| serde_json::from_str(l).ok())
|
||||
.collect();
|
||||
assert_eq!(parsed.len(), 2, "the torn line must not reach a batch");
|
||||
assert!(buffer::read_lines(&path).is_empty(), "drain truncates");
|
||||
assert!(!path.exists(), "taking a spool deletes it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1320,21 +1320,25 @@ fn a_tombstoned_buffer_never_appends_or_drains() {
|
||||
|
||||
assert!(buffer::append(&root, &buffer::buffer_path(&root), &line(2)).is_none());
|
||||
assert!(buffer::append_locked(&root, &buffer::dryrun_path(&root), "{}").is_none());
|
||||
assert!(buffer::drain(&root).is_empty());
|
||||
assert!(crate::spool::take_own(&root, &buffer::buffer_path(&root)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arming_truncates_a_pre_consent_buffer() {
|
||||
fn arming_after_a_tombstone_deletes_spools_and_truncates_the_legacy_buffer() {
|
||||
let home = temp_home();
|
||||
let root = root_of(&home);
|
||||
buffer::ensure_dir(&root).expect("create root");
|
||||
buffer::append(&root, &buffer::buffer_path(&root), &line(1)).expect("append");
|
||||
buffer::wipe(&root).expect("wipe");
|
||||
std::fs::create_dir_all(crate::spool::dir(&root)).expect("spool dir");
|
||||
let planted = crate::spool::dir(&root).join(format!("{}.jsonl", "a".repeat(32)));
|
||||
std::fs::write(&planted, "{}\n").expect("plant");
|
||||
|
||||
let generation = buffer::tombstone_generation(&root).expect("read wipe generation");
|
||||
buffer::arm(&root, generation.as_ref(), || true).expect("arm");
|
||||
assert!(!buffer::tombstone_present(&root));
|
||||
assert!(buffer::read_lines(&buffer::buffer_path(&root)).is_empty());
|
||||
assert!(!planted.exists(), "re-enabling must not inherit old spools");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1434,6 +1438,21 @@ fn completed_wipe_blocks_identity_and_state_recreation() {
|
||||
|
||||
// ------------------------------------------------------------ unarmed gate --
|
||||
|
||||
#[test]
|
||||
fn termination_reporting_is_a_noop_when_unarmed() {
|
||||
assert!(!crate::is_armed());
|
||||
crate::record_session_end();
|
||||
crate::record_session_end_blocking(ExitClass::Signal);
|
||||
assert_eq!(
|
||||
crate::report_termination(ExitClass::Signal, Duration::from_millis(10)),
|
||||
crate::FlushOutcome::Empty
|
||||
);
|
||||
assert_eq!(
|
||||
crate::flush_for_termination(Duration::from_millis(10)),
|
||||
crate::FlushOutcome::Empty
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_blocking_is_a_noop_when_unarmed() {
|
||||
// The process panic hook is installed before the command line is parsed, so
|
||||
@@ -1994,3 +2013,309 @@ 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"));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- the spools --
|
||||
|
||||
mod spools {
|
||||
use super::*;
|
||||
use crate::actor::{Context, send_dead};
|
||||
use crate::spool::{self, SpoolContext};
|
||||
|
||||
fn header(surface: Surface, version: &str) -> SpoolContext {
|
||||
SpoolContext::new(
|
||||
surface,
|
||||
version.to_string(),
|
||||
Some("abcdef123456".to_string()),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
fn event_line(source: SessionSource) -> String {
|
||||
serde_json::to_string(&Event::SessionStart { source }).expect("serialize")
|
||||
}
|
||||
|
||||
fn plant(root: &Path, nonce: char, header_line: &str, events: &[String]) -> PathBuf {
|
||||
std::fs::create_dir_all(spool::dir(root)).expect("spool dir");
|
||||
let path = spool::dir(root).join(format!("{}.jsonl", nonce.to_string().repeat(32)));
|
||||
let mut body = format!("{header_line}\n");
|
||||
for event in events {
|
||||
body.push_str(event);
|
||||
body.push('\n');
|
||||
}
|
||||
std::fs::write(&path, body).expect("plant spool");
|
||||
path
|
||||
}
|
||||
|
||||
fn header_line(surface: Surface, version: &str) -> String {
|
||||
serde_json::to_string(&header(surface, version)).expect("serialize header")
|
||||
}
|
||||
|
||||
fn current(root: &Path) -> Context {
|
||||
let header = header(Surface::Tui, "0.10.3");
|
||||
let (spool, nonce) = spool::open(root, &header);
|
||||
Context {
|
||||
root: root.to_path_buf(),
|
||||
endpoint: None,
|
||||
surface: Surface::Tui,
|
||||
config_path: None,
|
||||
app_version: "0.10.3".to_string(),
|
||||
git_sha: None,
|
||||
tty: false,
|
||||
spool,
|
||||
nonce,
|
||||
}
|
||||
}
|
||||
|
||||
fn dryrun_batches(root: &Path) -> Vec<Batch> {
|
||||
buffer::read_lines(&buffer::dryrun_path(root))
|
||||
.iter()
|
||||
.map(|line| serde_json::from_str(line).expect("a dry-run batch"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dead_spool_is_recovered_with_its_own_surface_and_version() {
|
||||
let home = temp_home();
|
||||
let root = root_of(&home);
|
||||
let context = current(&root);
|
||||
let path = plant(
|
||||
&root,
|
||||
'b',
|
||||
&header_line(Surface::Serve, "0.9.9"),
|
||||
&[event_line(SessionSource::Interactive)],
|
||||
);
|
||||
|
||||
let outcomes = send_dead(&context);
|
||||
|
||||
assert_eq!(outcomes, vec![crate::FlushOutcome::DryRun]);
|
||||
let batches = dryrun_batches(&root);
|
||||
assert_eq!(batches.len(), 1);
|
||||
assert_eq!(batches[0].surface, Surface::Serve);
|
||||
assert_eq!(batches[0].app_version, "0.9.9");
|
||||
assert_eq!(batches[0].git_sha.as_deref(), Some("abcdef123456"));
|
||||
assert_eq!(batches[0].events.len(), 1);
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dead_spool_is_never_sent_twice() {
|
||||
let home = temp_home();
|
||||
let root = root_of(&home);
|
||||
let context = current(&root);
|
||||
plant(
|
||||
&root,
|
||||
'b',
|
||||
&header_line(Surface::Exec, "0.10.3"),
|
||||
&[event_line(SessionSource::Interactive)],
|
||||
);
|
||||
assert_eq!(send_dead(&context).len(), 1);
|
||||
assert!(send_dead(&context).is_empty());
|
||||
assert_eq!(dryrun_batches(&root).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_live_locked_spool_is_untouched() {
|
||||
let home = temp_home();
|
||||
let root = root_of(&home);
|
||||
let live = header(Surface::Serve, "0.10.3");
|
||||
let (live_path, live_nonce) = spool::open(&root, &live);
|
||||
buffer::append(&root, &live_path, &event_line(SessionSource::Interactive)).expect("append");
|
||||
|
||||
let taken = spool::take_dead(&root, &"c".repeat(32), std::time::SystemTime::now());
|
||||
|
||||
assert!(taken.is_empty());
|
||||
assert_eq!(buffer::read_lines(&live_path).len(), 2);
|
||||
assert!(
|
||||
spool::dir(&root)
|
||||
.join(format!("{live_nonce}.lock"))
|
||||
.exists()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_running_process_never_recovers_its_own_spool() {
|
||||
let home = temp_home();
|
||||
let root = root_of(&home);
|
||||
let context = current(&root);
|
||||
buffer::append(
|
||||
&root,
|
||||
&context.spool,
|
||||
&event_line(SessionSource::Interactive),
|
||||
)
|
||||
.expect("append");
|
||||
assert!(send_dead(&context).is_empty());
|
||||
assert_eq!(buffer::read_lines(&context.spool).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tombstone_blocks_recovery_and_append() {
|
||||
let home = temp_home();
|
||||
let root = root_of(&home);
|
||||
let context = current(&root);
|
||||
buffer::wipe(&root).expect("wipe");
|
||||
let planted = plant(
|
||||
&root,
|
||||
'b',
|
||||
&header_line(Surface::Serve, "0.10.3"),
|
||||
&[event_line(SessionSource::Interactive)],
|
||||
);
|
||||
|
||||
assert!(send_dead(&context).is_empty());
|
||||
assert!(dryrun_batches(&root).is_empty());
|
||||
assert!(
|
||||
planted.exists(),
|
||||
"nothing is read or sent under a tombstone"
|
||||
);
|
||||
assert!(buffer::append(&root, &context.spool, "{}").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wipe_removes_every_spool() {
|
||||
let home = temp_home();
|
||||
let root = root_of(&home);
|
||||
let context = current(&root);
|
||||
plant(
|
||||
&root,
|
||||
'b',
|
||||
&header_line(Surface::Serve, "0.10.3"),
|
||||
&[event_line(SessionSource::Interactive)],
|
||||
);
|
||||
buffer::wipe(&root).expect("wipe");
|
||||
assert!(!spool::dir(&root).exists());
|
||||
assert!(!context.spool.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hostile_context_drops_the_whole_spool_unsent() {
|
||||
let home = temp_home();
|
||||
let root = root_of(&home);
|
||||
let context = current(&root);
|
||||
let good = event_line(SessionSource::Interactive);
|
||||
let hostile_headers = [
|
||||
header_line(Surface::Serve, "/Users/victim/secret"),
|
||||
header_line(Surface::Serve, "0.10.3").replace("abcdef123456", "ZZZZZZZZZZZZ"),
|
||||
header_line(Surface::Serve, "0.10.3").replace("\"spool\":1", "\"spool\":2"),
|
||||
header_line(Surface::Serve, "0.10.3")
|
||||
.replace("\"schema_version\":", "\"x\":9,\"schema_version\":"),
|
||||
header_line(Surface::Serve, "0.10.3").replace("\"serve\"", "\"not-a-surface\""),
|
||||
format!(
|
||||
"{{\"schema_version\":{},\"notice_version\":{},\"app_version\":\"0.10.3\"}}",
|
||||
SCHEMA_VERSION + 1,
|
||||
NOTICE_VERSION
|
||||
),
|
||||
good.clone(),
|
||||
];
|
||||
for (index, hostile) in hostile_headers.iter().enumerate() {
|
||||
let nonce = char::from(b'1' + index as u8);
|
||||
let path = plant(&root, nonce, hostile, std::slice::from_ref(&good));
|
||||
assert!(send_dead(&context).is_empty(), "{hostile}");
|
||||
assert!(!path.exists(), "{hostile}");
|
||||
}
|
||||
let mut stale = header(Surface::Serve, "0.10.3");
|
||||
stale.schema_version = SCHEMA_VERSION + 1;
|
||||
plant(
|
||||
&root,
|
||||
'f',
|
||||
&serde_json::to_string(&stale).expect("serialize"),
|
||||
std::slice::from_ref(&good),
|
||||
);
|
||||
assert!(send_dead(&context).is_empty());
|
||||
assert!(dryrun_batches(&root).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hostile_event_in_a_dead_spool_is_dropped_and_the_rest_sent() {
|
||||
let home = temp_home();
|
||||
let root = root_of(&home);
|
||||
let context = current(&root);
|
||||
plant(
|
||||
&root,
|
||||
'b',
|
||||
&header_line(Surface::Serve, "0.10.3"),
|
||||
&[
|
||||
r#"{"event":"panic","site":"/Users/victim/src/secret-repo/main.rs"}"#.to_string(),
|
||||
event_line(SessionSource::Interactive),
|
||||
],
|
||||
);
|
||||
assert_eq!(send_dead(&context).len(), 1);
|
||||
let batches = dryrun_batches(&root);
|
||||
assert_eq!(batches[0].events.len(), 1);
|
||||
assert!(!serde_json::to_string(&batches).unwrap().contains("victim"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_count_and_age_caps_hold() {
|
||||
let home = temp_home();
|
||||
let root = root_of(&home);
|
||||
let context = current(&root);
|
||||
let now = std::time::SystemTime::now();
|
||||
let mut paths = Vec::new();
|
||||
for (index, nonce) in ['1', '2', '3', '4', '5', '6'].into_iter().enumerate() {
|
||||
let path = plant(
|
||||
&root,
|
||||
nonce,
|
||||
&header_line(Surface::Serve, "0.10.3"),
|
||||
&[event_line(SessionSource::Interactive)],
|
||||
);
|
||||
let age = Duration::from_secs(60 * (index as u64 + 1));
|
||||
std::fs::File::options()
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.expect("open")
|
||||
.set_modified(now - age)
|
||||
.expect("set mtime");
|
||||
paths.push(path);
|
||||
}
|
||||
let stale = plant(
|
||||
&root,
|
||||
'7',
|
||||
&header_line(Surface::Serve, "0.10.3"),
|
||||
&[event_line(SessionSource::Interactive)],
|
||||
);
|
||||
std::fs::File::options()
|
||||
.write(true)
|
||||
.open(&stale)
|
||||
.expect("open")
|
||||
.set_modified(now - Duration::from_secs(8 * 24 * 60 * 60))
|
||||
.expect("set mtime");
|
||||
|
||||
assert_eq!(send_dead(&context).len(), spool::MAX_RECOVERED);
|
||||
assert_eq!(dryrun_batches(&root).len(), spool::MAX_RECOVERED);
|
||||
for path in paths.iter().chain([&stale]) {
|
||||
assert!(!path.exists(), "{} must be gone", path.display());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_processes_never_mix_lines() {
|
||||
let home = temp_home();
|
||||
let root = root_of(&home);
|
||||
let (tui_path, _) = spool::open(&root, &header(Surface::Tui, "0.10.3"));
|
||||
let (exec_path, _) = spool::open(&root, &header(Surface::Exec, "0.10.3"));
|
||||
assert_ne!(tui_path, exec_path);
|
||||
buffer::append(&root, &tui_path, &event_line(SessionSource::Interactive)).expect("append");
|
||||
buffer::append(&root, &exec_path, &event_line(SessionSource::Ci)).expect("append");
|
||||
|
||||
let tui = spool::take_own(&root, &tui_path);
|
||||
assert!(tui.iter().any(|l| l.contains("interactive")));
|
||||
assert!(
|
||||
tui.iter()
|
||||
.all(|l| !l.contains("\"ci\"") && !l.contains("exec"))
|
||||
);
|
||||
assert!(exec_path.exists(), "taking one spool leaves the other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ring_keeps_the_header() {
|
||||
let home = temp_home();
|
||||
let root = root_of(&home);
|
||||
let (path, _) = spool::open(&root, &header(Surface::Serve, "0.10.3"));
|
||||
for n in 0..700 {
|
||||
buffer::append(&root, &path, &line(n)).expect("append");
|
||||
}
|
||||
let lines = buffer::read_lines(&path);
|
||||
assert!(lines.len() <= buffer::MAX_EVENTS);
|
||||
assert!(spool::is_header_line(&lines[0]));
|
||||
assert!(lines.last().unwrap().contains(&line(699)));
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **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).
|
||||
- **Qwen Code as an external agent:** Codewhale now finds an installed Qwen Code CLI (`qwen`, 0.6.1 or newer) and can run it as a session over ACP with `qwen --acp`. `GET /v1/agents/external` lists it, an older install is reported as needing `npm i -g @qwen-code/qwen-code@latest`, and sign-in stays inside Qwen Code (`/auth`). Thanks [@MannXo](https://github.com/MannXo) (PR 7293).
|
||||
- **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.
|
||||
|
||||
+8
-60
@@ -862,6 +862,10 @@ fn spawn_signal_cleanup_task() {
|
||||
let mut signals = TerminatingSignals::register();
|
||||
tokio::spawn(async move {
|
||||
let exit_code = signals.wait().await;
|
||||
codewhale_telemetry::record_session_end_blocking(codewhale_telemetry::ExitClass::Signal);
|
||||
let telemetry_send = tokio::task::spawn_blocking(|| {
|
||||
codewhale_telemetry::flush_for_termination(codewhale_telemetry::SIGNAL_FLUSH_TIMEOUT)
|
||||
});
|
||||
// Persistence and a serving Runtime API share a bounded window to
|
||||
// flush queued saves and end open streams with a typed `stream.end`.
|
||||
// A second signal skips both waits.
|
||||
@@ -870,6 +874,9 @@ fn spawn_signal_cleanup_task() {
|
||||
tokio::join!(
|
||||
codewhale_app_server::runtime_api::drain_for_signal_exit(),
|
||||
crate::tui::persistence_actor::flush_for_signal_exit(),
|
||||
async {
|
||||
let _ = telemetry_send.await;
|
||||
},
|
||||
)
|
||||
} => {}
|
||||
_ = signals.wait() => {}
|
||||
@@ -885,66 +892,11 @@ fn spawn_signal_cleanup_task() {
|
||||
#[cfg(unix)]
|
||||
codewhale_runtime::process_tree::kill_contained_trees_for_exit();
|
||||
crate::tui::ui::emergency_restore_terminal();
|
||||
// Nothing async survives the `exit` below, so this is the last
|
||||
// chance to say how the session ended. `record_blocking` is one
|
||||
// `O_APPEND` write with no lock: taking the compaction lock here
|
||||
// would let a second Codewhale process sharing CODEWHALE_HOME hang
|
||||
// Ctrl-C, and the second-signal short-circuit below has to stay
|
||||
// reachable. A no-op unless this process was armed.
|
||||
//
|
||||
// The class is stated, not derived: `RunTerminationReason::Canceled`
|
||||
// also exits 130, so `exit_code` cannot tell a signal from an
|
||||
// Esc-cancelled turn.
|
||||
record_signal_session_end();
|
||||
}
|
||||
std::process::exit(exit_code);
|
||||
});
|
||||
}
|
||||
|
||||
/// When this process's armed telemetry session began. Set once, at arming, and
|
||||
/// read from both the ordinary teardown and the signal path.
|
||||
static TELEMETRY_SESSION_START: std::sync::OnceLock<std::time::Instant> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
/// Build `session_end` from what this process actually accumulated.
|
||||
///
|
||||
/// The exit class is read from the process-wide atomic and never derived from
|
||||
/// an exit code: `RunTerminationReason::Canceled` maps to 130, the same value
|
||||
/// the SIGINT path uses, so a code-based derivation would report every
|
||||
/// Esc-cancelled turn as a signal.
|
||||
///
|
||||
/// The cold-start bucket is `None` unless the interactive event loop actually
|
||||
/// began, which is what keeps it absent rather than invented on the surfaces
|
||||
/// that have no event loop.
|
||||
fn telemetry_session_end() -> codewhale_telemetry::Event {
|
||||
let counters = codewhale_telemetry::session_counters();
|
||||
codewhale_telemetry::Event::SessionEnd {
|
||||
duration_bucket: codewhale_telemetry::DurationBucket::from_secs(
|
||||
TELEMETRY_SESSION_START
|
||||
.get()
|
||||
.map_or(0, |start| start.elapsed().as_secs()),
|
||||
),
|
||||
exit_class: codewhale_telemetry::exit_class(),
|
||||
cold_start_bucket: codewhale_runtime::startup_trace::cold_start_ms()
|
||||
.map(codewhale_telemetry::ColdStartBucket::from_millis),
|
||||
providers: counters.providers(),
|
||||
counters: counters.counters(),
|
||||
errors: counters.errors(),
|
||||
turn_wall: counters.turn_wall(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the session synchronously, from the signal handler.
|
||||
///
|
||||
/// A no-op unless this process was armed.
|
||||
fn record_signal_session_end() {
|
||||
codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Signal);
|
||||
for event in codewhale_telemetry::session_counters().usage_events() {
|
||||
codewhale_telemetry::record_blocking(event);
|
||||
}
|
||||
codewhale_telemetry::record_blocking(telemetry_session_end());
|
||||
}
|
||||
|
||||
/// Terminating-signal streams, registered up front and awaited later.
|
||||
///
|
||||
/// Splitting registration from the await is the point: the OS disposition
|
||||
@@ -2514,7 +2466,6 @@ fn arm_telemetry_with_setup(
|
||||
return;
|
||||
};
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -2580,10 +2531,7 @@ async fn finish_telemetry(outcome: &Result<()>, surface: codewhale_telemetry::Su
|
||||
{
|
||||
codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Error);
|
||||
}
|
||||
for event in codewhale_telemetry::session_counters().usage_events() {
|
||||
codewhale_telemetry::record(event);
|
||||
}
|
||||
codewhale_telemetry::record(telemetry_session_end());
|
||||
codewhale_telemetry::record_session_end();
|
||||
if surface == codewhale_telemetry::Surface::Cli {
|
||||
let persistence = codewhale_telemetry::persist_local_blocking();
|
||||
logging::info(format!(
|
||||
|
||||
@@ -227,7 +227,7 @@ fn the_session_source_distinguishes_resume_and_fork_from_a_fresh_launch() {
|
||||
|
||||
#[test]
|
||||
fn a_session_end_built_without_arming_writes_nothing() {
|
||||
let event = telemetry_session_end();
|
||||
let event = codewhale_telemetry::session_end_event();
|
||||
assert!(matches!(
|
||||
event,
|
||||
codewhale_telemetry::Event::SessionEnd { .. }
|
||||
|
||||
@@ -87,27 +87,6 @@ pub(crate) use types::{
|
||||
WORKFLOW_DRAFT_INSTRUCTION_PREFIX,
|
||||
};
|
||||
|
||||
const ACCOUNT_PREFERENCES_NOTICE: &str = "account-preferences-notice";
|
||||
|
||||
fn account_sign_in_marker(profile: Option<&str>) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let api_base = codewhale_engine::profile_constitution::runtime_account_api_base();
|
||||
let session = codewhale_secrets::account::AccountSessionStore::new(
|
||||
codewhale_secrets::Secrets::file_backed_read_only(),
|
||||
profile,
|
||||
&api_base,
|
||||
)
|
||||
.runtime_info_at(Utc::now())
|
||||
.ok()
|
||||
.and_then(|info| info.session_id)
|
||||
.unwrap_or_default();
|
||||
Sha256::digest(session.as_bytes())
|
||||
.iter()
|
||||
.take(8)
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
// === Types ===
|
||||
|
||||
/// One login owns one mailbox. A cancelled task can only write its abandoned
|
||||
@@ -2469,7 +2448,6 @@ pub struct App {
|
||||
/// re-render per character. `None` means "no bump since the last
|
||||
/// finalize" so the first chunk of a block always renders immediately.
|
||||
pub thinking_revision_last_bump_at: Option<Instant>,
|
||||
pub(crate) thinking_revision_owed: bool,
|
||||
/// Newline-gated streaming collector state.
|
||||
pub streaming_state: StreamingState,
|
||||
/// Live approximate output tokens for the current assistant stream.
|
||||
@@ -3813,38 +3791,6 @@ impl App {
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn notify_account_preferences_not_loaded(&mut self) {
|
||||
let sign_in = account_sign_in_marker(self.config_profile.as_deref());
|
||||
self.notify_account_preferences_not_loaded_for(&sign_in);
|
||||
}
|
||||
|
||||
fn notify_account_preferences_not_loaded_for(&mut self, sign_in: &str) {
|
||||
let first_for_sign_in = Settings::transact_opt(|settings| {
|
||||
if settings.account_preferences_notice_shown.as_deref() == Some(sign_in) {
|
||||
return Ok(None);
|
||||
}
|
||||
settings.account_preferences_notice_shown = Some(sign_in.to_string());
|
||||
Ok(Some(()))
|
||||
});
|
||||
if matches!(first_for_sign_in, Ok(None)) {
|
||||
return;
|
||||
}
|
||||
let text = self
|
||||
.tr(MessageId::AccountPreferencesNotLoaded)
|
||||
.replace("{command}", "codewhale login");
|
||||
self.push_status_toast_record(StatusToast::standing(
|
||||
text,
|
||||
StatusToastLevel::Warning,
|
||||
ACCOUNT_PREFERENCES_NOTICE,
|
||||
));
|
||||
}
|
||||
|
||||
pub(crate) fn dismiss_account_preferences_notice(&mut self) -> bool {
|
||||
let before = self.status_toasts.len();
|
||||
self.retire_event_notices(ACCOUNT_PREFERENCES_NOTICE);
|
||||
self.status_toasts.len() != before
|
||||
}
|
||||
|
||||
/// One-release migration notice for the Shift+Tab/Ctrl+T rebinding: users
|
||||
/// pressing Shift+Tab expecting the old thinking cycle land here first.
|
||||
fn notify_keybinding_migration_once(&mut self) {
|
||||
|
||||
@@ -1142,7 +1142,6 @@ impl App {
|
||||
streaming_thinking_active_entry: None,
|
||||
pending_thinking_translation_cells: HashMap::new(),
|
||||
thinking_revision_last_bump_at: None,
|
||||
thinking_revision_owed: false,
|
||||
streaming_state: StreamingState::new(),
|
||||
streaming_output_token_estimate: 0,
|
||||
reasoning_buffer: String::new(),
|
||||
|
||||
@@ -184,46 +184,6 @@ fn feature_intro_shows_once_persists_then_is_idempotent() {
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_preferences_notice_shows_once_per_sign_in_and_can_be_dismissed() {
|
||||
let _env_lock = lock_test_env();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("config.toml");
|
||||
let _env = EnvVarGuard::set(
|
||||
"DEEPSEEK_CONFIG_PATH",
|
||||
config_path.to_string_lossy().as_ref(),
|
||||
);
|
||||
let shown = |app: &App| {
|
||||
let notice = app
|
||||
.tr(MessageId::AccountPreferencesNotLoaded)
|
||||
.replace("{command}", "codewhale login");
|
||||
app.status_toasts
|
||||
.iter()
|
||||
.filter(|toast| toast.text == notice && toast.kind == StatusToastKind::Standing)
|
||||
.count()
|
||||
};
|
||||
|
||||
let mut app = App::new(test_options(false), &Config::default());
|
||||
app.notify_account_preferences_not_loaded_for("first-sign-in");
|
||||
app.notify_account_preferences_not_loaded_for("first-sign-in");
|
||||
assert_eq!(shown(&app), 1);
|
||||
assert_eq!(
|
||||
Settings::load()
|
||||
.expect("settings should load")
|
||||
.account_preferences_notice_shown
|
||||
.as_deref(),
|
||||
Some("first-sign-in")
|
||||
);
|
||||
assert!(app.dismiss_account_preferences_notice());
|
||||
assert_eq!(shown(&app), 0);
|
||||
|
||||
let mut later_session = App::new(test_options(false), &Config::default());
|
||||
later_session.notify_account_preferences_not_loaded_for("first-sign-in");
|
||||
assert_eq!(shown(&later_session), 0, "the same sign-in is told once");
|
||||
later_session.notify_account_preferences_not_loaded_for("second-sign-in");
|
||||
assert_eq!(shown(&later_session), 1, "a later sign-in is told again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_input_scenario() {
|
||||
// Scenario consolidation of: initial_input_prefill_waits_for_manual_submit, initial_input_submit_marks_startup_dispatch
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
│ Background (default) ✎ │current terminal
|
||||
│ Pet mode Off [ ] │saved terminal
|
||||
│ Quiet transcript On [x] │startup terminal
|
||||
│ Model reasoning in chat On [x] │source settings.toml
|
||||
│ Model reasoning in chat Off [ ] │source settings.toml
|
||||
│ Thinking Default Expanded Off [ ] │scope SAVED
|
||||
│ Thinking Preview Lines 2 ✎ │apply applies on save
|
||||
│ Thinking background highlight On [x] │kind choice
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
Background (default) ✎ SAVED █
|
||||
Pet mode Off [ ] SAVED █
|
||||
Quiet transcript On [x] SAVED █
|
||||
Model reasoning in chat On [x] SAVED █
|
||||
Model reasoning in chat Off [ ] SAVED █
|
||||
Thinking Default Expanded Off [ ] SAVED █
|
||||
Thinking Preview Lines 2 ✎ SAVED │
|
||||
Thinking background highlight On [x] SAVED │
|
||||
|
||||
@@ -30,17 +30,15 @@ use crate::tui::history::HistoryCell;
|
||||
/// frame cap (see `frame_rate_limiter`) yet imperceptible as lag.
|
||||
///
|
||||
/// Correctness: this only skips *intermediate* repaints. Appended content is
|
||||
/// never dropped — it lands in the cell immediately — the display beat renders
|
||||
/// a skipped bump once the window has passed, and finalize always forces a
|
||||
/// bump so the final reasoning text is fully rendered.
|
||||
/// never dropped — it lands in the cell immediately — and finalize always
|
||||
/// forces a bump so the final reasoning text is fully rendered.
|
||||
const THINKING_REVISION_THROTTLE: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Bump the active-cell revision for a streaming thinking mutation, but at
|
||||
/// most once per [`THINKING_REVISION_THROTTLE`] window. Returns whether a bump
|
||||
/// was actually emitted. Skipped bumps coalesce into the next one, into
|
||||
/// [`render_owed_revision`] on a later display beat, or into the forced
|
||||
/// finalize bump, so no content is ever lost — only redundant intermediate
|
||||
/// re-wraps are dropped.
|
||||
/// was actually emitted. Skipped bumps coalesce into the next one (or into the
|
||||
/// forced finalize bump), so no content is ever lost — only redundant
|
||||
/// intermediate re-wraps are dropped.
|
||||
fn bump_thinking_revision_throttled(app: &mut App, now: Instant) -> bool {
|
||||
let due = app
|
||||
.thinking_revision_last_bump_at
|
||||
@@ -49,17 +47,9 @@ fn bump_thinking_revision_throttled(app: &mut App, now: Instant) -> bool {
|
||||
app.thinking_revision_last_bump_at = Some(now);
|
||||
app.bump_active_cell_revision();
|
||||
}
|
||||
app.thinking_revision_owed = !due;
|
||||
due
|
||||
}
|
||||
|
||||
pub(super) fn render_owed_revision(app: &mut App, now: Instant) -> bool {
|
||||
if app.streaming_thinking_active_entry.is_none() {
|
||||
app.thinking_revision_owed = false;
|
||||
}
|
||||
app.thinking_revision_owed && bump_thinking_revision_throttled(app, now)
|
||||
}
|
||||
|
||||
/// Ensure an in-flight Thinking entry exists in `active_cell` and return its
|
||||
/// entry index. If no thinking entry is currently streaming, push a fresh one.
|
||||
/// P2.3: thinking shares the active cell with subsequent tool calls so the
|
||||
@@ -86,8 +76,7 @@ pub(super) fn ensure_active_entry(app: &mut App) -> usize {
|
||||
/// committed to the cell immediately; the active-cell revision bump that
|
||||
/// triggers a re-wrap of the live tail is debounced to at most one per
|
||||
/// [`THINKING_REVISION_THROTTLE`] window (#1620). Skipped bumps coalesce into
|
||||
/// the next append, a later display beat or the forced finalize bump, so no
|
||||
/// content is ever lost.
|
||||
/// the next append or the forced finalize bump, so no content is ever lost.
|
||||
pub(super) fn append(app: &mut App, entry_idx: usize, text: &str) {
|
||||
append_at(app, entry_idx, text, Instant::now());
|
||||
}
|
||||
@@ -319,7 +308,6 @@ pub(super) fn finalize_active_entry(app: &mut App, duration: Option<f32>, remain
|
||||
// the debounce window so the next thinking block's first chunk renders
|
||||
// immediately rather than being coalesced into a stale window.
|
||||
app.thinking_revision_last_bump_at = None;
|
||||
app.thinking_revision_owed = false;
|
||||
app.bump_active_cell_revision();
|
||||
true
|
||||
}
|
||||
@@ -438,34 +426,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_paused_stream_renders_the_reasoning_it_already_received() {
|
||||
let mut app = test_app();
|
||||
let entry = ensure_active_entry(&mut app);
|
||||
app.thinking_revision_last_bump_at = None;
|
||||
let t0 = Instant::now();
|
||||
append_at(&mut app, entry, "first slice ", t0);
|
||||
let shown = app.active_cell_revision;
|
||||
append_at(&mut app, entry, "held back", t0 + Duration::from_millis(16));
|
||||
assert_eq!(app.active_cell_revision, shown);
|
||||
|
||||
assert!(!render_owed_revision(
|
||||
&mut app,
|
||||
t0 + Duration::from_millis(32)
|
||||
));
|
||||
assert_eq!(app.active_cell_revision, shown);
|
||||
assert!(render_owed_revision(
|
||||
&mut app,
|
||||
t0 + THINKING_REVISION_THROTTLE
|
||||
));
|
||||
assert_eq!(app.active_cell_revision, shown.wrapping_add(1));
|
||||
assert!(!render_owed_revision(
|
||||
&mut app,
|
||||
t0 + THINKING_REVISION_THROTTLE * 3
|
||||
));
|
||||
assert_eq!(app.active_cell_revision, shown.wrapping_add(1));
|
||||
}
|
||||
|
||||
/// #7123: a reasoning block that opens while the display beat still holds
|
||||
/// an uncommitted slice of prose must not drop that slice. `start_block`
|
||||
/// resets `StreamingState`, so the tail has to be delivered first.
|
||||
|
||||
@@ -1418,6 +1418,9 @@ pub async fn run_tui(
|
||||
// than through it. Only this path calls it, which is what keeps the
|
||||
// cold-start bucket absent on surfaces with no event loop.
|
||||
codewhale_runtime::startup_trace::mark_cold_start();
|
||||
if let Some(millis) = codewhale_runtime::startup_trace::cold_start_ms() {
|
||||
codewhale_telemetry::set_cold_start_ms(millis);
|
||||
}
|
||||
let result = run_event_loop(
|
||||
&mut terminal,
|
||||
&mut app,
|
||||
@@ -7232,16 +7235,14 @@ pub(crate) async fn run_event_loop(
|
||||
continue;
|
||||
}
|
||||
// An idle operator can dismiss the persistent context warning
|
||||
// or the account preferences notice without affecting vim or
|
||||
// attachment handling. While a turn is active Esc retains its
|
||||
// cancellation meaning.
|
||||
// without affecting vim or attachment handling. While a turn
|
||||
// is active Esc retains its cancellation meaning.
|
||||
KeyCode::Esc
|
||||
if !app.is_loading
|
||||
&& app.input.is_empty()
|
||||
&& !slash_menu_open
|
||||
&& !mention_menu_open
|
||||
&& (app.dismiss_context_pressure_warning()
|
||||
|| app.dismiss_account_preferences_notice()) =>
|
||||
&& app.dismiss_context_pressure_warning() =>
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -9328,10 +9329,14 @@ pub(super) fn apply_engine_status(app: &mut App, message: String) -> bool {
|
||||
app.add_message(HistoryCell::System { content: message });
|
||||
return true;
|
||||
}
|
||||
if app.tr(MessageId::ProfileConstitutionUnavailableLocal) == message.as_str() {
|
||||
app.notify_account_preferences_not_loaded();
|
||||
return false;
|
||||
// The account-profile fallback notice is sent once per engine; keep it in
|
||||
// the transcript so the next turn's status line cannot erase it.
|
||||
let retain = app.tr(MessageId::ProfileConstitutionUnavailableLocal) == message.as_str();
|
||||
if retain {
|
||||
app.add_message(HistoryCell::System {
|
||||
content: message.clone(),
|
||||
});
|
||||
}
|
||||
app.status_message = Some(message);
|
||||
false
|
||||
retain
|
||||
}
|
||||
@@ -1555,10 +1555,7 @@ pub(crate) fn commit_streaming_display_tick(
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if streaming_thinking::render_owed_revision(app, now) {
|
||||
updated = true;
|
||||
}
|
||||
if app.streaming_state.has_pending_stream_text(0) || app.thinking_revision_owed {
|
||||
if app.streaming_state.has_pending_stream_text(0) {
|
||||
stream_display_clock.note_delta(now);
|
||||
}
|
||||
|
||||
|
||||
@@ -27343,7 +27343,7 @@ fn turn_inspector_switches_isolated_full_turn_pages_with_tagged_reasoning_and_ou
|
||||
|
||||
let latest = pager.body_text();
|
||||
assert!(latest.contains("SECOND-PROMPT"), "{latest}");
|
||||
assert!(latest.contains("[∿ thinking 1/1 · running]"), "{latest}");
|
||||
assert!(latest.contains("[∿ reasoning 1/1 · running]"), "{latest}");
|
||||
assert!(latest.contains("SECOND-THOUGHT"), "{latest}");
|
||||
assert!(latest.contains("[◆ · running]"), "{latest}");
|
||||
assert!(latest.contains("SECOND-ANSWER"), "{latest}");
|
||||
@@ -27355,7 +27355,7 @@ fn turn_inspector_switches_isolated_full_turn_pages_with_tagged_reasoning_and_ou
|
||||
for expected in [
|
||||
"FIRST-PROMPT",
|
||||
"keep this input line",
|
||||
"[∿ thinking 1/1 · Done]",
|
||||
"[∿ reasoning 1/1 · Done]",
|
||||
"FIRST-THOUGHT",
|
||||
"full reasoning tail",
|
||||
"[⚙ using tool]",
|
||||
@@ -35488,41 +35488,23 @@ fn extension_prompt_origin_keeps_denials_and_other_policy_holds() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_profile_fallback_notice_is_a_standing_notice_outside_the_transcript() {
|
||||
fn account_profile_fallback_notice_stays_in_the_transcript() {
|
||||
let _home = SettingsHomeGuard::new();
|
||||
let mut app = App::new(create_test_options(), &Config::default());
|
||||
let engine_notice = app
|
||||
let notice = app
|
||||
.tr(MessageId::ProfileConstitutionUnavailableLocal)
|
||||
.to_string();
|
||||
let before = app.history.len();
|
||||
let status_before = app.status_message.clone();
|
||||
assert!(!super::event_loop::apply_engine_status(
|
||||
assert!(super::event_loop::apply_engine_status(
|
||||
&mut app,
|
||||
engine_notice
|
||||
notice.clone()
|
||||
));
|
||||
assert_eq!(app.history.len(), before);
|
||||
assert_eq!(app.status_message, status_before);
|
||||
assert!(!super::event_loop::apply_engine_status(
|
||||
&mut app,
|
||||
"Executing tools sequentially".into()
|
||||
));
|
||||
assert_eq!(app.history.len(), before);
|
||||
let notice = app
|
||||
.tr(MessageId::AccountPreferencesNotLoaded)
|
||||
.replace("{command}", "codewhale login");
|
||||
let standing = |app: &App| {
|
||||
app.status_toasts
|
||||
.iter()
|
||||
.find(|toast| toast.text == notice)
|
||||
.cloned()
|
||||
};
|
||||
app.sync_status_message_to_toasts();
|
||||
let shown = standing(&app).expect("a later routine status does not erase the notice");
|
||||
assert_eq!(shown.level, StatusToastLevel::Warning);
|
||||
assert_eq!(shown.ttl_ms, None);
|
||||
assert!(app.dismiss_account_preferences_notice());
|
||||
assert!(standing(&app).is_none());
|
||||
assert!(!app.dismiss_account_preferences_notice());
|
||||
assert_eq!(app.history.len(), before + 1);
|
||||
assert!(matches!(&app.history[before], HistoryCell::System { content } if *content == notice));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -32,8 +32,6 @@ mod plugin_e2e_acceptance;
|
||||
mod screen_mode_inline_pty;
|
||||
#[cfg(all(unix, feature = "long-running-tests"))]
|
||||
mod search_text_pty;
|
||||
#[cfg(all(unix, feature = "long-running-tests"))]
|
||||
mod thinking_stream_pty;
|
||||
mod tool_lifecycle_acceptance;
|
||||
#[cfg(all(unix, feature = "long-running-tests"))]
|
||||
mod work_bar_keys_pty;
|
||||
@@ -1,585 +0,0 @@
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{SocketAddr, TcpListener, TcpStream};
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex, mpsc};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::qa_harness::{
|
||||
harness::{Harness, SealedWorkspace, make_sealed_workspace},
|
||||
keys,
|
||||
};
|
||||
|
||||
const WAIT: Duration = Duration::from_secs(20);
|
||||
const MODEL: &str = "deepseek-v4-pro";
|
||||
const PROMPTS: [&str; 2] = ["first slow fixture prompt", "second slow fixture prompt"];
|
||||
const ANSWER: &str = "Fixture answer after thinking.";
|
||||
const REASONING_LINES: usize = 6;
|
||||
const NOTICE: &str = "Account not loaded. Run codewhale login.";
|
||||
const RETIRED_TRANSCRIPT_NOTICE: &str = "could not be loaded";
|
||||
|
||||
fn reasoning_line(index: usize) -> String {
|
||||
format!("thinking fixture line {index} of {REASONING_LINES}")
|
||||
}
|
||||
|
||||
struct PacedProvider {
|
||||
address: SocketAddr,
|
||||
gate: mpsc::Sender<()>,
|
||||
stop: Arc<AtomicBool>,
|
||||
turns: Arc<AtomicUsize>,
|
||||
profile_requests: Arc<AtomicUsize>,
|
||||
acceptor: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
struct Served {
|
||||
gates: Mutex<mpsc::Receiver<()>>,
|
||||
stop: Arc<AtomicBool>,
|
||||
turns: Arc<AtomicUsize>,
|
||||
profile_requests: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl PacedProvider {
|
||||
fn start() -> Self {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("loopback provider");
|
||||
let address = listener.local_addr().expect("loopback address");
|
||||
let (gate, gates) = mpsc::channel();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let turns = Arc::new(AtomicUsize::new(0));
|
||||
let profile_requests = Arc::new(AtomicUsize::new(0));
|
||||
let served = Arc::new(Served {
|
||||
gates: Mutex::new(gates),
|
||||
stop: Arc::clone(&stop),
|
||||
turns: Arc::clone(&turns),
|
||||
profile_requests: Arc::clone(&profile_requests),
|
||||
});
|
||||
let acceptor = std::thread::spawn(move || {
|
||||
for stream in listener.incoming() {
|
||||
if served.stop.load(Ordering::Acquire) {
|
||||
break;
|
||||
}
|
||||
let Ok(stream) = stream else { continue };
|
||||
let served = Arc::clone(&served);
|
||||
std::thread::spawn(move || {
|
||||
let _ = serve(stream, &served);
|
||||
});
|
||||
}
|
||||
});
|
||||
Self {
|
||||
address,
|
||||
gate,
|
||||
stop,
|
||||
turns,
|
||||
profile_requests,
|
||||
acceptor: Some(acceptor),
|
||||
}
|
||||
}
|
||||
|
||||
fn model_base(&self) -> String {
|
||||
format!("http://{}/v1", self.address)
|
||||
}
|
||||
|
||||
fn account_base(&self) -> String {
|
||||
format!("http://{}", self.address)
|
||||
}
|
||||
|
||||
fn release(&self, gates: usize) {
|
||||
for _ in 0..gates {
|
||||
self.gate.send(()).expect("provider gate");
|
||||
}
|
||||
}
|
||||
|
||||
fn turns(&self) -> usize {
|
||||
self.turns.load(Ordering::Acquire)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PacedProvider {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::Release);
|
||||
let _ = TcpStream::connect(self.address);
|
||||
if let Some(acceptor) = self.acceptor.take() {
|
||||
let _ = acceptor.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, served: &Served) -> std::io::Result<()> {
|
||||
stream.set_read_timeout(Some(Duration::from_secs(10)))?;
|
||||
let mut reader = BufReader::new(stream.try_clone()?);
|
||||
let mut stream = stream;
|
||||
let mut request_line = String::new();
|
||||
if reader.read_line(&mut request_line)? == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let mut content_length = 0usize;
|
||||
loop {
|
||||
let mut header = String::new();
|
||||
if reader.read_line(&mut header)? == 0 {
|
||||
break;
|
||||
}
|
||||
let header = header.trim_end();
|
||||
if header.is_empty() {
|
||||
break;
|
||||
}
|
||||
if let Some((name, value)) = header.split_once(':')
|
||||
&& name.eq_ignore_ascii_case("content-length")
|
||||
{
|
||||
content_length = value.trim().parse().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
let mut body = vec![0u8; content_length.min(8 * 1024 * 1024)];
|
||||
reader.read_exact(&mut body)?;
|
||||
let mut parts = request_line.split_whitespace();
|
||||
let method = parts.next().unwrap_or_default();
|
||||
let path = parts.next().unwrap_or_default();
|
||||
if method == "GET" && path.ends_with("/models") {
|
||||
return respond_json(
|
||||
&mut stream,
|
||||
"200 OK",
|
||||
&serde_json::json!({"object":"list","data":[{"id":MODEL,"object":"model"}]}),
|
||||
);
|
||||
}
|
||||
if path.starts_with("/api/") {
|
||||
served.profile_requests.fetch_add(1, Ordering::AcqRel);
|
||||
return respond_json(
|
||||
&mut stream,
|
||||
"401 Unauthorized",
|
||||
&serde_json::json!({"error":"loopback fixture account is signed out"}),
|
||||
);
|
||||
}
|
||||
if method != "POST" {
|
||||
return respond_json(
|
||||
&mut stream,
|
||||
"404 Not Found",
|
||||
&serde_json::json!({"error":"not served by the loopback fixture"}),
|
||||
);
|
||||
}
|
||||
let request: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
|
||||
let streaming = request["stream"].as_bool().unwrap_or(false);
|
||||
let with_tools = request["tools"]
|
||||
.as_array()
|
||||
.is_some_and(|tools| !tools.is_empty());
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
let turn = PROMPTS
|
||||
.iter()
|
||||
.rposition(|prompt| text.contains(prompt))
|
||||
.map(|index| index + 1);
|
||||
let paced = streaming
|
||||
&& with_tools
|
||||
&& turn.is_some_and(|turn| {
|
||||
served
|
||||
.turns
|
||||
.compare_exchange(turn - 1, turn, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_ok()
|
||||
});
|
||||
if !streaming {
|
||||
return respond_json(
|
||||
&mut stream,
|
||||
"200 OK",
|
||||
&serde_json::json!({
|
||||
"id":"loopback","object":"chat.completion","model":MODEL,
|
||||
"choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],
|
||||
"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}
|
||||
}),
|
||||
);
|
||||
}
|
||||
stream.write_all(
|
||||
b"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncache-control: no-cache\r\nconnection: close\r\n\r\n",
|
||||
)?;
|
||||
stream.flush()?;
|
||||
if paced {
|
||||
wait_for_gate(served)?;
|
||||
for index in 1..=REASONING_LINES {
|
||||
send_delta(
|
||||
&mut stream,
|
||||
serde_json::json!({"reasoning_content": format!("{}\n", reasoning_line(index))}),
|
||||
)?;
|
||||
}
|
||||
wait_for_gate(served)?;
|
||||
send_delta(&mut stream, serde_json::json!({"content": ANSWER}))?;
|
||||
wait_for_gate(served)?;
|
||||
} else {
|
||||
send_delta(&mut stream, serde_json::json!({"content": "ok"}))?;
|
||||
}
|
||||
send_chunk(
|
||||
&mut stream,
|
||||
&serde_json::json!({
|
||||
"id":"loopback","object":"chat.completion.chunk","model":MODEL,
|
||||
"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],
|
||||
"usage":{"prompt_tokens":8,"completion_tokens":4,"total_tokens":12}
|
||||
}),
|
||||
)?;
|
||||
stream.write_all(b"data: [DONE]\n\n")?;
|
||||
stream.flush()
|
||||
}
|
||||
|
||||
fn wait_for_gate(served: &Served) -> std::io::Result<()> {
|
||||
let gates = served.gates.lock().expect("provider gates");
|
||||
loop {
|
||||
if served.stop.load(Ordering::Acquire) {
|
||||
return Err(std::io::ErrorKind::Interrupted.into());
|
||||
}
|
||||
match gates.recv_timeout(Duration::from_millis(100)) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => {
|
||||
return Err(std::io::ErrorKind::BrokenPipe.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn send_delta(stream: &mut TcpStream, delta: serde_json::Value) -> std::io::Result<()> {
|
||||
send_chunk(
|
||||
stream,
|
||||
&serde_json::json!({
|
||||
"id":"loopback","object":"chat.completion.chunk","model":MODEL,
|
||||
"choices":[{"index":0,"delta":delta,"finish_reason":null}]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn send_chunk(stream: &mut TcpStream, chunk: &serde_json::Value) -> std::io::Result<()> {
|
||||
stream.write_all(format!("data: {chunk}\n\n").as_bytes())?;
|
||||
stream.flush()
|
||||
}
|
||||
|
||||
fn respond_json(
|
||||
stream: &mut TcpStream,
|
||||
status: &str,
|
||||
body: &serde_json::Value,
|
||||
) -> std::io::Result<()> {
|
||||
let body = body.to_string();
|
||||
stream.write_all(
|
||||
format!(
|
||||
"HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
)
|
||||
.as_bytes(),
|
||||
)?;
|
||||
stream.flush()
|
||||
}
|
||||
|
||||
fn prepare(workspace: &SealedWorkspace, provider: &PacedProvider, settings: Option<&str>) {
|
||||
let state = workspace.home().join(".codewhale");
|
||||
std::fs::write(state.join(".onboarded"), "").unwrap();
|
||||
std::fs::write(
|
||||
state.join("config.toml"),
|
||||
format!(
|
||||
"provider = \"deepseek\"\n[providers.deepseek]\nbase_url = {:?}\napi_key = \"loopback-only\"\nmodel = {MODEL:?}\n[notifications]\nmethod = \"off\"\ncompletion_sound = \"off\"\n",
|
||||
provider.model_base()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
if let Some(settings) = settings {
|
||||
std::fs::write(state.join("settings.toml"), settings).unwrap();
|
||||
}
|
||||
let trust = workspace.workspace().join(".deepseek");
|
||||
std::fs::create_dir_all(&trust).unwrap();
|
||||
std::fs::write(trust.join("trusted"), "").unwrap();
|
||||
}
|
||||
|
||||
fn launch(
|
||||
workspace: &SealedWorkspace,
|
||||
provider: &PacedProvider,
|
||||
(rows, cols): (u16, u16),
|
||||
session: &str,
|
||||
animated: bool,
|
||||
) -> Harness {
|
||||
let mut tui = Harness::builder(Harness::codewhale_binary())
|
||||
.cwd(workspace.workspace())
|
||||
.clear_env()
|
||||
.seal_home(workspace.home())
|
||||
.env("PATH", "/usr/bin:/bin:/usr/sbin:/sbin")
|
||||
.env("SHELL", "/bin/sh")
|
||||
.env("CODEWHALE_CLOUD_API_BASE", provider.account_base())
|
||||
.env("CODEWHALE_DISABLE_MODELS_DEV_FETCH", "1")
|
||||
.env("CODEWHALE_NO_UPDATE_CHECK", "1")
|
||||
.env("NO_ANIMATIONS", if animated { "0" } else { "1" })
|
||||
.args([
|
||||
"--workspace",
|
||||
workspace.workspace().to_str().unwrap(),
|
||||
"--no-project-config",
|
||||
"--skip-onboarding",
|
||||
session,
|
||||
])
|
||||
.size(rows, cols)
|
||||
.spawn()
|
||||
.unwrap();
|
||||
tui.wait_for_text("accept edits", WAIT)
|
||||
.unwrap_or_else(|error| panic!("the dial chip never painted: {error}"));
|
||||
tui
|
||||
}
|
||||
|
||||
fn expect(tui: &mut Harness, what: &str, predicate: impl FnMut(&super::qa_harness::Frame) -> bool) {
|
||||
if let Err(error) = tui.wait_for(predicate, WAIT) {
|
||||
panic!("waiting for {what}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
fn settle(tui: &mut Harness) {
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
let mut quiet_since = Instant::now();
|
||||
while Instant::now() < deadline && quiet_since.elapsed() < Duration::from_millis(300) {
|
||||
if tui.pump() {
|
||||
quiet_since = Instant::now();
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
fn report(tui: &mut Harness, label: &str) {
|
||||
let frame = tui.frame();
|
||||
println!(
|
||||
"--- {label} at {}x{} ---\n{}",
|
||||
frame.cols(),
|
||||
frame.rows(),
|
||||
frame.debug_dump()
|
||||
);
|
||||
}
|
||||
|
||||
fn footer_row(tui: &mut Harness) -> (u16, String) {
|
||||
let frame = tui.frame();
|
||||
(0..frame.rows())
|
||||
.rev()
|
||||
.map(|row| (row, frame.row(row)))
|
||||
.find(|(_, text)| text.contains("accept edits"))
|
||||
.unwrap_or_else(|| panic!("no dial chip on screen:\n{}", frame.debug_dump()))
|
||||
}
|
||||
|
||||
fn assert_status_and_water(tui: &mut Harness, label: &str) {
|
||||
let (footer, status) = footer_row(tui);
|
||||
let frame = tui.frame();
|
||||
for retired in ["operate", "Operate", "Work bar"] {
|
||||
assert!(
|
||||
!frame.contains(retired),
|
||||
"{label}: `{retired}` is on screen:\n{}",
|
||||
frame.debug_dump()
|
||||
);
|
||||
}
|
||||
assert!(status.contains("accept edits"), "{label}: {status}");
|
||||
for row in 0..footer.saturating_sub(3) {
|
||||
let text = frame.row(row);
|
||||
let cells: Vec<char> = text.chars().collect();
|
||||
let school = cells.windows(2).any(|pair| {
|
||||
pair.iter()
|
||||
.all(|cell| ('\u{2801}'..='\u{28FF}').contains(cell))
|
||||
});
|
||||
assert!(
|
||||
!school && !text.contains(['˚', '°', '≈', '~']),
|
||||
"{label}: ambient art on transcript row {row} `{text}`:\n{}",
|
||||
frame.debug_dump()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn thinking_rows(tui: &mut Harness) -> usize {
|
||||
let frame = tui.frame();
|
||||
(0..frame.rows())
|
||||
.filter(|&row| frame.row(row).contains("Thinking…"))
|
||||
.count()
|
||||
}
|
||||
|
||||
fn stream_first_turn(tui: &mut Harness, provider: &PacedProvider, thinking_shown: bool) {
|
||||
tui.type_line(PROMPTS[0]).unwrap();
|
||||
expect(tui, "the first request and its interrupt hint", |frame| {
|
||||
provider.turns() == 1 && frame.contains("Esc to interrupt")
|
||||
});
|
||||
std::thread::sleep(Duration::from_millis(900));
|
||||
report(tui, "loading");
|
||||
assert_status_and_water(tui, "loading");
|
||||
|
||||
provider.release(1);
|
||||
let tail = reasoning_line(REASONING_LINES);
|
||||
if thinking_shown {
|
||||
expect(tui, "the live thinking block", |frame| {
|
||||
frame.contains("Thinking…") && frame.contains(&tail)
|
||||
});
|
||||
} else {
|
||||
expect(tui, "the thinking activity line", |frame| {
|
||||
frame.contains("Thinking…")
|
||||
});
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(1300));
|
||||
report(tui, "thinking");
|
||||
assert_status_and_water(tui, "thinking");
|
||||
assert_eq!(thinking_rows(tui), 1, "one Thinking line");
|
||||
let (_, status) = footer_row(tui);
|
||||
assert!(
|
||||
status.contains("thinking ") && !status.contains("reasoning"),
|
||||
"the clock names the phase: {status}"
|
||||
);
|
||||
let frame = tui.frame();
|
||||
assert!(!frame.contains("reasoning..."), "{}", frame.debug_dump());
|
||||
assert_eq!(frame.contains(&tail), thinking_shown);
|
||||
assert!(
|
||||
!frame.contains(&reasoning_line(1)),
|
||||
"a long trace stays inside its window:\n{}",
|
||||
frame.debug_dump()
|
||||
);
|
||||
|
||||
provider.release(1);
|
||||
expect(tui, "the streamed answer", |frame| frame.contains(ANSWER));
|
||||
report(tui, "answer");
|
||||
let frame = tui.frame();
|
||||
assert!(!frame.contains("Thinking…"), "{}", frame.debug_dump());
|
||||
assert_eq!(frame.contains("Thought for "), thinking_shown);
|
||||
assert!(!frame.contains(&tail), "{}", frame.debug_dump());
|
||||
|
||||
provider.release(1);
|
||||
expect(tui, "the settled turn", |frame| {
|
||||
!frame.contains("Esc to interrupt")
|
||||
});
|
||||
settle(tui);
|
||||
report(tui, "settled");
|
||||
assert_status_and_water(tui, "settled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thinking_streams_stops_and_restores_at_the_shipped_widths() {
|
||||
for size in [(24, 80), (32, 100), (32, 120), (40, 140)] {
|
||||
let workspace = make_sealed_workspace().unwrap();
|
||||
let provider = PacedProvider::start();
|
||||
prepare(&workspace, &provider, None);
|
||||
let mut tui = launch(&workspace, &provider, size, "--fresh", true);
|
||||
stream_first_turn(&mut tui, &provider, true);
|
||||
|
||||
tui.type_line(PROMPTS[1]).unwrap();
|
||||
expect(&mut tui, "the second request", |_| provider.turns() == 2);
|
||||
provider.release(1);
|
||||
let tail = reasoning_line(REASONING_LINES);
|
||||
expect(&mut tui, "thinking before the stop", |frame| {
|
||||
frame.contains("Thinking…") && frame.contains(&tail)
|
||||
});
|
||||
tui.send(keys::key::esc()).unwrap();
|
||||
expect(&mut tui, "the stopped turn", |frame| {
|
||||
!frame.contains("Esc to interrupt") && !frame.contains("Thinking…")
|
||||
});
|
||||
settle(&mut tui);
|
||||
report(&mut tui, "stopped");
|
||||
assert_status_and_water(&mut tui, "stopped");
|
||||
provider.release(2);
|
||||
|
||||
tui.send(keys::key::ctrl('u')).unwrap();
|
||||
settle(&mut tui);
|
||||
tui.send(keys::key::ctrl_d()).unwrap();
|
||||
tui.wait_for_exit(Duration::from_secs(10));
|
||||
tui.shutdown();
|
||||
|
||||
let mut tui = launch(&workspace, &provider, size, "--continue", false);
|
||||
expect(&mut tui, "the restored conversation", |frame| {
|
||||
frame.contains(ANSWER)
|
||||
});
|
||||
settle(&mut tui);
|
||||
report(&mut tui, "restored");
|
||||
assert_status_and_water(&mut tui, "restored");
|
||||
let frame = tui.frame();
|
||||
assert!(!frame.contains("Thinking…"), "{}", frame.debug_dump());
|
||||
tui.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_choice_to_hide_thinking_survives_the_new_default() {
|
||||
let workspace = make_sealed_workspace().unwrap();
|
||||
let provider = PacedProvider::start();
|
||||
prepare(&workspace, &provider, Some("show_thinking = false\n"));
|
||||
let mut tui = launch(&workspace, &provider, (24, 80), "--fresh", false);
|
||||
stream_first_turn(&mut tui, &provider, false);
|
||||
let frame = tui.frame();
|
||||
assert!(!frame.contains("Thought"), "{}", frame.debug_dump());
|
||||
tui.shutdown();
|
||||
let saved: toml::Value = toml::from_str(
|
||||
&std::fs::read_to_string(workspace.home().join(".codewhale/settings.toml")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(saved["show_thinking"].as_bool(), Some(false));
|
||||
}
|
||||
|
||||
fn sign_in(workspace: &SealedWorkspace, provider: &PacedProvider, session: &str) {
|
||||
let path = workspace.home().join(".codewhale/secrets/secrets.json");
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let secrets =
|
||||
codewhale_secrets::Secrets::new(Arc::new(codewhale_secrets::FileKeyringStore::new(path)));
|
||||
let bundle = serde_json::from_value(serde_json::json!({
|
||||
"tokenType": "Bearer",
|
||||
"accessToken": "loopback-access",
|
||||
"refreshToken": "loopback-refresh",
|
||||
"session": {"id": session},
|
||||
"user": {"id": "loopback-account"}
|
||||
}))
|
||||
.unwrap();
|
||||
codewhale_secrets::account::AccountSessionStore::new(secrets, None, &provider.account_base())
|
||||
.save(bundle)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn run_ungated_turn(tui: &mut Harness, provider: &PacedProvider) {
|
||||
provider.release(3);
|
||||
tui.type_line(PROMPTS[0]).unwrap();
|
||||
expect(tui, "the answer", |frame| frame.contains(ANSWER));
|
||||
expect(tui, "the settled turn", |frame| {
|
||||
!frame.contains("Esc to interrupt")
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_preferences_notice_is_shown_once_per_sign_in_and_esc_dismisses_it() {
|
||||
let workspace = make_sealed_workspace().unwrap();
|
||||
let provider = PacedProvider::start();
|
||||
prepare(&workspace, &provider, None);
|
||||
sign_in(&workspace, &provider, "first-sign-in");
|
||||
|
||||
for cols in [80, 120] {
|
||||
let workspace = make_sealed_workspace().unwrap();
|
||||
let provider = PacedProvider::start();
|
||||
prepare(&workspace, &provider, None);
|
||||
sign_in(&workspace, &provider, "first-sign-in");
|
||||
let mut tui = launch(&workspace, &provider, (32, cols), "--fresh", false);
|
||||
run_ungated_turn(&mut tui, &provider);
|
||||
expect(&mut tui, "the standing notice", |frame| {
|
||||
frame.contains(NOTICE)
|
||||
});
|
||||
report(&mut tui, "account notice");
|
||||
assert!(provider.profile_requests.load(Ordering::Acquire) > 0);
|
||||
let (_, status) = footer_row(&mut tui);
|
||||
assert!(
|
||||
status.contains(NOTICE),
|
||||
"the notice is footer chrome: {status}"
|
||||
);
|
||||
assert!(!tui.frame().contains(RETIRED_TRANSCRIPT_NOTICE));
|
||||
tui.send(keys::key::esc()).unwrap();
|
||||
expect(&mut tui, "the dismissed notice", |frame| {
|
||||
!frame.contains(NOTICE)
|
||||
});
|
||||
report(&mut tui, "account notice dismissed");
|
||||
tui.shutdown();
|
||||
}
|
||||
|
||||
let mut tui = launch(&workspace, &provider, (32, 120), "--fresh", false);
|
||||
run_ungated_turn(&mut tui, &provider);
|
||||
expect(&mut tui, "the first notice", |frame| frame.contains(NOTICE));
|
||||
tui.shutdown();
|
||||
|
||||
let mut tui = launch(&workspace, &provider, (32, 120), "--fresh", false);
|
||||
provider.turns.store(0, Ordering::Release);
|
||||
run_ungated_turn(&mut tui, &provider);
|
||||
tui.wait_for_idle(Duration::from_secs(6), Duration::from_secs(30))
|
||||
.unwrap();
|
||||
report(&mut tui, "same sign-in, later session");
|
||||
assert!(
|
||||
!tui.frame().contains(NOTICE),
|
||||
"the same sign-in is told once"
|
||||
);
|
||||
assert!(!tui.frame().contains(RETIRED_TRANSCRIPT_NOTICE));
|
||||
tui.shutdown();
|
||||
|
||||
sign_in(&workspace, &provider, "second-sign-in");
|
||||
let mut tui = launch(&workspace, &provider, (32, 120), "--fresh", false);
|
||||
provider.turns.store(0, Ordering::Release);
|
||||
run_ungated_turn(&mut tui, &provider);
|
||||
expect(&mut tui, "the notice for a later sign-in", |frame| {
|
||||
frame.contains(NOTICE)
|
||||
});
|
||||
report(&mut tui, "later sign-in");
|
||||
tui.shutdown();
|
||||
}
|
||||
@@ -51,7 +51,7 @@ const SENTINEL_FILENAME: &str = "tc-workspace-sentinel-file.txt";
|
||||
const SENTINEL_PROVIDER_TABLE: &str = "tc_custom_provider_sentinel";
|
||||
const SENTINEL_MCP_SERVER: &str = "tc-mcp-server-sentinel";
|
||||
const SENTINEL_API_KEY: &str = "tc-api-key-sentinel-not-a-real-key";
|
||||
/// Planted by writing to `buffer.jsonl` directly, which is what any other
|
||||
/// Planted by writing to a spool file directly, which is what any other
|
||||
/// process running as this user can do.
|
||||
const SENTINEL_INJECTED: &str = "tc-injected-sentinel-/Users/victim/secret-repo";
|
||||
/// The key lives only in the child's environment, never in a file, so the
|
||||
@@ -279,22 +279,43 @@ async fn assert_no_batches(server: &MockServer, why: &str) {
|
||||
);
|
||||
}
|
||||
|
||||
fn spool_files(fixture: &Fixture) -> Vec<PathBuf> {
|
||||
let Ok(entries) = std::fs::read_dir(fixture.telemetry_root().join("spool")) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut files: Vec<PathBuf> = entries
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| path.extension().is_some_and(|ext| ext == "jsonl"))
|
||||
.collect();
|
||||
files.sort();
|
||||
files
|
||||
}
|
||||
|
||||
fn only_spool_file(fixture: &Fixture) -> Option<PathBuf> {
|
||||
let mut files = spool_files(fixture);
|
||||
(files.len() == 1).then(|| files.remove(0))
|
||||
}
|
||||
|
||||
fn buffered_events(fixture: &Fixture) -> Vec<Value> {
|
||||
let path = fixture.telemetry_root().join("buffer.jsonl");
|
||||
let body = std::fs::read_to_string(&path).unwrap_or_else(|error| {
|
||||
panic!(
|
||||
"read locally buffered telemetry at {}: {error}",
|
||||
path.display()
|
||||
)
|
||||
});
|
||||
body.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.map(|line| {
|
||||
serde_json::from_str::<Value>(line).unwrap_or_else(|error| {
|
||||
let mut events = Vec::new();
|
||||
for path in spool_files(fixture) {
|
||||
let body = std::fs::read_to_string(&path).unwrap_or_else(|error| {
|
||||
panic!(
|
||||
"read locally buffered telemetry at {}: {error}",
|
||||
path.display()
|
||||
)
|
||||
});
|
||||
for line in body.lines().filter(|line| !line.trim().is_empty()) {
|
||||
let value = serde_json::from_str::<Value>(line).unwrap_or_else(|error| {
|
||||
panic!("buffered telemetry must be JSON: {error}\nline: {line}")
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
if value.get("event").is_some() {
|
||||
events.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
async fn assert_short_cli_persistence_without_network(
|
||||
@@ -380,8 +401,12 @@ async fn incomplete_short_cli_sessions_require_an_explicit_timeout() {
|
||||
let server = start_recorder().await;
|
||||
let fixture = Fixture::new().with_endpoint(&server.uri());
|
||||
std::fs::create_dir_all(fixture.telemetry_root()).expect("create local buffer root");
|
||||
std::fs::create_dir_all(fixture.telemetry_root().join("spool")).expect("create spool dir");
|
||||
std::fs::write(
|
||||
fixture.telemetry_root().join("buffer.jsonl"),
|
||||
fixture
|
||||
.telemetry_root()
|
||||
.join("spool")
|
||||
.join(format!("{}.jsonl", "a".repeat(32))),
|
||||
"{\"event\":\"session_start\",\"source\":\"unknown\"}\n",
|
||||
)
|
||||
.expect("write incomplete session");
|
||||
@@ -845,8 +870,10 @@ async fn a_hostile_buffer_line_never_reaches_a_batch() {
|
||||
let stdout = read_in_background(child.stdout.take().expect("stdout pipe"));
|
||||
let stderr = read_in_background(child.stderr.take().expect("stderr pipe"));
|
||||
|
||||
let buffer = fixture.telemetry_root().join("buffer.jsonl");
|
||||
wait_until(Duration::from_secs(30), || buffer.exists());
|
||||
wait_until(Duration::from_secs(30), || {
|
||||
only_spool_file(&fixture).is_some()
|
||||
});
|
||||
let buffer = only_spool_file(&fixture).expect("this session's spool");
|
||||
append_lines(
|
||||
&buffer,
|
||||
&[
|
||||
@@ -929,11 +956,10 @@ async fn mid_session_opt_out_stops_the_shutdown_flush() {
|
||||
// Wait until this session's event is actually buffered, then leave enough
|
||||
// time for any accidental background flush to reach the recorder. Nothing
|
||||
// may be sent before shutdown.
|
||||
let buffer = fixture.telemetry_root().join("buffer.jsonl");
|
||||
wait_until(Duration::from_secs(30), || {
|
||||
std::fs::read_to_string(&buffer)
|
||||
.map(|body| body.contains("\"event\":\"session_start\""))
|
||||
.unwrap_or(false)
|
||||
only_spool_file(&fixture)
|
||||
.and_then(|buffer| std::fs::read_to_string(buffer).ok())
|
||||
.is_some_and(|body| body.contains("\"event\":\"session_start\""))
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
assert_no_batches(&server, "before the shutdown flush").await;
|
||||
@@ -981,7 +1007,7 @@ async fn ctrl_c_exits_while_a_second_process_holds_the_lock() {
|
||||
// Arming itself takes the lock, so the holder below must wait until the
|
||||
// session is armed — otherwise this test would pin a deadlock it created.
|
||||
wait_until(Duration::from_secs(30), || {
|
||||
root.join("buffer.jsonl").exists()
|
||||
only_spool_file(&fixture).is_some()
|
||||
});
|
||||
let _holder = LockHolder::take(&lock_path);
|
||||
|
||||
|
||||
@@ -2230,10 +2230,7 @@ Common settings keys:
|
||||
`@`-mention completions are populated. `fuzzy` searches the workspace and
|
||||
applies mention frecency. `browser` lists only the immediate children of the
|
||||
currently typed directory segment in deterministic alphabetical order.
|
||||
- `show_thinking` (on/off, default on): shows model thinking in the transcript,
|
||||
live while it streams and collapsed under a `Thought for …` header once the
|
||||
answer starts. Off hides it; answers and tools stay visible. A value already
|
||||
saved in `settings.toml` is kept.
|
||||
- `show_thinking` (on/off)
|
||||
- `thinking_default_expanded` (on/off, default off): renders thinking blocks
|
||||
expanded initially when `show_thinking` is enabled. Space still toggles the
|
||||
selected block, and it decides only the blocks you have not touched: a block
|
||||
|
||||
+49
-20
@@ -65,11 +65,11 @@ 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, deletes every spool, truncates 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
|
||||
there and the buffer is undrainable — a failed wipe fails closed. Every later
|
||||
there and no spool can be sent — a failed wipe fails closed. Every later
|
||||
run re-asserts the same tombstone for as long as the setting stands, so it
|
||||
survives. Turning telemetry back on in `/settings` records that explicit preference
|
||||
in both existing privacy registers; a fresh launch can then clear the tombstone. Nothing buffered before that point is
|
||||
@@ -101,17 +101,21 @@ Everything is under `$CODEWHALE_HOME/telemetry/` (`0700`), every file `0600`:
|
||||
|
||||
| file | role |
|
||||
|---|---|
|
||||
| `buffer.jsonl` | pending events, one JSON object per line |
|
||||
| `spool/<nonce>.jsonl` | one running process's pending events: a header line (schema, notice and app version, release sha, surface, whether a terminal), then one JSON object per line |
|
||||
| `spool/<nonce>.lock` | held by that process while it runs; a spool whose lock is free belongs to a process that has ended |
|
||||
| `buffer.jsonl.lock` | the sibling ordering lock shared by writes, delivery, arming, and wipe |
|
||||
| `buffer.jsonl` | left over from older versions; only ever emptied, never written or sent |
|
||||
| `dryrun.jsonl` | where batches go when the endpoint is configured empty |
|
||||
| `state.json` | the last app version seen and the last flush attempt |
|
||||
| `install_id.json` | the random install id and when it was minted |
|
||||
| `disabled` | the tombstone; present means nothing is appended or sent |
|
||||
|
||||
Both `buffer.jsonl` and `dryrun.jsonl` are rings capped at 512 records or
|
||||
256 KiB, whichever comes first, with the oldest dropped. The documented
|
||||
footprint ceiling for the whole directory is therefore **512 KiB plus a few
|
||||
hundred bytes of metadata**.
|
||||
The nonce is random, names a file on your disk and is never sent. Each spool and
|
||||
`dryrun.jsonl` is a ring capped at 512 records (the header counts as one) or
|
||||
256 KiB, whichever comes first, with the oldest events dropped. The documented
|
||||
footprint ceiling is therefore **256 KiB for each running process, 256 KiB for
|
||||
`dryrun.jsonl`, and at most four leftover spools of ended processes kept after
|
||||
each TUI, `exec` or `serve` start**, plus a few hundred bytes of metadata.
|
||||
|
||||
The install id is a random v4 UUID. It is never derived from your hostname,
|
||||
MAC address, `machine-id`, home directory, username, or executable path — a
|
||||
@@ -126,18 +130,43 @@ claim one.
|
||||
## When anything is sent, and where
|
||||
|
||||
Nothing is collected or sent when a persistent opt-out or run-scoped kill
|
||||
switch is in force, or when privacy state is unreadable. TUI and `exec` sessions have exactly one network flush point: an attempt
|
||||
during shutdown, bounded at three seconds. Short CLI commands such as `config`,
|
||||
`doctor`, and `auth` do not wait for that network request. They record
|
||||
`session_end`, seal the event to the local buffer with a much shorter bound, and
|
||||
return; a configured endpoint sends those buffered events with the next
|
||||
interactive shutdown flush. An explicitly empty endpoint finalizes its local
|
||||
dry-run batch immediately.
|
||||
switch is in force, or when privacy state is unreadable. TUI and `exec` sessions have one network flush point at a normal end: an attempt
|
||||
during shutdown, bounded at three seconds. That flush sends only the process's
|
||||
own spool, under that process's own surface and version, and deletes it before
|
||||
the request is made. Short CLI commands such as `config`, `doctor`, and `auth`
|
||||
do not wait for that network request. They record `session_end`, seal the event
|
||||
to their own spool with a much shorter bound, and return; the next TUI, `exec`
|
||||
or `serve` start sends what they left (below). An explicitly empty endpoint
|
||||
finalizes its local dry-run batch immediately.
|
||||
|
||||
There is no startup flush, mid-session flush, per-turn flush, or per-tool-call
|
||||
flush. Every network shutdown flush re-resolves your setting from disk
|
||||
A session that is ended early makes one more bounded attempt, so its usage is
|
||||
not lost with it. When a terminating signal (interrupt, terminate, hang-up, or
|
||||
console close) reaches a TUI, `exec` or `serve` session, and when `exec` ends
|
||||
because the program reading its output has closed the pipe, the session records
|
||||
`session_end` with exit class `signal`, then makes one send attempt bounded at
|
||||
one and a half seconds, alongside the process's own shutdown work. A second
|
||||
signal ends the process at once without waiting. This is the same single `POST`
|
||||
of the same fields, never retried. Short CLI commands stay local-only on this
|
||||
path too.
|
||||
|
||||
A session that ends without any report (killed outright, a power loss, a crash
|
||||
before the handler runs) leaves its spool behind. When a later TUI, `exec` or
|
||||
`serve` session starts, it sends the leftovers once: at most four, newest first,
|
||||
one `POST` per leftover session, each carrying that session's own surface and
|
||||
app version and this installation's current install id and send time. Each
|
||||
leftover is deleted before its request is made and is never retried. A leftover
|
||||
older than seven days, one beyond the newest four, one written by a build with a
|
||||
different schema or notice version, and any line that fails the schema bounds is
|
||||
deleted without being sent. Consent and the opt-out tombstone are checked
|
||||
before anything is read and again before each request, and the request runs on
|
||||
its own thread so it never delays your session. Opting out deletes every spool.
|
||||
Opting back in starts without any spool from before.
|
||||
|
||||
There is no mid-session flush, per-turn flush, or per-tool-call
|
||||
flush. Every network flush re-resolves your setting from disk
|
||||
immediately beforehand, so `codewhale config set telemetry false` written from
|
||||
another terminal stops the flush of a session that is already running.
|
||||
another terminal stops the flush of a session that is already running, including
|
||||
one that is being terminated.
|
||||
|
||||
A flush is **one `POST`** to the resolved endpoint — by default
|
||||
`https://telemetry.codewhale.net/v1/telemetry`. The request carries a
|
||||
@@ -206,7 +235,7 @@ new fields, surfaces, and `product_usage` events.
|
||||
| `arch` | enum | `std::env::consts::ARCH` | `x86_64 \| aarch64 \| other`. |
|
||||
| `libc` | enum | `cfg!(target_env)` — **compile time** | `gnu \| musl \| none`. Runtime detection reads distro vendor strings; compile-time is free and leaks nothing. |
|
||||
| `tty` | bool | `std::io::IsTerminal`, as at `crates/telemetry/src/envelope.rs:196` | `stdin().is_terminal() && stdout().is_terminal()`. |
|
||||
| `events` | array | the drained buffer | Every element is one of the six events below and nothing else. Capped at 200 events or 64 KiB per batch; a batch that would exceed either cap leaves the remainder buffered for the next flush. |
|
||||
| `events` | array | the process's spool | Every element is one of the six events below and nothing else. Capped at 200 events or 64 KiB per batch; events beyond either cap are dropped with the spool, never carried to a later flush. |
|
||||
|
||||
**`os_major` is not collected.** Reading it costs unsafe FFI on two platforms plus a file parser on a third, in the one crate whose entire value is being small enough to audit — and `os`, `arch`, and `libc` are free and answer the platform question. It may be reconsidered if the stored data ever shows that the OS-version cut is what triage is missing; a hunch is not that evidence.
|
||||
|
||||
@@ -317,7 +346,7 @@ 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`. **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 when the first signal arrives, by `exec` when its output pipe closes, 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.
|
||||
|
||||
@@ -541,7 +570,7 @@ is established by this source change.
|
||||
|
||||
`install_id` rotates client-side every 90 days (`rotated_at` in `install_id.json`), so no single identifier spans a long history. This costs longitudinal accuracy and the docs say so: **no count derived from `install_id` is a user count.** It is a lower bound on distinct machine-installs in a window, and it undercounts a returning user across a rotation.
|
||||
|
||||
**Turning it off deletes what was kept locally, not what was already sent.** `codewhale config set telemetry false` erases the install id, the buffer, and the dry-run records on your machine, and stops anything further. Rows already accepted by the endpoint are keyed only by a rotating random id that is now gone; first-party rows age out with the three-month window. If PostHog processing is activated, already-delivered processor records follow that project’s configured retention. The client has no remote deletion API and does not claim that local opt-out erases delivered records.
|
||||
**Turning it off deletes what was kept locally, not what was already sent.** `codewhale config set telemetry false` erases the install id, every spool, and the dry-run records on your machine, and stops anything further. Rows already accepted by the endpoint are keyed only by a rotating random id that is now gone; first-party rows age out with the three-month window. If PostHog processing is activated, already-delivered processor records follow that project’s configured retention. The client has no remote deletion API and does not claim that local opt-out erases delivered records.
|
||||
|
||||
### What the owner reads back — observed active installs
|
||||
|
||||
|
||||
+1
-1
@@ -805,7 +805,7 @@ owner = "crates/tui/src/commands/groups/session/export.rs"
|
||||
[[feature]]
|
||||
id = "live-thinking"
|
||||
name = "Live thinking"
|
||||
summary = "Model thinking shows live in the transcript, then collapses under a Thought header; /verbose shows it in full."
|
||||
summary = "/verbose toggles live model thinking in the transcript."
|
||||
status = "stable"
|
||||
surfaces = { tui = "stable" }
|
||||
owner = "crates/tui/src/commands/groups/config/mod.rs"
|
||||
|
||||
@@ -1152,7 +1152,7 @@ codewhale 还把用户偏好存储在:
|
||||
- `mention_menu_limit`(整数,默认 `128`):输入区渲染可见窗口之前保留的 `@`-提及弹出候选最大数。可见行仍取决于终端高度。
|
||||
- `mention_walk_depth`(整数,默认 `10`):`@`-提及补全遍历的最大工作区深度。在深度嵌套的工作区设为 `0` 表示无限深度;在非常大的仓库保持默认,除非需要。
|
||||
- `mention_menu_behavior`(`fuzzy`、`browser`;默认 `fuzzy`):控制 `@`-提及补全如何填充。`fuzzy` 搜索工作区并应用提及 frecency。`browser` 只按确定性字母顺序列出当前输入目录段的直接子项。
|
||||
- `show_thinking`(on/off,默认 on):在对话记录中显示模型思考,流式输出时实时显示,回答开始后折叠到“思考用时 …”标题下。设为 off 则隐藏思考;回答和工具仍然可见。`settings.toml` 中已保存的值保持不变。
|
||||
- `show_thinking`(on/off)
|
||||
- `thinking_default_expanded`(on/off,默认 off):启用 `show_thinking` 时,让思考块初始以展开状态渲染。Space 仍可切换所选块,并且它只决定你尚未动过的块:你自己展开或折叠过的块,即使之后更改这个设置,也会保持该状态。这在 Space 绑定可能被拦截的 SSH/tmux 环境中很有用。
|
||||
- `thinking_preview_lines`(整数,默认 `2`):**折叠**的已完成思考仍显示多少正文行。`0` 仅头部;`10` 是较旧的完整显示。实时流式预览不变。用 Space 展开块,或设置 `thinking_default_expanded` 打开每个块。
|
||||
- `help_expand_groups`(on/off,默认 off):让帮助/快捷键从每个组展开开始。默认折叠长尾(Grok 风格);输入过滤仍展开匹配。
|
||||
|
||||
@@ -89,8 +89,8 @@
|
||||
"sources": [
|
||||
{
|
||||
"path": "docs/CONFIGURATION.md",
|
||||
"sha256": "00f8d3b2b40dc4145a78b5bf93bd6d4e96c83478fdb5b4e0b7316108926ba1b7",
|
||||
"content": "# Configuration\n\ncodewhale reads configuration from a TOML file plus environment variables.\nAt process startup it may also load literal built-in-provider credentials from\na workspace-local `.env` file. Use the tracked `.env.example` as the template;\ncopy it to `.env`, then add only credential values.\n\nA workspace is not configuration authority. Codewhale therefore ignores\nconfig/profile/home paths, provider/model/base-URL routing, MCP/plugin state,\napproval/sandbox/shell posture, executable paths, runtime settings, and every\nother non-credential `.env` entry. Variable expansion is rejected so a\nrepository cannot substitute an ambient secret into a credential value. Use\n`config.toml`, CLI flags, or values exported by the launching shell for those\nexplicit control-plane settings. `.env` is read from a stable regular-file\nhandle, is capped at 1 MiB, and symbolic links, reparse points, and multiply\nlinked files are rejected.\n\n## Reading and checking configuration from the CLI\n\n`codewhale config get <key>` reads scalar keys, whole tables such as `tools`,\nand nested paths such as `tools.user_input_timeout_seconds`. Displayed tables\nand nested values apply the same recursive credential redaction as `config dump`.\n\n`config set` supports its named scalar keys and the provider, route, and\nnotification commands. It refuses a key that nothing reads and suggests the\nnearest real key (`config set calm_mod on` names `calm_mode`). A settings.toml\nkey such as `calm_mode` or `tool_collapse` is validated and written to the\nuser-global settings.toml, not config.toml; `config get` reads it back from\nthere even when an old config.toml copy (which nothing reads) is still present,\nand names that copy so you can `config unset` it. Settings keys have no project\nscope, so `--project` refuses them. A config.toml key is checked against the\ntype its reader expects and stored as a TOML boolean or number where the\nreader needs one (`yolo = true`, `max_subagents = 4`); `reasoning_effort`\naccepts the same aliases as `/effort` (#6563). Other dotted writes fail before\nmodifying the file and name the TOML table to edit. For example, set a tools\ntimeout in the file as:\n\n```toml\n[tools]\nuser_input_timeout_seconds = 0\n```\n\n`codewhale config doctor` checks credential presence and endpoint shape, and\nwarns about each config.toml root key that nothing reads: a settings.toml key\nleft in config.toml is named as misplaced, and any other unread key gets a\ndid-you-mean. Keys read by any runtime reader are not reported. The warnings do\nnot fail the check, and a clean result does not validate every runtime setting's\nvalue (#6083, #6563).\n\n## Usage and execution statistics\n\n`codewhale stats` is an alias of `codewhale metrics`; both use the same local,\nread-only backend. Use `--json` for the structured `stats` report. For example:\n\n```sh\ncodewhale stats --days 7 --models 10 --tools 20 --roles 10\ncodewhale metrics --project /path/to/workspace --json\n```\n\n`--days N` accepts a positive number of days and conflicts with `--since`.\n`--since 24h` and the existing duration syntax remain available. `--models N`,\n`--tools N` and `--roles N` select the top positive N rows by known tokens,\nidentified tool executions and recorded Fleet tasks respectively. These limits\nonly shorten the display; they do not remove stored receipts.\n\nThe report separates saved-document and Runtime views, frozen native-source\nmoney and token subtotals by actual model, retained tool-result history\nestimates and outcomes, completed compaction observations, and recorded Fleet\nroles. USD and CNY stay separate. A cumulative correction replaces its proven\npredecessor; copied Session/Runtime receipts and inherited Runtime fork rows\nare not added as new provider work. Conflicting source versions are shown\nseparately and excluded from the subtotal. Saved legacy display totals remain\nvisible as document projections without being charged again globally.\n\nCoverage is explicit. A missing quote, usage receipt, tool outcome, output\nshape or recovery link is unknown, not zero. Empty scalar usage from older\ntransport records cannot prove a reported zero. Token counts describe known\nreported data; tool history tokens are estimates of retained text, not bytes\nproven to have been replayed on a provider request. Latest prepared-request\ncomponents and source-linked compaction/role economics are unavailable where\nthey were not captured. The report does not infer provider invoices or prove\ncomplete historical coverage.\n\n`--project WORKSPACE` filters current saved/thread/Fleet views. Original source\nworkspace is not yet captured, so native money and model subtotals are\nunavailable for that filter; mutable current metadata is not used to assign a\ncharge. Older receipt projections without reliable workspace scope are also\nunavailable under this filter. Without it, those legacy receipt views remain\nseparate fromLine truncated
|
||||
"sha256": "fb934929d28d87dd161a26c427ffa21595ac358d994a46806c53f84fd49cb0f8",
|
||||
"content": "# Configuration\n\ncodewhale reads configuration from a TOML file plus environment variables.\nAt process startup it may also load literal built-in-provider credentials from\na workspace-local `.env` file. Use the tracked `.env.example` as the template;\ncopy it to `.env`, then add only credential values.\n\nA workspace is not configuration authority. Codewhale therefore ignores\nconfig/profile/home paths, provider/model/base-URL routing, MCP/plugin state,\napproval/sandbox/shell posture, executable paths, runtime settings, and every\nother non-credential `.env` entry. Variable expansion is rejected so a\nrepository cannot substitute an ambient secret into a credential value. Use\n`config.toml`, CLI flags, or values exported by the launching shell for those\nexplicit control-plane settings. `.env` is read from a stable regular-file\nhandle, is capped at 1 MiB, and symbolic links, reparse points, and multiply\nlinked files are rejected.\n\n## Reading and checking configuration from the CLI\n\n`codewhale config get <key>` reads scalar keys, whole tables such as `tools`,\nand nested paths such as `tools.user_input_timeout_seconds`. Displayed tables\nand nested values apply the same recursive credential redaction as `config dump`.\n\n`config set` supports its named scalar keys and the provider, route, and\nnotification commands. It refuses a key that nothing reads and suggests the\nnearest real key (`config set calm_mod on` names `calm_mode`). A settings.toml\nkey such as `calm_mode` or `tool_collapse` is validated and written to the\nuser-global settings.toml, not config.toml; `config get` reads it back from\nthere even when an old config.toml copy (which nothing reads) is still present,\nand names that copy so you can `config unset` it. Settings keys have no project\nscope, so `--project` refuses them. A config.toml key is checked against the\ntype its reader expects and stored as a TOML boolean or number where the\nreader needs one (`yolo = true`, `max_subagents = 4`); `reasoning_effort`\naccepts the same aliases as `/effort` (#6563). Other dotted writes fail before\nmodifying the file and name the TOML table to edit. For example, set a tools\ntimeout in the file as:\n\n```toml\n[tools]\nuser_input_timeout_seconds = 0\n```\n\n`codewhale config doctor` checks credential presence and endpoint shape, and\nwarns about each config.toml root key that nothing reads: a settings.toml key\nleft in config.toml is named as misplaced, and any other unread key gets a\ndid-you-mean. Keys read by any runtime reader are not reported. The warnings do\nnot fail the check, and a clean result does not validate every runtime setting's\nvalue (#6083, #6563).\n\n## Usage and execution statistics\n\n`codewhale stats` is an alias of `codewhale metrics`; both use the same local,\nread-only backend. Use `--json` for the structured `stats` report. For example:\n\n```sh\ncodewhale stats --days 7 --models 10 --tools 20 --roles 10\ncodewhale metrics --project /path/to/workspace --json\n```\n\n`--days N` accepts a positive number of days and conflicts with `--since`.\n`--since 24h` and the existing duration syntax remain available. `--models N`,\n`--tools N` and `--roles N` select the top positive N rows by known tokens,\nidentified tool executions and recorded Fleet tasks respectively. These limits\nonly shorten the display; they do not remove stored receipts.\n\nThe report separates saved-document and Runtime views, frozen native-source\nmoney and token subtotals by actual model, retained tool-result history\nestimates and outcomes, completed compaction observations, and recorded Fleet\nroles. USD and CNY stay separate. A cumulative correction replaces its proven\npredecessor; copied Session/Runtime receipts and inherited Runtime fork rows\nare not added as new provider work. Conflicting source versions are shown\nseparately and excluded from the subtotal. Saved legacy display totals remain\nvisible as document projections without being charged again globally.\n\nCoverage is explicit. A missing quote, usage receipt, tool outcome, output\nshape or recovery link is unknown, not zero. Empty scalar usage from older\ntransport records cannot prove a reported zero. Token counts describe known\nreported data; tool history tokens are estimates of retained text, not bytes\nproven to have been replayed on a provider request. Latest prepared-request\ncomponents and source-linked compaction/role economics are unavailable where\nthey were not captured. The report does not infer provider invoices or prove\ncomplete historical coverage.\n\n`--project WORKSPACE` filters current saved/thread/Fleet views. Original source\nworkspace is not yet captured, so native money and model subtotals are\nunavailable for that filter; mutable current metadata is not used to assign a\ncharge. Older receipt projections without reliable workspace scope are also\nunavailable under this filter. Without it, those legacy receipt views remain\nseparate fromLine truncated
|
||||
},
|
||||
{
|
||||
"path": "docs/PROVIDERS.md",
|
||||
@@ -121,8 +121,8 @@
|
||||
"sources": [
|
||||
{
|
||||
"path": "docs/CONFIGURATION.md",
|
||||
"sha256": "00f8d3b2b40dc4145a78b5bf93bd6d4e96c83478fdb5b4e0b7316108926ba1b7",
|
||||
"content": "# Configuration\n\ncodewhale reads configuration from a TOML file plus environment variables.\nAt process startup it may also load literal built-in-provider credentials from\na workspace-local `.env` file. Use the tracked `.env.example` as the template;\ncopy it to `.env`, then add only credential values.\n\nA workspace is not configuration authority. Codewhale therefore ignores\nconfig/profile/home paths, provider/model/base-URL routing, MCP/plugin state,\napproval/sandbox/shell posture, executable paths, runtime settings, and every\nother non-credential `.env` entry. Variable expansion is rejected so a\nrepository cannot substitute an ambient secret into a credential value. Use\n`config.toml`, CLI flags, or values exported by the launching shell for those\nexplicit control-plane settings. `.env` is read from a stable regular-file\nhandle, is capped at 1 MiB, and symbolic links, reparse points, and multiply\nlinked files are rejected.\n\n## Reading and checking configuration from the CLI\n\n`codewhale config get <key>` reads scalar keys, whole tables such as `tools`,\nand nested paths such as `tools.user_input_timeout_seconds`. Displayed tables\nand nested values apply the same recursive credential redaction as `config dump`.\n\n`config set` supports its named scalar keys and the provider, route, and\nnotification commands. It refuses a key that nothing reads and suggests the\nnearest real key (`config set calm_mod on` names `calm_mode`). A settings.toml\nkey such as `calm_mode` or `tool_collapse` is validated and written to the\nuser-global settings.toml, not config.toml; `config get` reads it back from\nthere even when an old config.toml copy (which nothing reads) is still present,\nand names that copy so you can `config unset` it. Settings keys have no project\nscope, so `--project` refuses them. A config.toml key is checked against the\ntype its reader expects and stored as a TOML boolean or number where the\nreader needs one (`yolo = true`, `max_subagents = 4`); `reasoning_effort`\naccepts the same aliases as `/effort` (#6563). Other dotted writes fail before\nmodifying the file and name the TOML table to edit. For example, set a tools\ntimeout in the file as:\n\n```toml\n[tools]\nuser_input_timeout_seconds = 0\n```\n\n`codewhale config doctor` checks credential presence and endpoint shape, and\nwarns about each config.toml root key that nothing reads: a settings.toml key\nleft in config.toml is named as misplaced, and any other unread key gets a\ndid-you-mean. Keys read by any runtime reader are not reported. The warnings do\nnot fail the check, and a clean result does not validate every runtime setting's\nvalue (#6083, #6563).\n\n## Usage and execution statistics\n\n`codewhale stats` is an alias of `codewhale metrics`; both use the same local,\nread-only backend. Use `--json` for the structured `stats` report. For example:\n\n```sh\ncodewhale stats --days 7 --models 10 --tools 20 --roles 10\ncodewhale metrics --project /path/to/workspace --json\n```\n\n`--days N` accepts a positive number of days and conflicts with `--since`.\n`--since 24h` and the existing duration syntax remain available. `--models N`,\n`--tools N` and `--roles N` select the top positive N rows by known tokens,\nidentified tool executions and recorded Fleet tasks respectively. These limits\nonly shorten the display; they do not remove stored receipts.\n\nThe report separates saved-document and Runtime views, frozen native-source\nmoney and token subtotals by actual model, retained tool-result history\nestimates and outcomes, completed compaction observations, and recorded Fleet\nroles. USD and CNY stay separate. A cumulative correction replaces its proven\npredecessor; copied Session/Runtime receipts and inherited Runtime fork rows\nare not added as new provider work. Conflicting source versions are shown\nseparately and excluded from the subtotal. Saved legacy display totals remain\nvisible as document projections without being charged again globally.\n\nCoverage is explicit. A missing quote, usage receipt, tool outcome, output\nshape or recovery link is unknown, not zero. Empty scalar usage from older\ntransport records cannot prove a reported zero. Token counts describe known\nreported data; tool history tokens are estimates of retained text, not bytes\nproven to have been replayed on a provider request. Latest prepared-request\ncomponents and source-linked compaction/role economics are unavailable where\nthey were not captured. The report does not infer provider invoices or prove\ncomplete historical coverage.\n\n`--project WORKSPACE` filters current saved/thread/Fleet views. Original source\nworkspace is not yet captured, so native money and model subtotals are\nunavailable for that filter; mutable current metadata is not used to assign a\ncharge. Older receipt projections without reliable workspace scope are also\nunavailable under this filter. Without it, those legacy receipt views remain\nseparate fromLine truncated
|
||||
"sha256": "fb934929d28d87dd161a26c427ffa21595ac358d994a46806c53f84fd49cb0f8",
|
||||
"content": "# Configuration\n\ncodewhale reads configuration from a TOML file plus environment variables.\nAt process startup it may also load literal built-in-provider credentials from\na workspace-local `.env` file. Use the tracked `.env.example` as the template;\ncopy it to `.env`, then add only credential values.\n\nA workspace is not configuration authority. Codewhale therefore ignores\nconfig/profile/home paths, provider/model/base-URL routing, MCP/plugin state,\napproval/sandbox/shell posture, executable paths, runtime settings, and every\nother non-credential `.env` entry. Variable expansion is rejected so a\nrepository cannot substitute an ambient secret into a credential value. Use\n`config.toml`, CLI flags, or values exported by the launching shell for those\nexplicit control-plane settings. `.env` is read from a stable regular-file\nhandle, is capped at 1 MiB, and symbolic links, reparse points, and multiply\nlinked files are rejected.\n\n## Reading and checking configuration from the CLI\n\n`codewhale config get <key>` reads scalar keys, whole tables such as `tools`,\nand nested paths such as `tools.user_input_timeout_seconds`. Displayed tables\nand nested values apply the same recursive credential redaction as `config dump`.\n\n`config set` supports its named scalar keys and the provider, route, and\nnotification commands. It refuses a key that nothing reads and suggests the\nnearest real key (`config set calm_mod on` names `calm_mode`). A settings.toml\nkey such as `calm_mode` or `tool_collapse` is validated and written to the\nuser-global settings.toml, not config.toml; `config get` reads it back from\nthere even when an old config.toml copy (which nothing reads) is still present,\nand names that copy so you can `config unset` it. Settings keys have no project\nscope, so `--project` refuses them. A config.toml key is checked against the\ntype its reader expects and stored as a TOML boolean or number where the\nreader needs one (`yolo = true`, `max_subagents = 4`); `reasoning_effort`\naccepts the same aliases as `/effort` (#6563). Other dotted writes fail before\nmodifying the file and name the TOML table to edit. For example, set a tools\ntimeout in the file as:\n\n```toml\n[tools]\nuser_input_timeout_seconds = 0\n```\n\n`codewhale config doctor` checks credential presence and endpoint shape, and\nwarns about each config.toml root key that nothing reads: a settings.toml key\nleft in config.toml is named as misplaced, and any other unread key gets a\ndid-you-mean. Keys read by any runtime reader are not reported. The warnings do\nnot fail the check, and a clean result does not validate every runtime setting's\nvalue (#6083, #6563).\n\n## Usage and execution statistics\n\n`codewhale stats` is an alias of `codewhale metrics`; both use the same local,\nread-only backend. Use `--json` for the structured `stats` report. For example:\n\n```sh\ncodewhale stats --days 7 --models 10 --tools 20 --roles 10\ncodewhale metrics --project /path/to/workspace --json\n```\n\n`--days N` accepts a positive number of days and conflicts with `--since`.\n`--since 24h` and the existing duration syntax remain available. `--models N`,\n`--tools N` and `--roles N` select the top positive N rows by known tokens,\nidentified tool executions and recorded Fleet tasks respectively. These limits\nonly shorten the display; they do not remove stored receipts.\n\nThe report separates saved-document and Runtime views, frozen native-source\nmoney and token subtotals by actual model, retained tool-result history\nestimates and outcomes, completed compaction observations, and recorded Fleet\nroles. USD and CNY stay separate. A cumulative correction replaces its proven\npredecessor; copied Session/Runtime receipts and inherited Runtime fork rows\nare not added as new provider work. Conflicting source versions are shown\nseparately and excluded from the subtotal. Saved legacy display totals remain\nvisible as document projections without being charged again globally.\n\nCoverage is explicit. A missing quote, usage receipt, tool outcome, output\nshape or recovery link is unknown, not zero. Empty scalar usage from older\ntransport records cannot prove a reported zero. Token counts describe known\nreported data; tool history tokens are estimates of retained text, not bytes\nproven to have been replayed on a provider request. Latest prepared-request\ncomponents and source-linked compaction/role economics are unavailable where\nthey were not captured. The report does not infer provider invoices or prove\ncomplete historical coverage.\n\n`--project WORKSPACE` filters current saved/thread/Fleet views. Original source\nworkspace is not yet captured, so native money and model subtotals are\nunavailable for that filter; mutable current metadata is not used to assign a\ncharge. Older receipt projections without reliable workspace scope are also\nunavailable under this filter. Without it, those legacy receipt views remain\nseparate fromLine truncated
|
||||
},
|
||||
{
|
||||
"path": "docs/LEGACY_PATHS.md",
|
||||
@@ -153,8 +153,8 @@
|
||||
},
|
||||
{
|
||||
"path": "docs/CONFIGURATION.md",
|
||||
"sha256": "00f8d3b2b40dc4145a78b5bf93bd6d4e96c83478fdb5b4e0b7316108926ba1b7",
|
||||
"content": "# Configuration\n\ncodewhale reads configuration from a TOML file plus environment variables.\nAt process startup it may also load literal built-in-provider credentials from\na workspace-local `.env` file. Use the tracked `.env.example` as the template;\ncopy it to `.env`, then add only credential values.\n\nA workspace is not configuration authority. Codewhale therefore ignores\nconfig/profile/home paths, provider/model/base-URL routing, MCP/plugin state,\napproval/sandbox/shell posture, executable paths, runtime settings, and every\nother non-credential `.env` entry. Variable expansion is rejected so a\nrepository cannot substitute an ambient secret into a credential value. Use\n`config.toml`, CLI flags, or values exported by the launching shell for those\nexplicit control-plane settings. `.env` is read from a stable regular-file\nhandle, is capped at 1 MiB, and symbolic links, reparse points, and multiply\nlinked files are rejected.\n\n## Reading and checking configuration from the CLI\n\n`codewhale config get <key>` reads scalar keys, whole tables such as `tools`,\nand nested paths such as `tools.user_input_timeout_seconds`. Displayed tables\nand nested values apply the same recursive credential redaction as `config dump`.\n\n`config set` supports its named scalar keys and the provider, route, and\nnotification commands. It refuses a key that nothing reads and suggests the\nnearest real key (`config set calm_mod on` names `calm_mode`). A settings.toml\nkey such as `calm_mode` or `tool_collapse` is validated and written to the\nuser-global settings.toml, not config.toml; `config get` reads it back from\nthere even when an old config.toml copy (which nothing reads) is still present,\nand names that copy so you can `config unset` it. Settings keys have no project\nscope, so `--project` refuses them. A config.toml key is checked against the\ntype its reader expects and stored as a TOML boolean or number where the\nreader needs one (`yolo = true`, `max_subagents = 4`); `reasoning_effort`\naccepts the same aliases as `/effort` (#6563). Other dotted writes fail before\nmodifying the file and name the TOML table to edit. For example, set a tools\ntimeout in the file as:\n\n```toml\n[tools]\nuser_input_timeout_seconds = 0\n```\n\n`codewhale config doctor` checks credential presence and endpoint shape, and\nwarns about each config.toml root key that nothing reads: a settings.toml key\nleft in config.toml is named as misplaced, and any other unread key gets a\ndid-you-mean. Keys read by any runtime reader are not reported. The warnings do\nnot fail the check, and a clean result does not validate every runtime setting's\nvalue (#6083, #6563).\n\n## Usage and execution statistics\n\n`codewhale stats` is an alias of `codewhale metrics`; both use the same local,\nread-only backend. Use `--json` for the structured `stats` report. For example:\n\n```sh\ncodewhale stats --days 7 --models 10 --tools 20 --roles 10\ncodewhale metrics --project /path/to/workspace --json\n```\n\n`--days N` accepts a positive number of days and conflicts with `--since`.\n`--since 24h` and the existing duration syntax remain available. `--models N`,\n`--tools N` and `--roles N` select the top positive N rows by known tokens,\nidentified tool executions and recorded Fleet tasks respectively. These limits\nonly shorten the display; they do not remove stored receipts.\n\nThe report separates saved-document and Runtime views, frozen native-source\nmoney and token subtotals by actual model, retained tool-result history\nestimates and outcomes, completed compaction observations, and recorded Fleet\nroles. USD and CNY stay separate. A cumulative correction replaces its proven\npredecessor; copied Session/Runtime receipts and inherited Runtime fork rows\nare not added as new provider work. Conflicting source versions are shown\nseparately and excluded from the subtotal. Saved legacy display totals remain\nvisible as document projections without being charged again globally.\n\nCoverage is explicit. A missing quote, usage receipt, tool outcome, output\nshape or recovery link is unknown, not zero. Empty scalar usage from older\ntransport records cannot prove a reported zero. Token counts describe known\nreported data; tool history tokens are estimates of retained text, not bytes\nproven to have been replayed on a provider request. Latest prepared-request\ncomponents and source-linked compaction/role economics are unavailable where\nthey were not captured. The report does not infer provider invoices or prove\ncomplete historical coverage.\n\n`--project WORKSPACE` filters current saved/thread/Fleet views. Original source\nworkspace is not yet captured, so native money and model subtotals are\nunavailable for that filter; mutable current metadata is not used to assign a\ncharge. Older receipt projections without reliable workspace scope are also\nunavailable under this filter. Without it, those legacy receipt views remain\nseparate fromLine truncated
|
||||
"sha256": "fb934929d28d87dd161a26c427ffa21595ac358d994a46806c53f84fd49cb0f8",
|
||||
"content": "# Configuration\n\ncodewhale reads configuration from a TOML file plus environment variables.\nAt process startup it may also load literal built-in-provider credentials from\na workspace-local `.env` file. Use the tracked `.env.example` as the template;\ncopy it to `.env`, then add only credential values.\n\nA workspace is not configuration authority. Codewhale therefore ignores\nconfig/profile/home paths, provider/model/base-URL routing, MCP/plugin state,\napproval/sandbox/shell posture, executable paths, runtime settings, and every\nother non-credential `.env` entry. Variable expansion is rejected so a\nrepository cannot substitute an ambient secret into a credential value. Use\n`config.toml`, CLI flags, or values exported by the launching shell for those\nexplicit control-plane settings. `.env` is read from a stable regular-file\nhandle, is capped at 1 MiB, and symbolic links, reparse points, and multiply\nlinked files are rejected.\n\n## Reading and checking configuration from the CLI\n\n`codewhale config get <key>` reads scalar keys, whole tables such as `tools`,\nand nested paths such as `tools.user_input_timeout_seconds`. Displayed tables\nand nested values apply the same recursive credential redaction as `config dump`.\n\n`config set` supports its named scalar keys and the provider, route, and\nnotification commands. It refuses a key that nothing reads and suggests the\nnearest real key (`config set calm_mod on` names `calm_mode`). A settings.toml\nkey such as `calm_mode` or `tool_collapse` is validated and written to the\nuser-global settings.toml, not config.toml; `config get` reads it back from\nthere even when an old config.toml copy (which nothing reads) is still present,\nand names that copy so you can `config unset` it. Settings keys have no project\nscope, so `--project` refuses them. A config.toml key is checked against the\ntype its reader expects and stored as a TOML boolean or number where the\nreader needs one (`yolo = true`, `max_subagents = 4`); `reasoning_effort`\naccepts the same aliases as `/effort` (#6563). Other dotted writes fail before\nmodifying the file and name the TOML table to edit. For example, set a tools\ntimeout in the file as:\n\n```toml\n[tools]\nuser_input_timeout_seconds = 0\n```\n\n`codewhale config doctor` checks credential presence and endpoint shape, and\nwarns about each config.toml root key that nothing reads: a settings.toml key\nleft in config.toml is named as misplaced, and any other unread key gets a\ndid-you-mean. Keys read by any runtime reader are not reported. The warnings do\nnot fail the check, and a clean result does not validate every runtime setting's\nvalue (#6083, #6563).\n\n## Usage and execution statistics\n\n`codewhale stats` is an alias of `codewhale metrics`; both use the same local,\nread-only backend. Use `--json` for the structured `stats` report. For example:\n\n```sh\ncodewhale stats --days 7 --models 10 --tools 20 --roles 10\ncodewhale metrics --project /path/to/workspace --json\n```\n\n`--days N` accepts a positive number of days and conflicts with `--since`.\n`--since 24h` and the existing duration syntax remain available. `--models N`,\n`--tools N` and `--roles N` select the top positive N rows by known tokens,\nidentified tool executions and recorded Fleet tasks respectively. These limits\nonly shorten the display; they do not remove stored receipts.\n\nThe report separates saved-document and Runtime views, frozen native-source\nmoney and token subtotals by actual model, retained tool-result history\nestimates and outcomes, completed compaction observations, and recorded Fleet\nroles. USD and CNY stay separate. A cumulative correction replaces its proven\npredecessor; copied Session/Runtime receipts and inherited Runtime fork rows\nare not added as new provider work. Conflicting source versions are shown\nseparately and excluded from the subtotal. Saved legacy display totals remain\nvisible as document projections without being charged again globally.\n\nCoverage is explicit. A missing quote, usage receipt, tool outcome, output\nshape or recovery link is unknown, not zero. Empty scalar usage from older\ntransport records cannot prove a reported zero. Token counts describe known\nreported data; tool history tokens are estimates of retained text, not bytes\nproven to have been replayed on a provider request. Latest prepared-request\ncomponents and source-linked compaction/role economics are unavailable where\nthey were not captured. The report does not infer provider invoices or prove\ncomplete historical coverage.\n\n`--project WORKSPACE` filters current saved/thread/Fleet views. Original source\nworkspace is not yet captured, so native money and model subtotals are\nunavailable for that filter; mutable current metadata is not used to assign a\ncharge. Older receipt projections without reliable workspace scope are also\nunavailable under this filter. Without it, those legacy receipt views remain\nseparate fromLine truncated
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -259,8 +259,8 @@
|
||||
},
|
||||
{
|
||||
"path": "docs/TELEMETRY.md",
|
||||
"sha256": "0ac65dbd0e1d9d196d1c4421116852c3ab6a855062c375c25b09db993084c563",
|
||||
"content": "# Codewhale product telemetry\n\n> 阅读简体中文版:[zh_hans/TELEMETRY.md](zh_hans/TELEMETRY.md)\n\n**Usage analytics are on by default in the current source.** The first\ninteractive launch gives a localized, nonblocking disclosure naming **Codewhale\nand PostHog**, with a direct route to turn counting off. Notice version `6`\ndescribes this policy; displaying it is not recorded as human acceptance.\nMissing an older acceptance record does not turn a fresh installation off.\nExisting explicit opt-outs remain off across upgrades, and unreadable privacy\nstate fails closed. Environment and CLI kill switches still take precedence.\n\nRead the disclosure with `codewhale config telemetry`. Turn usage off in\n`/settings` or run `codewhale config set telemetry false`. Turning it back on\nin Settings or with `codewhale config set telemetry true` updates the existing\ndurable preference and privacy records for new sessions. The compatibility\ncommand `codewhale config telemetry --accept-notice 5` remains available, but\nis not required for a fresh installation. Collection never includes work content or credentials.\n\nThe PostHog sink is optional and inert until the ingest operator explicitly\nconfigures a project token and approved regional host, and records staging\nproof that the actual egress path does not forward the original client IP.\nSource readiness does not establish a deployment, an activated processor,\nan egress receipt, or a retention setting.\n\nCodewhale does not collect conversations, code, prompts, files, file/repo/branch\nnames, model content, or credentials. It sends no per-turn or per-tool timeline.\nIt does send the closed, aggregate schema below: version and platform classes,\nsession duration/outcome, feature/error counters, and a random install id that\nrotates every 90 days.\n\n**There is now a real endpoint.** An enabled session sends its batches to the\nfirst-party ingest service at `https://telemetry.codewhale.net/v1/telemetry`,\nwhich is the shipped default for `telemetry_endpoint`. What that service is,\nwhat it stores, and what it structurally cannot store is spelled out in \"What\nthe endpoint does\" below.\n\n**To send nothing anywhere, turn telemetry off** (see \"Turning it off\"). To stay\nenabled but contact nobody, set `telemetry_endpoint = \"\"`: batches are then\nappended to `$CODEWHALE_HOME/telemetry/dryrun.jsonl` on your own machine, byte\nfor byte what the server would have received, and no HTTP client is ever\nconstructed. That file is how you audit this document against reality.\n\nThis document is the schema. It is not a summary of the schema: a test in\n`crates/telemetry` parses the field names out of this file and asserts set\nequality against the structs the serializer actually uses, so a field that is\nhere and not in the code — or in the code and not here — fails the build.\n\nLocal saved sessions and `codewhale metrics` remain available with telemetry\ndisabled and require no account or hosted reporting service. Disabling telemetry\ndoes not disable local usage receipts.\n\n## Turning it off\n\nThere are two off switches and they do different things. Both stop collection\ncompletely; only one of them erases anything.\n\n```sh\ncodewhale config set telemetry false # opt out: stops collection and erases state\nCODEWHALE_TELEMETRY=0 codewhale # kill switch: stops collection, erases nothing\nDO_NOT_TRACK=1 codewhale # the same kill switch, by the common convention\ncodewhale --telemetry false # the same kill switch, for one command\n```\n\n**`telemetry = false` in the config file is the opt-out.** It is a floor:\n`--telemetry true` and `CODEWHALE_TELEMETRY=1` both lose to it, because a\nsetting you can undo by accident from a wrapper script is not a setting. It\ndeletes the random install id, truncates every buffered event and every dry-run\nrecord, and writes a tombstone. Appends, identity/state writes, and delivery all\nshare the wipe's ordering lock, so once opt-out returns no pre-opt-out write or\nPOST remains in flight. If any part of that wipe fails, the tombstone is still\nthere and the buffer is undrainable — a failed wipe fails closed. Every later\nrun re-asserts the same tombstone for as long as the setting stands, so it\nsurvives. Turning telemetry back on in `/settings` records that explicit preference\nin both existing privacy registers; a fresh launch can then clear the tombstone. Nothing buffered before that point is\never sent.\n\n**The environment variable and the flag are kill switches, not opt-outs.**\nTelemetry is off for the run, nothing is written, nothing is sent — and\nnothing on disk is touched or deleted. That is deliberate: a harness or agent\nthat sets `CODEWHALE_TELEMETRY=0` for one command must not silently discard the\ninstall id and the dry-run records of the person who owns the machine. If you\nwant the erasing kind, use the config file.\n\n`CODEWHALE_TELEMELine truncated
|
||||
"sha256": "6ecccb66215a6aae5076cd00735028c10a50ce6e5902ce4151a9606ee86696a9",
|
||||
"content": "# Codewhale product telemetry\n\n> 阅读简体中文版:[zh_hans/TELEMETRY.md](zh_hans/TELEMETRY.md)\n\n**Usage analytics are on by default in the current source.** The first\ninteractive launch gives a localized, nonblocking disclosure naming **Codewhale\nand PostHog**, with a direct route to turn counting off. Notice version `6`\ndescribes this policy; displaying it is not recorded as human acceptance.\nMissing an older acceptance record does not turn a fresh installation off.\nExisting explicit opt-outs remain off across upgrades, and unreadable privacy\nstate fails closed. Environment and CLI kill switches still take precedence.\n\nRead the disclosure with `codewhale config telemetry`. Turn usage off in\n`/settings` or run `codewhale config set telemetry false`. Turning it back on\nin Settings or with `codewhale config set telemetry true` updates the existing\ndurable preference and privacy records for new sessions. The compatibility\ncommand `codewhale config telemetry --accept-notice 5` remains available, but\nis not required for a fresh installation. Collection never includes work content or credentials.\n\nThe PostHog sink is optional and inert until the ingest operator explicitly\nconfigures a project token and approved regional host, and records staging\nproof that the actual egress path does not forward the original client IP.\nSource readiness does not establish a deployment, an activated processor,\nan egress receipt, or a retention setting.\n\nCodewhale does not collect conversations, code, prompts, files, file/repo/branch\nnames, model content, or credentials. It sends no per-turn or per-tool timeline.\nIt does send the closed, aggregate schema below: version and platform classes,\nsession duration/outcome, feature/error counters, and a random install id that\nrotates every 90 days.\n\n**There is now a real endpoint.** An enabled session sends its batches to the\nfirst-party ingest service at `https://telemetry.codewhale.net/v1/telemetry`,\nwhich is the shipped default for `telemetry_endpoint`. What that service is,\nwhat it stores, and what it structurally cannot store is spelled out in \"What\nthe endpoint does\" below.\n\n**To send nothing anywhere, turn telemetry off** (see \"Turning it off\"). To stay\nenabled but contact nobody, set `telemetry_endpoint = \"\"`: batches are then\nappended to `$CODEWHALE_HOME/telemetry/dryrun.jsonl` on your own machine, byte\nfor byte what the server would have received, and no HTTP client is ever\nconstructed. That file is how you audit this document against reality.\n\nThis document is the schema. It is not a summary of the schema: a test in\n`crates/telemetry` parses the field names out of this file and asserts set\nequality against the structs the serializer actually uses, so a field that is\nhere and not in the code — or in the code and not here — fails the build.\n\nLocal saved sessions and `codewhale metrics` remain available with telemetry\ndisabled and require no account or hosted reporting service. Disabling telemetry\ndoes not disable local usage receipts.\n\n## Turning it off\n\nThere are two off switches and they do different things. Both stop collection\ncompletely; only one of them erases anything.\n\n```sh\ncodewhale config set telemetry false # opt out: stops collection and erases state\nCODEWHALE_TELEMETRY=0 codewhale # kill switch: stops collection, erases nothing\nDO_NOT_TRACK=1 codewhale # the same kill switch, by the common convention\ncodewhale --telemetry false # the same kill switch, for one command\n```\n\n**`telemetry = false` in the config file is the opt-out.** It is a floor:\n`--telemetry true` and `CODEWHALE_TELEMETRY=1` both lose to it, because a\nsetting you can undo by accident from a wrapper script is not a setting. It\ndeletes the random install id, deletes every spool, truncates every dry-run\nrecord, and writes a tombstone. Appends, identity/state writes, and delivery all\nshare the wipe's ordering lock, so once opt-out returns no pre-opt-out write or\nPOST remains in flight. If any part of that wipe fails, the tombstone is still\nthere and no spool can be sent — a failed wipe fails closed. Every later\nrun re-asserts the same tombstone for as long as the setting stands, so it\nsurvives. Turning telemetry back on in `/settings` records that explicit preference\nin both existing privacy registers; a fresh launch can then clear the tombstone. Nothing buffered before that point is\never sent.\n\n**The environment variable and the flag are kill switches, not opt-outs.**\nTelemetry is off for the run, nothing is written, nothing is sent — and\nnothing on disk is touched or deleted. That is deliberate: a harness or agent\nthat sets `CODEWHALE_TELEMETRY=0` for one command must not silently discard the\ninstall id and the dry-run records of the person who owns the machine. If you\nwant the erasing kind, use the config file.\n\n`CODEWHALE_TELEMETRY` (andLine truncated
|
||||
},
|
||||
{
|
||||
"path": "docs/public-surface-facts.json",
|
||||
@@ -296,8 +296,8 @@
|
||||
},
|
||||
{
|
||||
"path": "docs/CONFIGURATION.md",
|
||||
"sha256": "00f8d3b2b40dc4145a78b5bf93bd6d4e96c83478fdb5b4e0b7316108926ba1b7",
|
||||
"content": "# Configuration\n\ncodewhale reads configuration from a TOML file plus environment variables.\nAt process startup it may also load literal built-in-provider credentials from\na workspace-local `.env` file. Use the tracked `.env.example` as the template;\ncopy it to `.env`, then add only credential values.\n\nA workspace is not configuration authority. Codewhale therefore ignores\nconfig/profile/home paths, provider/model/base-URL routing, MCP/plugin state,\napproval/sandbox/shell posture, executable paths, runtime settings, and every\nother non-credential `.env` entry. Variable expansion is rejected so a\nrepository cannot substitute an ambient secret into a credential value. Use\n`config.toml`, CLI flags, or values exported by the launching shell for those\nexplicit control-plane settings. `.env` is read from a stable regular-file\nhandle, is capped at 1 MiB, and symbolic links, reparse points, and multiply\nlinked files are rejected.\n\n## Reading and checking configuration from the CLI\n\n`codewhale config get <key>` reads scalar keys, whole tables such as `tools`,\nand nested paths such as `tools.user_input_timeout_seconds`. Displayed tables\nand nested values apply the same recursive credential redaction as `config dump`.\n\n`config set` supports its named scalar keys and the provider, route, and\nnotification commands. It refuses a key that nothing reads and suggests the\nnearest real key (`config set calm_mod on` names `calm_mode`). A settings.toml\nkey such as `calm_mode` or `tool_collapse` is validated and written to the\nuser-global settings.toml, not config.toml; `config get` reads it back from\nthere even when an old config.toml copy (which nothing reads) is still present,\nand names that copy so you can `config unset` it. Settings keys have no project\nscope, so `--project` refuses them. A config.toml key is checked against the\ntype its reader expects and stored as a TOML boolean or number where the\nreader needs one (`yolo = true`, `max_subagents = 4`); `reasoning_effort`\naccepts the same aliases as `/effort` (#6563). Other dotted writes fail before\nmodifying the file and name the TOML table to edit. For example, set a tools\ntimeout in the file as:\n\n```toml\n[tools]\nuser_input_timeout_seconds = 0\n```\n\n`codewhale config doctor` checks credential presence and endpoint shape, and\nwarns about each config.toml root key that nothing reads: a settings.toml key\nleft in config.toml is named as misplaced, and any other unread key gets a\ndid-you-mean. Keys read by any runtime reader are not reported. The warnings do\nnot fail the check, and a clean result does not validate every runtime setting's\nvalue (#6083, #6563).\n\n## Usage and execution statistics\n\n`codewhale stats` is an alias of `codewhale metrics`; both use the same local,\nread-only backend. Use `--json` for the structured `stats` report. For example:\n\n```sh\ncodewhale stats --days 7 --models 10 --tools 20 --roles 10\ncodewhale metrics --project /path/to/workspace --json\n```\n\n`--days N` accepts a positive number of days and conflicts with `--since`.\n`--since 24h` and the existing duration syntax remain available. `--models N`,\n`--tools N` and `--roles N` select the top positive N rows by known tokens,\nidentified tool executions and recorded Fleet tasks respectively. These limits\nonly shorten the display; they do not remove stored receipts.\n\nThe report separates saved-document and Runtime views, frozen native-source\nmoney and token subtotals by actual model, retained tool-result history\nestimates and outcomes, completed compaction observations, and recorded Fleet\nroles. USD and CNY stay separate. A cumulative correction replaces its proven\npredecessor; copied Session/Runtime receipts and inherited Runtime fork rows\nare not added as new provider work. Conflicting source versions are shown\nseparately and excluded from the subtotal. Saved legacy display totals remain\nvisible as document projections without being charged again globally.\n\nCoverage is explicit. A missing quote, usage receipt, tool outcome, output\nshape or recovery link is unknown, not zero. Empty scalar usage from older\ntransport records cannot prove a reported zero. Token counts describe known\nreported data; tool history tokens are estimates of retained text, not bytes\nproven to have been replayed on a provider request. Latest prepared-request\ncomponents and source-linked compaction/role economics are unavailable where\nthey were not captured. The report does not infer provider invoices or prove\ncomplete historical coverage.\n\n`--project WORKSPACE` filters current saved/thread/Fleet views. Original source\nworkspace is not yet captured, so native money and model subtotals are\nunavailable for that filter; mutable current metadata is not used to assign a\ncharge. Older receipt projections without reliable workspace scope are also\nunavailable under this filter. Without it, those legacy receipt views remain\nseparate fromLine truncated
|
||||
"sha256": "fb934929d28d87dd161a26c427ffa21595ac358d994a46806c53f84fd49cb0f8",
|
||||
"content": "# Configuration\n\ncodewhale reads configuration from a TOML file plus environment variables.\nAt process startup it may also load literal built-in-provider credentials from\na workspace-local `.env` file. Use the tracked `.env.example` as the template;\ncopy it to `.env`, then add only credential values.\n\nA workspace is not configuration authority. Codewhale therefore ignores\nconfig/profile/home paths, provider/model/base-URL routing, MCP/plugin state,\napproval/sandbox/shell posture, executable paths, runtime settings, and every\nother non-credential `.env` entry. Variable expansion is rejected so a\nrepository cannot substitute an ambient secret into a credential value. Use\n`config.toml`, CLI flags, or values exported by the launching shell for those\nexplicit control-plane settings. `.env` is read from a stable regular-file\nhandle, is capped at 1 MiB, and symbolic links, reparse points, and multiply\nlinked files are rejected.\n\n## Reading and checking configuration from the CLI\n\n`codewhale config get <key>` reads scalar keys, whole tables such as `tools`,\nand nested paths such as `tools.user_input_timeout_seconds`. Displayed tables\nand nested values apply the same recursive credential redaction as `config dump`.\n\n`config set` supports its named scalar keys and the provider, route, and\nnotification commands. It refuses a key that nothing reads and suggests the\nnearest real key (`config set calm_mod on` names `calm_mode`). A settings.toml\nkey such as `calm_mode` or `tool_collapse` is validated and written to the\nuser-global settings.toml, not config.toml; `config get` reads it back from\nthere even when an old config.toml copy (which nothing reads) is still present,\nand names that copy so you can `config unset` it. Settings keys have no project\nscope, so `--project` refuses them. A config.toml key is checked against the\ntype its reader expects and stored as a TOML boolean or number where the\nreader needs one (`yolo = true`, `max_subagents = 4`); `reasoning_effort`\naccepts the same aliases as `/effort` (#6563). Other dotted writes fail before\nmodifying the file and name the TOML table to edit. For example, set a tools\ntimeout in the file as:\n\n```toml\n[tools]\nuser_input_timeout_seconds = 0\n```\n\n`codewhale config doctor` checks credential presence and endpoint shape, and\nwarns about each config.toml root key that nothing reads: a settings.toml key\nleft in config.toml is named as misplaced, and any other unread key gets a\ndid-you-mean. Keys read by any runtime reader are not reported. The warnings do\nnot fail the check, and a clean result does not validate every runtime setting's\nvalue (#6083, #6563).\n\n## Usage and execution statistics\n\n`codewhale stats` is an alias of `codewhale metrics`; both use the same local,\nread-only backend. Use `--json` for the structured `stats` report. For example:\n\n```sh\ncodewhale stats --days 7 --models 10 --tools 20 --roles 10\ncodewhale metrics --project /path/to/workspace --json\n```\n\n`--days N` accepts a positive number of days and conflicts with `--since`.\n`--since 24h` and the existing duration syntax remain available. `--models N`,\n`--tools N` and `--roles N` select the top positive N rows by known tokens,\nidentified tool executions and recorded Fleet tasks respectively. These limits\nonly shorten the display; they do not remove stored receipts.\n\nThe report separates saved-document and Runtime views, frozen native-source\nmoney and token subtotals by actual model, retained tool-result history\nestimates and outcomes, completed compaction observations, and recorded Fleet\nroles. USD and CNY stay separate. A cumulative correction replaces its proven\npredecessor; copied Session/Runtime receipts and inherited Runtime fork rows\nare not added as new provider work. Conflicting source versions are shown\nseparately and excluded from the subtotal. Saved legacy display totals remain\nvisible as document projections without being charged again globally.\n\nCoverage is explicit. A missing quote, usage receipt, tool outcome, output\nshape or recovery link is unknown, not zero. Empty scalar usage from older\ntransport records cannot prove a reported zero. Token counts describe known\nreported data; tool history tokens are estimates of retained text, not bytes\nproven to have been replayed on a provider request. Latest prepared-request\ncomponents and source-linked compaction/role economics are unavailable where\nthey were not captured. The report does not infer provider invoices or prove\ncomplete historical coverage.\n\n`--project WORKSPACE` filters current saved/thread/Fleet views. Original source\nworkspace is not yet captured, so native money and model subtotals are\nunavailable for that filter; mutable current metadata is not used to assign a\ncharge. Older receipt projections without reliable workspace scope are also\nunavailable under this filter. Without it, those legacy receipt views remain\nseparate fromLine truncated
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -328,8 +328,8 @@
|
||||
},
|
||||
{
|
||||
"path": "docs/CONFIGURATION.md",
|
||||
"sha256": "00f8d3b2b40dc4145a78b5bf93bd6d4e96c83478fdb5b4e0b7316108926ba1b7",
|
||||
"content": "# Configuration\n\ncodewhale reads configuration from a TOML file plus environment variables.\nAt process startup it may also load literal built-in-provider credentials from\na workspace-local `.env` file. Use the tracked `.env.example` as the template;\ncopy it to `.env`, then add only credential values.\n\nA workspace is not configuration authority. Codewhale therefore ignores\nconfig/profile/home paths, provider/model/base-URL routing, MCP/plugin state,\napproval/sandbox/shell posture, executable paths, runtime settings, and every\nother non-credential `.env` entry. Variable expansion is rejected so a\nrepository cannot substitute an ambient secret into a credential value. Use\n`config.toml`, CLI flags, or values exported by the launching shell for those\nexplicit control-plane settings. `.env` is read from a stable regular-file\nhandle, is capped at 1 MiB, and symbolic links, reparse points, and multiply\nlinked files are rejected.\n\n## Reading and checking configuration from the CLI\n\n`codewhale config get <key>` reads scalar keys, whole tables such as `tools`,\nand nested paths such as `tools.user_input_timeout_seconds`. Displayed tables\nand nested values apply the same recursive credential redaction as `config dump`.\n\n`config set` supports its named scalar keys and the provider, route, and\nnotification commands. It refuses a key that nothing reads and suggests the\nnearest real key (`config set calm_mod on` names `calm_mode`). A settings.toml\nkey such as `calm_mode` or `tool_collapse` is validated and written to the\nuser-global settings.toml, not config.toml; `config get` reads it back from\nthere even when an old config.toml copy (which nothing reads) is still present,\nand names that copy so you can `config unset` it. Settings keys have no project\nscope, so `--project` refuses them. A config.toml key is checked against the\ntype its reader expects and stored as a TOML boolean or number where the\nreader needs one (`yolo = true`, `max_subagents = 4`); `reasoning_effort`\naccepts the same aliases as `/effort` (#6563). Other dotted writes fail before\nmodifying the file and name the TOML table to edit. For example, set a tools\ntimeout in the file as:\n\n```toml\n[tools]\nuser_input_timeout_seconds = 0\n```\n\n`codewhale config doctor` checks credential presence and endpoint shape, and\nwarns about each config.toml root key that nothing reads: a settings.toml key\nleft in config.toml is named as misplaced, and any other unread key gets a\ndid-you-mean. Keys read by any runtime reader are not reported. The warnings do\nnot fail the check, and a clean result does not validate every runtime setting's\nvalue (#6083, #6563).\n\n## Usage and execution statistics\n\n`codewhale stats` is an alias of `codewhale metrics`; both use the same local,\nread-only backend. Use `--json` for the structured `stats` report. For example:\n\n```sh\ncodewhale stats --days 7 --models 10 --tools 20 --roles 10\ncodewhale metrics --project /path/to/workspace --json\n```\n\n`--days N` accepts a positive number of days and conflicts with `--since`.\n`--since 24h` and the existing duration syntax remain available. `--models N`,\n`--tools N` and `--roles N` select the top positive N rows by known tokens,\nidentified tool executions and recorded Fleet tasks respectively. These limits\nonly shorten the display; they do not remove stored receipts.\n\nThe report separates saved-document and Runtime views, frozen native-source\nmoney and token subtotals by actual model, retained tool-result history\nestimates and outcomes, completed compaction observations, and recorded Fleet\nroles. USD and CNY stay separate. A cumulative correction replaces its proven\npredecessor; copied Session/Runtime receipts and inherited Runtime fork rows\nare not added as new provider work. Conflicting source versions are shown\nseparately and excluded from the subtotal. Saved legacy display totals remain\nvisible as document projections without being charged again globally.\n\nCoverage is explicit. A missing quote, usage receipt, tool outcome, output\nshape or recovery link is unknown, not zero. Empty scalar usage from older\ntransport records cannot prove a reported zero. Token counts describe known\nreported data; tool history tokens are estimates of retained text, not bytes\nproven to have been replayed on a provider request. Latest prepared-request\ncomponents and source-linked compaction/role economics are unavailable where\nthey were not captured. The report does not infer provider invoices or prove\ncomplete historical coverage.\n\n`--project WORKSPACE` filters current saved/thread/Fleet views. Original source\nworkspace is not yet captured, so native money and model subtotals are\nunavailable for that filter; mutable current metadata is not used to assign a\ncharge. Older receipt projections without reliable workspace scope are also\nunavailable under this filter. Without it, those legacy receipt views remain\nseparate fromLine truncated
|
||||
"sha256": "fb934929d28d87dd161a26c427ffa21595ac358d994a46806c53f84fd49cb0f8",
|
||||
"content": "# Configuration\n\ncodewhale reads configuration from a TOML file plus environment variables.\nAt process startup it may also load literal built-in-provider credentials from\na workspace-local `.env` file. Use the tracked `.env.example` as the template;\ncopy it to `.env`, then add only credential values.\n\nA workspace is not configuration authority. Codewhale therefore ignores\nconfig/profile/home paths, provider/model/base-URL routing, MCP/plugin state,\napproval/sandbox/shell posture, executable paths, runtime settings, and every\nother non-credential `.env` entry. Variable expansion is rejected so a\nrepository cannot substitute an ambient secret into a credential value. Use\n`config.toml`, CLI flags, or values exported by the launching shell for those\nexplicit control-plane settings. `.env` is read from a stable regular-file\nhandle, is capped at 1 MiB, and symbolic links, reparse points, and multiply\nlinked files are rejected.\n\n## Reading and checking configuration from the CLI\n\n`codewhale config get <key>` reads scalar keys, whole tables such as `tools`,\nand nested paths such as `tools.user_input_timeout_seconds`. Displayed tables\nand nested values apply the same recursive credential redaction as `config dump`.\n\n`config set` supports its named scalar keys and the provider, route, and\nnotification commands. It refuses a key that nothing reads and suggests the\nnearest real key (`config set calm_mod on` names `calm_mode`). A settings.toml\nkey such as `calm_mode` or `tool_collapse` is validated and written to the\nuser-global settings.toml, not config.toml; `config get` reads it back from\nthere even when an old config.toml copy (which nothing reads) is still present,\nand names that copy so you can `config unset` it. Settings keys have no project\nscope, so `--project` refuses them. A config.toml key is checked against the\ntype its reader expects and stored as a TOML boolean or number where the\nreader needs one (`yolo = true`, `max_subagents = 4`); `reasoning_effort`\naccepts the same aliases as `/effort` (#6563). Other dotted writes fail before\nmodifying the file and name the TOML table to edit. For example, set a tools\ntimeout in the file as:\n\n```toml\n[tools]\nuser_input_timeout_seconds = 0\n```\n\n`codewhale config doctor` checks credential presence and endpoint shape, and\nwarns about each config.toml root key that nothing reads: a settings.toml key\nleft in config.toml is named as misplaced, and any other unread key gets a\ndid-you-mean. Keys read by any runtime reader are not reported. The warnings do\nnot fail the check, and a clean result does not validate every runtime setting's\nvalue (#6083, #6563).\n\n## Usage and execution statistics\n\n`codewhale stats` is an alias of `codewhale metrics`; both use the same local,\nread-only backend. Use `--json` for the structured `stats` report. For example:\n\n```sh\ncodewhale stats --days 7 --models 10 --tools 20 --roles 10\ncodewhale metrics --project /path/to/workspace --json\n```\n\n`--days N` accepts a positive number of days and conflicts with `--since`.\n`--since 24h` and the existing duration syntax remain available. `--models N`,\n`--tools N` and `--roles N` select the top positive N rows by known tokens,\nidentified tool executions and recorded Fleet tasks respectively. These limits\nonly shorten the display; they do not remove stored receipts.\n\nThe report separates saved-document and Runtime views, frozen native-source\nmoney and token subtotals by actual model, retained tool-result history\nestimates and outcomes, completed compaction observations, and recorded Fleet\nroles. USD and CNY stay separate. A cumulative correction replaces its proven\npredecessor; copied Session/Runtime receipts and inherited Runtime fork rows\nare not added as new provider work. Conflicting source versions are shown\nseparately and excluded from the subtotal. Saved legacy display totals remain\nvisible as document projections without being charged again globally.\n\nCoverage is explicit. A missing quote, usage receipt, tool outcome, output\nshape or recovery link is unknown, not zero. Empty scalar usage from older\ntransport records cannot prove a reported zero. Token counts describe known\nreported data; tool history tokens are estimates of retained text, not bytes\nproven to have been replayed on a provider request. Latest prepared-request\ncomponents and source-linked compaction/role economics are unavailable where\nthey were not captured. The report does not infer provider invoices or prove\ncomplete historical coverage.\n\n`--project WORKSPACE` filters current saved/thread/Fleet views. Original source\nworkspace is not yet captured, so native money and model subtotals are\nunavailable for that filter; mutable current metadata is not used to assign a\ncharge. Older receipt projections without reliable workspace scope are also\nunavailable under this filter. Without it, those legacy receipt views remain\nseparate fromLine truncated
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"format": "codewhale.features.v1",
|
||||
"source": {
|
||||
"path": "docs/features.toml",
|
||||
"sha256": "a63042dc25a658f626735cc40e7a7cf4c67eaa51bfbb037dc2820d083d10fe3a"
|
||||
"sha256": "4c4dfe17cea716328253c687d9d0665e1b24fc3b59d00f2a81cf1157dfea7072"
|
||||
},
|
||||
"features": [
|
||||
{
|
||||
@@ -1070,7 +1070,7 @@
|
||||
{
|
||||
"id": "live-thinking",
|
||||
"name": "Live thinking",
|
||||
"summary": "Model thinking shows live in the transcript, then collapses under a Thought header; /verbose shows it in full.",
|
||||
"summary": "/verbose toggles live model thinking in the transcript.",
|
||||
"status": "stable",
|
||||
"surfaces": {
|
||||
"tui": "stable"
|
||||
|
||||
@@ -35,6 +35,7 @@ export const RELEASE_CONTRIBUTORS: string[] = [
|
||||
"@luochen211",
|
||||
"@gannp",
|
||||
"@Felixkw12",
|
||||
"@MannXo",
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -42,7 +43,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", "@MannXo"];
|
||||
export const UNRELEASED_CONTRIBUTORS: string[] = ["@luochen211", "@Lstarsky0", "@SamhandsomeLee", "@gaord"];
|
||||
|
||||
/**
|
||||
* Contributors who helped with reports, reproductions, and verification.
|
||||
|
||||
Reference in new issue
Block a user