diff --git a/Cargo.lock b/Cargo.lock index 330f7ee170..d9025c1f9f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1186,6 +1186,7 @@ dependencies = [ "fd-lock", "libc", "lru", + "parking_lot", "regex", "serde", "serde_json", diff --git a/crates/core/src/prefix_cache.rs b/crates/core/src/prefix_cache.rs index 4dc3bfc9f4..6f25e163e8 100644 --- a/crates/core/src/prefix_cache.rs +++ b/crates/core/src/prefix_cache.rs @@ -712,7 +712,11 @@ impl PrefixStabilityManager { /// Serialize a tool to the same JSON shape the chat API receives, /// excluding internal-only fields like `allowed_callers`, `defer_loading`, /// `input_examples`, and `cache_control` that are never sent to DeepSeek. -fn tool_to_api_json(tool: &Tool) -> Option { +/// +/// Shared by the fingerprint manager and fork-prefix inheritance: both +/// need byte-equality against provider-cached prefixes, and raw `Tool` +/// serialization would false-negative on internal-only flag skew. +pub fn tool_to_api_json(tool: &Tool) -> Option { let mut value = serde_json::json!({ "type": "function", "function": { diff --git a/crates/runtime/Cargo.toml b/crates/runtime/Cargo.toml index f75235098d..4fab2fae76 100644 --- a/crates/runtime/Cargo.toml +++ b/crates/runtime/Cargo.toml @@ -47,6 +47,7 @@ codewhale-palette = { path = "../palette", version = "0.10.2", default-features colored = "3.0.0" fd-lock = "4.0.4" lru = "0.18" +parking_lot = "0.12" regex = "1.11" serde.workspace = true serde_json = { workspace = true, features = ["preserve_order", "raw_value"] } diff --git a/crates/runtime/src/prompt_zones.rs b/crates/runtime/src/prompt_zones.rs index 961a5aaaa8..29d13c4166 100644 --- a/crates/runtime/src/prompt_zones.rs +++ b/crates/runtime/src/prompt_zones.rs @@ -182,6 +182,249 @@ impl std::fmt::Display for PrefixDrift { } } +/// Exact header of the parent turn's latest model request, shared with +/// fork children so a same-route child can extend the parent's live +/// cached prefix instead of starting cold. +/// +/// Written once per model request by the turn loop (after the request +/// is final), read once per fork spawn. `None` until the first request +/// of the session goes out. The history half of the prefix rides +/// `SubAgentForkContext.messages` (turn-start snapshot); the system and +/// tools here must byte-match a cached request or inheritance silently +/// costs more than a cold start — every gate below fails closed. +#[derive(Debug, Clone)] +pub struct LiveHeaderSnapshot { + /// Exact system prompt, shape-preserving (`Text` stays `Text`). + pub system: Option, + /// Wire-order canonical tools JSON (see [`wire_tool_catalog_json`]). + pub tools_json: String, + /// Canonical ordered tool names used to match the child's already-active + /// policy-filtered tools without activating additional schemas. + pub active_names: Vec, + /// Route the request was built for (caches are per route). + pub model: String, + pub provider: codewhale_config::ProviderKind, + pub provider_identity: String, + pub endpoint_fingerprint: Option, + pub route_receipt: Option, + /// Request messages the hot proof covers. Each new request must + /// start with these byte-for-byte (element-wise) to keep the + /// proof; compaction or any rewrite resets to cold. + pub history: Vec, + /// Provider-reported cached-input tokens for this logical request, carried + /// across equal headers and append-only history with the same opaque route. + /// Positive usage permits matching prefix reuse; it does not establish + /// which components or the entire history were cache hits. + pub last_hit_tokens: Option, +} + +/// Cloneable handle to the turn's live-header cell. Created once per +/// Engine (turns run strictly one at a time); fork contexts clone the +/// `Arc` and read it at spawn. +pub type SharedLiveHeader = Arc>>>; + +/// Empty live-header cell: nothing cached yet, every gate reads cold. +#[must_use] +pub fn new_live_header_cell() -> SharedLiveHeader { + Arc::new(std::sync::Mutex::new(None)) +} + +/// Wire-canonical tools serialization: per-tool chat-API JSON in wire +/// order, joined by `\n`. Each segment goes through +/// [`codewhale_core::prefix_cache::tool_to_api_json`], so internal-only +/// fields (`defer_loading`, `allowed_callers`, …) that never reach the +/// provider cannot false-negative a byte comparison. Unlike +/// [`tool_catalog_digest`] (sorted for hashing), order is part of the +/// wire bytes. `None` if any tool fails to serialize; the inherit gate +/// treats that as a mismatch. +#[must_use] +pub fn wire_tool_catalog_json(tools: &[Tool]) -> Option { + let mut out = String::new(); + for (index, tool) in tools.iter().enumerate() { + if index > 0 { + out.push('\n'); + } + out.push_str(&codewhale_core::prefix_cache::tool_to_api_json(tool)?); + } + Some(out) +} + +/// Trial gate: `CODEWHALE_FORK_INHERIT=off` disables prefix inheritance +/// (same-binary A/B against the default-on arm). Process-pinned. +#[must_use] +pub fn fork_inherit_enabled() -> bool { + static GATE: std::sync::OnceLock = std::sync::OnceLock::new(); + *GATE.get_or_init(|| { + std::env::var("CODEWHALE_FORK_INHERIT") + .map(|raw| raw.trim() != "off") + .unwrap_or(true) + }) +} + +/// Trial diagnostics: `CODEWHALE_FORK_TRIAL=1` mirrors fork-inherit trial +/// events (decision + child first-response cache split) to stderr as +/// single-line JSON. Headless `exec` installs no tracing subscriber, so +/// the `tracing` trial logs are invisible there; this is the metered +/// readout for same-binary A/B trials. Off unless explicitly enabled. +#[must_use] +pub fn fork_trial_diag_enabled() -> bool { + static GATE: std::sync::OnceLock = std::sync::OnceLock::new(); + *GATE.get_or_init(|| { + std::env::var("CODEWHALE_FORK_TRIAL") + .map(|raw| raw.trim() == "1") + .unwrap_or(false) + }) +} + +/// Hot-proof carry-over for the per-request snapshot write. +/// +/// Tool calls (including fork spawns) execute before the current +/// request's usage lands, so a snapshot that reset its proof on every +/// write would read cold at every spawn. The new request keeps the old +/// proof only when it extends the old head byte-for-byte: same system, +/// same wire tools, messages grown append-only. Drift, compaction, or +/// any rewrite resets to `None`. Fails closed throughout. +#[must_use] +pub fn carry_hot_forward( + old: Option<&LiveHeaderSnapshot>, + new_system: &Option, + new_tools_json: &str, + new_history: &[Message], + new_route_receipt: Option<&R>, +) -> Option { + let old = old?; + if new_route_receipt.is_none() || old.route_receipt.as_ref() != new_route_receipt { + return None; + } + let hits = old.last_hit_tokens.filter(|hits| *hits > 0)?; + if &old.system != new_system { + return None; + } + if old.tools_json != new_tools_json { + return None; + } + if !new_history.starts_with(&old.history) { + return None; + } + Some(hits) +} + +/// Fork-inherit decision with a trial-log reason and, when inheriting, +/// the head of the parent's wire block the child reproduces byte-for-byte. +#[derive(Debug, Clone, PartialEq)] +pub struct ForkInheritDecision { + pub inherit: bool, + pub reason: &'static str, + /// Longest matching canonical tool head from the child's policy-filtered + /// active catalog. It determines ordering, never grants or removes tools. + /// Empty unless `inherit` is true. + pub prefix_tools: Vec, +} + +/// Pure inherit gate with graceful prefix tiers. +/// +/// Full-block equality almost never holds in production: the parent's +/// wire block grows by deferred discovery while the child's grant is +/// fixed and usually narrower. Instead the gate resolves the longest +/// head of the parent's wire block the child reproduces byte-for-byte +/// (same actual route receipt and positive parent cache usage required): +/// `inherited_full` matches all parent tool definitions; `inherited_prefix` +/// matches a shorter head. Neither outcome proves full history cache coverage +/// or provider savings. No common head and every guard failure stay cold. +/// +/// The grant boundary enforces itself through catalog lookup: names +/// outside the child's grant end the run, so narrowed roles inherit a +/// shorter head instead of cold-starting — no role table. `gate_on` is +/// [`fork_inherit_enabled`] read by the caller, so every arm stays +/// unit-testable. +#[must_use] +#[allow(clippy::too_many_arguments)] +pub fn resolve_fork_inherit( + gate_on: bool, + snapshot: Option<&LiveHeaderSnapshot>, + child_model: &str, + child_provider: codewhale_config::ProviderKind, + child_provider_identity: &str, + child_wire_format: codewhale_config::provider::WireFormat, + child_endpoint_fingerprint: Option<&str>, + child_route_receipt: Option<&R>, + child_catalog: &[Tool], + history_len: usize, +) -> ForkInheritDecision { + let cold = |reason: &'static str| ForkInheritDecision { + inherit: false, + reason, + prefix_tools: Vec::new(), + }; + if !gate_on { + return cold("gate_off"); + } + if child_wire_format != codewhale_config::provider::WireFormat::ChatCompletions { + return cold("wire_format_unsupported"); + } + let Some(snapshot) = snapshot else { + return cold("no_snapshot"); + }; + if snapshot.system.is_none() { + return cold("no_parent_system"); + } + if snapshot.model != child_model { + return cold("model_mismatch"); + } + if snapshot.provider != child_provider { + return cold("provider_mismatch"); + } + if snapshot.provider_identity != child_provider_identity { + return cold("route_mismatch"); + } + if snapshot.endpoint_fingerprint.as_deref() != child_endpoint_fingerprint + || child_endpoint_fingerprint.is_none() + { + return cold("endpoint_mismatch"); + } + if child_route_receipt.is_none() || snapshot.route_receipt.as_ref() != child_route_receipt { + return cold("route_receipt_mismatch"); + } + if snapshot.last_hit_tokens.unwrap_or(0) == 0 { + return cold("prefix_cold"); + } + if history_len == 0 { + return cold("empty_history"); + } + if snapshot.active_names.is_empty() { + return cold("no_parent_tools"); + } + let parent_wire: Vec<&str> = snapshot.tools_json.split('\n').collect(); + let mut prefix = Vec::new(); + for (index, name) in snapshot.active_names.iter().enumerate() { + let Some(parent_json) = parent_wire.get(index) else { + break; + }; + let Some(tool) = child_catalog.iter().find(|tool| &tool.name == name) else { + break; + }; + let tool = tool.clone(); + let wire = codewhale_core::prefix_cache::tool_to_api_json(&tool); + if wire.as_deref() != Some(*parent_json) { + break; + } + prefix.push(tool); + } + if prefix.is_empty() { + return cold("tools_mismatch"); + } + let full = prefix.len() == snapshot.active_names.len(); + ForkInheritDecision { + inherit: true, + reason: if full { + "inherited_full" + } else { + "inherited_prefix" + }, + prefix_tools: prefix, + } +} + // ── AppendLog ────────────────────────────────────────────────────────── /// Append-only conversation history. Derefs to `&[Message]` via @@ -733,4 +976,328 @@ mod tests { panic!("expected Text content block"); } } + + fn inherit_snapshot() -> LiveHeaderSnapshot<&'static str> { + let tools = vec![make_tool("read"), make_tool("agent")]; + LiveHeaderSnapshot { + system: Some(SystemPrompt::Text("parent system".to_string())), + tools_json: wire_tool_catalog_json(&tools).expect("serializes"), + active_names: tools.iter().map(|tool| tool.name.clone()).collect(), + model: "deepseek-chat".to_string(), + provider: codewhale_config::ProviderKind::Deepseek, + provider_identity: "deepseek".to_string(), + endpoint_fingerprint: Some("fixture-endpoint".to_string()), + route_receipt: Some("fixture-route"), + history: vec![make_message("user", "hi")], + last_hit_tokens: Some(1200), + } + } + + #[test] + fn wire_tool_json_is_order_sensitive_and_stable() { + let pair = [make_tool("a"), make_tool("b")]; + let swapped = [make_tool("b"), make_tool("a")]; + let forward = wire_tool_catalog_json(&pair).expect("serializes"); + let backward = wire_tool_catalog_json(&swapped).expect("serializes"); + assert_ne!(forward, backward, "wire order is part of the bytes"); + assert_eq!( + forward, + wire_tool_catalog_json(&pair).expect("stable"), + "same order serializes identically" + ); + assert_eq!(wire_tool_catalog_json(&[]).expect("empty"), ""); + // Internal-only flags never reach the wire bytes. + let mut flagged = make_tool("a"); + flagged.defer_loading = Some(true); + flagged.allowed_callers = Some(vec!["x".to_string()]); + assert_eq!( + wire_tool_catalog_json(&[flagged]).expect("serializes"), + wire_tool_catalog_json(&[make_tool("a")]).expect("serializes"), + ); + } + + #[test] + fn fork_inherit_requires_every_gate() { + let snapshot = inherit_snapshot(); + let full_catalog = vec![make_tool("read"), make_tool("agent")]; + let decide = + |snapshot: Option<&LiveHeaderSnapshot<&'static str>>, model: &str, catalog: &[Tool]| { + resolve_fork_inherit( + true, + snapshot, + model, + codewhale_config::ProviderKind::Deepseek, + "deepseek", + codewhale_config::provider::WireFormat::ChatCompletions, + Some("fixture-endpoint"), + Some(&"fixture-route"), + catalog, + 4, + ) + }; + let ok = decide(Some(&snapshot), "deepseek-chat", &full_catalog); + assert!(ok.inherit, "{ok:?}"); + assert_eq!(ok.reason, "inherited_full"); + assert_eq!(ok.prefix_tools.len(), 2); + let mut missing_receipt = snapshot.clone(); + missing_receipt.route_receipt = None; + assert_eq!( + decide(Some(&missing_receipt), "deepseek-chat", &full_catalog).reason, + "route_receipt_mismatch" + ); + let mut changed_receipt = snapshot.clone(); + changed_receipt.route_receipt = Some("different-fixture-route"); + assert_eq!( + decide(Some(&changed_receipt), "deepseek-chat", &full_catalog).reason, + "route_receipt_mismatch" + ); + let unsupported = resolve_fork_inherit( + true, + Some(&snapshot), + "deepseek-chat", + codewhale_config::ProviderKind::Deepseek, + "deepseek", + codewhale_config::provider::WireFormat::AnthropicMessages, + Some("fixture-endpoint"), + Some(&"fixture-route"), + &full_catalog, + 4, + ); + assert!(!unsupported.inherit); + assert_eq!(unsupported.reason, "wire_format_unsupported"); + + // Narrowed grant: head matches, tail grant-gap ends the run. + let narrowed = vec![make_tool("read")]; + let prefix = decide(Some(&snapshot), "deepseek-chat", &narrowed); + assert!(prefix.inherit, "{prefix:?}"); + assert_eq!(prefix.reason, "inherited_prefix"); + assert_eq!(prefix.prefix_tools.len(), 1); + + // Grant gap at the head: no common head, stays cold. + let gap_at_head = vec![make_tool("agent")]; + assert_eq!( + decide(Some(&snapshot), "deepseek-chat", &gap_at_head).reason, + "tools_mismatch" + ); + // Schema skew on the head tool also ends the run at zero. + let mut skewed = make_tool("read"); + skewed.description = "different".to_string(); + assert_eq!( + decide(Some(&snapshot), "deepseek-chat", &[skewed]).reason, + "tools_mismatch" + ); + // Internal-only flag skew must NOT end the run: those bytes + // never reach the provider. + let mut flagged = make_tool("read"); + flagged.defer_loading = Some(true); + flagged.allowed_callers = Some(vec!["parent-only".to_string()]); + let flagged_catalog = vec![flagged, make_tool("agent")]; + let flagged_ok = decide(Some(&snapshot), "deepseek-chat", &flagged_catalog); + assert!(flagged_ok.inherit, "{flagged_ok:?}"); + assert_eq!(flagged_ok.reason, "inherited_full"); + + assert_eq!( + decide(None, "deepseek-chat", &full_catalog).reason, + "no_snapshot" + ); + let mut no_system = snapshot.clone(); + no_system.system = None; + assert_eq!( + decide(Some(&no_system), "deepseek-chat", &full_catalog).reason, + "no_parent_system" + ); + assert_eq!( + decide(Some(&snapshot), "other-model", &full_catalog).reason, + "model_mismatch" + ); + let provider_mismatch = resolve_fork_inherit( + true, + Some(&snapshot), + "deepseek-chat", + codewhale_config::ProviderKind::Openai, + "deepseek", + codewhale_config::provider::WireFormat::ChatCompletions, + Some("fixture-endpoint"), + Some(&"fixture-route"), + &full_catalog, + 4, + ); + assert_eq!(provider_mismatch.reason, "provider_mismatch"); + assert!(!provider_mismatch.inherit); + let route_mismatch = resolve_fork_inherit( + true, + Some(&snapshot), + "deepseek-chat", + codewhale_config::ProviderKind::Deepseek, + "custom-mirror", + codewhale_config::provider::WireFormat::ChatCompletions, + Some("fixture-endpoint"), + Some(&"fixture-route"), + &full_catalog, + 4, + ); + assert_eq!(route_mismatch.reason, "route_mismatch"); + for cold in [None, Some(0)] { + let mut snapshot = snapshot.clone(); + snapshot.last_hit_tokens = cold; + assert_eq!( + decide(Some(&snapshot), "deepseek-chat", &full_catalog).reason, + "prefix_cold", + "cold={cold:?}" + ); + } + let empty_history = resolve_fork_inherit( + true, + Some(&snapshot), + "deepseek-chat", + codewhale_config::ProviderKind::Deepseek, + "deepseek", + codewhale_config::provider::WireFormat::ChatCompletions, + Some("fixture-endpoint"), + Some(&"fixture-route"), + &full_catalog, + 0, + ); + assert_eq!(empty_history.reason, "empty_history"); + let mut no_tools = snapshot.clone(); + no_tools.active_names.clear(); + assert_eq!( + decide(Some(&no_tools), "deepseek-chat", &full_catalog).reason, + "no_parent_tools" + ); + let gated_off = resolve_fork_inherit( + false, + Some(&snapshot), + "deepseek-chat", + codewhale_config::ProviderKind::Deepseek, + "deepseek", + codewhale_config::provider::WireFormat::ChatCompletions, + Some("fixture-endpoint"), + Some(&"fixture-route"), + &full_catalog, + 4, + ); + assert_eq!(gated_off.reason, "gate_off"); + assert!(!gated_off.inherit); + } + + #[test] + fn carry_hot_forward_keeps_append_only_and_resets_rewrites() { + let snapshot = inherit_snapshot(); + let grown = vec![ + make_message("user", "hi"), + make_message("assistant", "hello"), + ]; + assert_eq!( + carry_hot_forward( + Some(&snapshot), + &snapshot.system, + &snapshot.tools_json, + &grown, + Some(&"fixture-route") + ), + Some(1200), + "append-only growth keeps the proof" + ); + // Identical re-send (transparent retry) also extends. + assert_eq!( + carry_hot_forward( + Some(&snapshot), + &snapshot.system, + &snapshot.tools_json, + &snapshot.history, + Some(&"fixture-route") + ), + Some(1200) + ); + let edited = vec![make_message("user", "edited")]; + assert_eq!( + carry_hot_forward( + Some(&snapshot), + &snapshot.system, + &snapshot.tools_json, + &edited, + Some(&"fixture-route") + ), + None, + "in-place rewrite resets" + ); + assert_eq!( + carry_hot_forward( + Some(&snapshot), + &snapshot.system, + &snapshot.tools_json, + &[], + Some(&"fixture-route") + ), + None, + "compaction shrink resets" + ); + let other_system = Some(SystemPrompt::Text("other".to_string())); + assert_eq!( + carry_hot_forward( + Some(&snapshot), + &other_system, + &snapshot.tools_json, + &grown, + Some(&"fixture-route") + ), + None, + "system drift resets" + ); + assert_eq!( + carry_hot_forward( + Some(&snapshot), + &snapshot.system, + "{\"name\":\"other\"}", + &grown, + Some(&"fixture-route") + ), + None, + "tool drift resets" + ); + assert_eq!( + carry_hot_forward( + None, + &snapshot.system, + &snapshot.tools_json, + &grown, + Some(&"fixture-route") + ), + None + ); + assert_eq!( + carry_hot_forward( + Some(&snapshot), + &snapshot.system, + &snapshot.tools_json, + &grown, + None + ), + None + ); + assert_eq!( + carry_hot_forward( + Some(&snapshot), + &snapshot.system, + &snapshot.tools_json, + &grown, + Some(&"different-fixture-route") + ), + None + ); + let mut cold = snapshot.clone(); + cold.last_hit_tokens = Some(0); + assert_eq!( + carry_hot_forward( + Some(&cold), + &cold.system, + &cold.tools_json, + &grown, + Some(&"fixture-route") + ), + None, + "zero hits never carry" + ); + } } diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 3738bdcbaa..8ef198bc54 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -4622,6 +4622,13 @@ impl CodewhaleClient { } impl LlmClient for CodewhaleClient { + fn wire_format(&self) -> Option { + Some(CodewhaleClient::wire_format(self)) + } + fn cache_route_receipt(&self) -> Option { + Some(self.turn_route_receipt()) + } + fn provider_name(&self) -> &'static str { self.api_provider.as_str() } diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index a05a2d8480..a1de9c5ebb 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -32,7 +32,9 @@ use codewhale_models::{ use super::prepared::WireDialect; use super::role_placement::{RolePlacement, role_placement}; -use super::wire::{extract_sse_data_value, flush_sse_line, push_sse_event_data, take_sse_line}; +use super::wire::{ + extract_sse_data_value, flush_sse_line, push_sse_event_data, take_sse_line_from_slice, +}; use super::{ CodewhaleClient, ERROR_BODY_MAX_BYTES, SSE_BACKPRESSURE_HIGH_WATERMARK, SSE_BACKPRESSURE_SLEEP_MS, SSE_MAX_LINES_PER_CHUNK, acquire_stream_buffer, @@ -1547,8 +1549,9 @@ impl CodewhaleClient { // a `\n` so an HTTP/2 DATA split mid-character cannot become // U+FFFD; genuine invalid bytes fail closed. let mut lines_processed = 0usize; + let mut remaining = byte_buf.as_slice(); loop { - let line = match take_sse_line(&mut byte_buf) { + let line = match take_sse_line_from_slice(&mut remaining) { Ok(Some(line)) => line, Ok(None) => break, Err(err) => { @@ -1637,6 +1640,9 @@ impl CodewhaleClient { tokio::task::yield_now().await; } } + // Move the unterminated tail once per chunk, not once per line. + let consumed = byte_buf.len() - remaining.len(); + byte_buf.drain(..consumed); } // Flush a final SSE frame that arrived without a terminating blank @@ -1826,7 +1832,6 @@ impl<'a> PromptBuilder<'a> { ), false, ); - dump_system_prompt_if_requested(&messages); if provider == ProviderKind::Arcee { apply_arcee_waf_safe_message_encoding(&mut messages); } @@ -1906,38 +1911,82 @@ const SYSTEM_PROMPT_DUMP_BEGIN: &str = "<<>>"; const SYSTEM_PROMPT_DUMP_END: &str = "<<>>"; const ARCEE_WAF_TEXT_SPLIT_TRIGGERS: &[(&str, &str, &str)] = &[("python -c", "python ", "-c")]; -fn dump_system_prompt_if_requested(messages: &[Value]) { +/// Dump the wire prefix (system region + tools array) when the +/// system-prompt dump flag is set, so prefix-token audits can measure the +/// catalog beside the prompt. Called once from +/// [`PreparedOutboundRequest::new`](super::prepared::PreparedOutboundRequest::new), +/// the single funnel for all three wire dialects, so the bytes are the +/// exact-final-body bytes. Debug-only, zero production impact. +pub(crate) fn dump_wire_prefix_if_requested(body: &Value) { let Ok(flag) = std::env::var(SYSTEM_PROMPT_DUMP_ENV) else { return; }; if !matches!(flag.trim(), "1" | "true" | "TRUE" | "yes" | "YES") { return; } - let Some(prompt) = messages.iter().find_map(system_message_text) else { - return; - }; let mut stderr = std::io::stderr().lock(); - let _ = writeln!(stderr, "{SYSTEM_PROMPT_DUMP_BEGIN}"); - let _ = writeln!(stderr, "{prompt}"); - let _ = writeln!(stderr, "{SYSTEM_PROMPT_DUMP_END}"); -} - -fn system_message_text(message: &Value) -> Option { - if message.get("role").and_then(Value::as_str) != Some("system") { - return None; + if let Some(prompt) = wire_system_text(body) { + let _ = writeln!(stderr, "{SYSTEM_PROMPT_DUMP_BEGIN}"); + let _ = writeln!(stderr, "{prompt}"); + let _ = writeln!(stderr, "{SYSTEM_PROMPT_DUMP_END}"); + } + if let Some(tools) = body.get("tools") { + let _ = writeln!(stderr, "<<>>"); + let _ = writeln!(stderr, "{tools}"); + let _ = writeln!(stderr, "<<>>"); + } +} + +/// Best-effort system-region text across the three wire dialects: +/// Anthropic `system` (string or blocks), Responses `instructions`, and +/// chat `messages[]` with a system/developer role. +fn wire_system_text(body: &Value) -> Option { + if let Some(system) = body.get("system") { + return Some(match system { + Value::String(text) => text.clone(), + Value::Array(parts) => { + let text = parts + .iter() + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect::>() + .join("\n"); + if text.is_empty() { + system.to_string() + } else { + text + } + } + _ => system.to_string(), + }); } - match message.get("content")? { - Value::String(text) => Some(text.clone()), - Value::Array(parts) => { - let text = parts - .iter() - .filter_map(|part| part.get("text").and_then(Value::as_str)) - .collect::>() - .join(""); - (!text.is_empty()).then_some(text) - } - _ => None, + if let Some(instructions) = body.get("instructions").and_then(Value::as_str) { + return Some(instructions.to_string()); } + body.get("messages")? + .as_array()? + .iter() + .find_map(|message| { + if !matches!( + message.get("role").and_then(Value::as_str), + Some("system" | "developer") + ) { + return None; + } + match message.get("content")? { + Value::String(text) => Some(text.clone()), + Value::Array(parts) => { + // Same "\n" block join as the Anthropic arm above, so + // the audit text is dialect-consistent. + let text = parts + .iter() + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect::>() + .join("\n"); + (!text.is_empty()).then_some(text) + } + _ => None, + } + }) } fn apply_arcee_waf_safe_message_encoding(messages: &mut [Value]) { diff --git a/crates/tui/src/client/prepared.rs b/crates/tui/src/client/prepared.rs index e8357c83be..4eb82cd4b5 100644 --- a/crates/tui/src/client/prepared.rs +++ b/crates/tui/src/client/prepared.rs @@ -305,6 +305,7 @@ impl PreparedOutboundRequest { replay_input_tokens: Option, entrypoint: CallerStreamMode, ) -> Self { + super::chat::dump_wire_prefix_if_requested(&body); let reasoning = ReasoningReceipt::from_body(dialect, &body, requested_effort); Self { dialect, diff --git a/crates/tui/src/client/test_cases_02.rs b/crates/tui/src/client/test_cases_02.rs index cdf2bebef5..a2df66111f 100644 --- a/crates/tui/src/client/test_cases_02.rs +++ b/crates/tui/src/client/test_cases_02.rs @@ -448,14 +448,14 @@ let expected_names = crate::core::engine::default_active_native_tool_names() .iter() .copied() - // Children can inspect the shared goal, but only its owning - // session can create it or change its completion state. + // Children discover goal inspection via search; only the + // owning session can create a goal or change its + // completion state, so those never ride the child surface. .filter(|name| !matches!(*name, "create_goal" | "update_goal")) .chain([crate::core::engine::tool_catalog::TOOL_SEARCH_NAME]) .map(str::to_string) .collect(); assert_eq!(source_names, expected_names); - assert!(source_names.contains("get_goal")); assert!(!source_names.contains("create_goal")); assert!(!source_names.contains("update_goal")); diff --git a/crates/tui/src/client/wire.rs b/crates/tui/src/client/wire.rs index 1db76c3495..7b9921523c 100644 --- a/crates/tui/src/client/wire.rs +++ b/crates/tui/src/client/wire.rs @@ -197,9 +197,19 @@ fn decode_sse_line_bytes(bytes: &[u8]) -> Result<&str, SseLineError> { /// Genuine invalid bytes fail closed (`Err(SseLineError)`); we do not /// substitute U+FFFD. pub(super) fn take_sse_line(buffer: &mut Vec) -> Result, SseLineError> { + let mut remaining = buffer.as_slice(); + let decoded = take_sse_line_from_slice(&mut remaining); + let consumed = buffer.len() - remaining.len(); + buffer.drain(..consumed); + decoded +} + +/// Advance over one complete line without moving the remaining bytes. Chunk +/// consumers can drain the consumed prefix once after processing all its lines. +pub(super) fn take_sse_line_from_slice(buffer: &mut &[u8]) -> Result, SseLineError> { let line_end = buffer.iter().position(|&b| b == b'\n'); if line_end.unwrap_or(buffer.len()) > MAX_SSE_LINE_BYTES { - buffer.clear(); + *buffer = &[]; return Err(SseLineError::TooLong); } let Some(line_end) = line_end else { @@ -211,7 +221,7 @@ pub(super) fn take_sse_line(buffer: &mut Vec) -> Result, SseL end -= 1; } let decoded = decode_sse_line_bytes(&buffer[..end]).map(|text| text.trim().to_string()); - buffer.drain(..=line_end); + *buffer = &buffer[line_end + 1..]; decoded.map(Some) } diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 02b630192f..05581255eb 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -1093,6 +1093,13 @@ pub struct Engine { /// One captured admitted route. Presentation snapshots derive strings from /// it; a changed table cannot be blessed by reinterpreting those strings. api_provider_identity: Option, + /// Live header cell for fork-prefix inheritance: the turn loop writes + /// the latest request's exact (system, tools, route, hit-flag) here + /// per model call, and fork contexts clone the handle so a + /// same-route child can extend the parent's cached prefix. Safe as + /// a session-lifetime cell: turns run strictly one at a time, so it + /// always holds the latest request of the running turn. + live_header: crate::prompt_zones::SharedLiveHeader, active_route_limits: Option, active_route_capabilities: codewhale_config::route::RouteCapabilities, /// The endpoint the current client was built from: base URL, endpoint @@ -2501,6 +2508,7 @@ impl Engine { repeated_upstream_failure: None, api_provider, api_provider_identity, + live_header: crate::prompt_zones::new_live_header_cell(), active_route_limits, active_route_capabilities: codewhale_config::route::RouteCapabilities::default(), active_route_endpoint: None, @@ -4594,7 +4602,13 @@ impl Engine { async fn emit_session_updated(&self) { if let Some(job) = self.child_job() - && let Err(error) = job.project(&self.session.messages, job.steps()).await + && let Err(error) = job + .project( + &self.session.messages, + job.steps(), + self.session.system_prompt.as_ref(), + ) + .await { self.cancel_token.cancel(); tracing::error!(%error, "child Session projection failed; turn stopped"); @@ -5744,6 +5758,7 @@ impl Engine { mcp_access: McpAccess, route: TurnRouteContext, turn_id: &str, + turn_goal_objective: Option<&str>, ) -> TurnToolBuild { // Account-owned Chat is a text-only inference boundary. Do not build // native/plugin/dynamic registries, connect or snapshot MCP, capture a @@ -5807,6 +5822,7 @@ impl Engine { tool_context.clone(), self.tx_subagent_completion.clone(), SubAgentForkContext { + live_header: crate::prompt_zones::new_live_header_cell(), messages: self.messages_with_turn_metadata(), structured_state_block: None, work_source: Some(self.todo_source()), @@ -5854,6 +5870,7 @@ impl Engine { .await; Some(SubAgentForkContext { messages: self.messages_with_turn_metadata(), + live_header: Arc::clone(&self.live_header), structured_state_block: state.to_system_block(), // Resolve at spawn time so a todo_write earlier in this turn // reaches the child rather than freezing turn-start state. @@ -6095,7 +6112,20 @@ impl Engine { // The surface budget belongs to the route the request would go to, // which is not necessarily the installed one under auto routing. let capability = route.capability_profile(); - let always_load = self.config.tools_always_load.clone(); + let mut always_load = self.config.tools_always_load.clone(); + // Token diet: get/update_goal cannot succeed without an active goal, + // so they stay deferred until one exists. Keyed off the TURN's + // resolved goal (the same `NextTurnPromptContext.goal_objective` + // the turn fragment renders), never ambient engine config: + // production installs the turn goal before this build while + // preview builds without installing, so ambient state would + // diverge preview from the wire body on detached-goal turns. + // Same-field gating also means no new cache break — the tools + // flip exactly when the fragment flips. + if turn_goal_objective.is_some() { + always_load.insert("get_goal".to_string()); + always_load.insert("update_goal".to_string()); + } self.turn_tool_surface_budget = Some(capability.tool_surface_budget); if self.child_host.is_some() { tool_registry.remove_tool("create_goal"); @@ -6145,6 +6175,18 @@ impl Engine { ); if let Some(child) = self.child_host.as_ref() { Self::narrow_child_surface(&child.authority, &mut surface); + let prefix = &child.fork_cache_prefix; + surface.catalog.sort_by_key(|tool| { + prefix + .iter() + .position(|name| name == &tool.name) + .unwrap_or(prefix.len()) + }); + surface.active = tool_catalog::active_tools_for_request( + &surface.catalog, + &surface.active_names, + surface.strict_tool_mode, + ); } TurnToolBuild { surface, @@ -6955,6 +6997,7 @@ impl Engine { reasoning_effort_auto: self.session.reasoning_effort_auto, }, &turn_id_for_mailbox, + prompt_context.goal_objective.as_deref(), ) .await; let tool_catalog_for_event = Some(surface.catalog.clone()); diff --git a/crates/tui/src/core/engine/child_host.rs b/crates/tui/src/core/engine/child_host.rs index eb7013069f..f4618c9d15 100644 --- a/crates/tui/src/core/engine/child_host.rs +++ b/crates/tui/src/core/engine/child_host.rs @@ -15,6 +15,11 @@ pub(super) struct ChildHostState { handle: Option, pub(super) report: Option, route_runtime: crate::tools::subagent::SubAgentRuntime, + pub(super) fork_cache_checked: bool, + pub(super) fork_trial_first_response_logged: bool, + pub(super) fork_cache_inherited: + Option>, + pub(super) fork_cache_prefix: Vec, replacements_tried: usize, pin_fallback_used: bool, pending_route: Option<( @@ -28,6 +33,10 @@ impl From for ChildHostState { let route_runtime = setup.authority.runtime.clone(); Self { route_runtime, + fork_cache_checked: false, + fork_trial_first_response_logged: false, + fork_cache_inherited: None, + fork_cache_prefix: Vec::new(), replacements_tried: 0, pin_fallback_used: false, pending_route: None, @@ -237,7 +246,12 @@ impl Engine { let report_deadline_expired = reporting && deadline.is_some_and(|deadline| Instant::now() >= deadline); drop(timer); - job.project(&self.session.messages, job.steps()).await?; + job.project( + &self.session.messages, + job.steps(), + self.session.system_prompt.as_ref(), + ) + .await?; if job.authority.runtime.cancel_token.is_cancelled() { return job .finish( diff --git a/crates/tui/src/core/engine/preview.rs b/crates/tui/src/core/engine/preview.rs index 67fe132f83..257426ebc7 100644 --- a/crates/tui/src/core/engine/preview.rs +++ b/crates/tui/src/core/engine/preview.rs @@ -313,6 +313,7 @@ impl Engine { McpAccess::PassiveSnapshot, route_context.clone(), "", + prompt_context.goal_objective.as_deref(), ) .await; diff --git a/crates/tui/src/core/engine/preview/tests.rs b/crates/tui/src/core/engine/preview/tests.rs index 05e896c93d..7c8945acd0 100644 --- a/crates/tui/src/core/engine/preview/tests.rs +++ b/crates/tui/src/core/engine/preview/tests.rs @@ -641,6 +641,7 @@ async fn planned_route_builds_subagent_catalog_without_installed_client() { reasoning_effort_auto: planned.auto_controls_reasoning, }, "", + None, ) .await; assert!( @@ -2195,6 +2196,7 @@ async fn preview_tool_snapshot_has_no_mcp_or_event_side_effects() { reasoning_effort_auto: false, }, "", + None, ) .await; diff --git a/crates/tui/src/core/engine/tests/test_cases_08.rs b/crates/tui/src/core/engine/tests/test_cases_08.rs index f8f469da5c..13c5d6ae05 100644 --- a/crates/tui/src/core/engine/tests/test_cases_08.rs +++ b/crates/tui/src/core/engine/tests/test_cases_08.rs @@ -312,6 +312,103 @@ async fn injected_model_duplicate_reads_both_execute_and_close_both_tool_ids() { task.await.expect("engine task"); } +#[tokio::test] +async fn native_navigation_tracks_only_returned_references_in_the_real_model_request() { + use crate::llm_client::mock::{MockLlmClient, canned}; + + for expose in [false, true] { + let workspace = tempdir().expect("tempdir"); + fs::write( + workspace.path().join("nav.rs"), + "fn wanted() {\n let _ = \"PRIVATE_BODY\";\n}\n", + ) + .unwrap(); + let selection = if expose { + "return {selected: r.metadata.navigation ? r.metadata.navigation.symbols[0].read : null};" + } else { + "return {done: true};" + }; + let code = format!( + "const r = await tools.call('read', {{path: 'nav.rs', limit: 1}}); {selection}" + ); + let mock = std::sync::Arc::new(MockLlmClient::new(vec![ + canned::tool_call_turn( + "navigation-program", + "execute_tools", + &serde_json::json!({"code": code}).to_string(), + ), + canned::simple_text_turn("Navigation received."), + ])); + let client: crate::core::model_client::SharedModelClient = mock.clone(); + let (mut engine, _handle) = Engine::new_with_model_client( + deterministic_engine_config(workspace.path()), + &Config::default(), + client, + ); + let context = crate::tools::ToolContext::new(workspace.path()); + let mut registry = crate::tools::ToolRegistry::new(context); + registry.register(std::sync::Arc::new(crate::tools::file_tool::ReadTool)); + let mut tools = registry.to_api_tools_with_cache(true); + let mut compose = crate::tools::codemode::execute_tools_tool_definition(); + compose.defer_loading = Some(false); + tools.push(compose); + let surface = test_tool_surface(&engine, registry, Some(tools), AppMode::Agent); + let mut turn = crate::core::turn::TurnContext::new(8); + let (status, error) = engine.run_turn(&mut turn, surface, None, None).await; + assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}"); + let requests = mock.captured_requests(); + assert_eq!(requests.len(), 2); + let delivered = requests[1] + .messages + .iter() + .flat_map(|message| &message.content) + .find_map(|block| match block { + ContentBlock::ToolResult { + tool_use_id, + content, + .. + } if tool_use_id == "navigation-program" => Some(content), + _ => None, + }) + .expect("actual outer result in the second model request"); + let payload: serde_json::Value = serde_json::from_str(delivered).unwrap(); + assert!(payload["success"].as_bool().unwrap(), "{payload}"); + assert!( + !delivered.contains("PRIVATE_BODY"), + "an intermediate read is not model delivery" + ); + let state = serde_json::to_value(&engine.session.working_set).unwrap(); + let refs = state["delivered_code_refs"] + .as_array() + .cloned() + .unwrap_or_default(); + if expose { + let selected = &payload["body"]["return"]["selected"]; + assert_eq!( + selected["path"], "nav.rs", + "composition must return an actionable reference" + ); + assert_eq!( + refs, + vec![selected.clone()], + "record exactly what survived outbound projection" + ); + let mut restored = crate::working_set::WorkingSet::default(); + restored.rebuild_from_messages(&engine.session.messages, workspace.path()); + assert_eq!( + serde_json::to_value(restored).unwrap()["delivered_code_refs"], + serde_json::json!(refs), + "resume reconstructs delivered references from the logged conversation, not the physical cache" + ); + } else { + assert!( + refs.is_empty(), + "hidden program reads must not seed delivery state" + ); + } + } +} + #[tokio::test] async fn duplicate_raw_read_errors_each_touch_the_working_set() { use crate::llm_client::mock::{MockLlmClient, canned}; diff --git a/crates/tui/src/core/engine/tests/test_cases_09.rs b/crates/tui/src/core/engine/tests/test_cases_09.rs index c1e6c79558..26469221b5 100644 --- a/crates/tui/src/core/engine/tests/test_cases_09.rs +++ b/crates/tui/src/core/engine/tests/test_cases_09.rs @@ -1604,10 +1604,23 @@ fn only_the_stamped_approval_note_is_hidden_from_the_person() { #[test] fn core_primitives_and_todo_write_default_to_eager() { let always_load = HashSet::new(); - for core in ["read", "write", "edit", "bash", "agent", "todo_write"] { + for core in ["read", "write", "edit", "bash", "todo_write"] { assert!(!should_default_defer_tool(core, &always_load)); } - for searchable in ["File", "Bash", "Git", "Run", "tasks", "git_blame"] { + // Token diet: agent+workflow (21KB of schema) and get/update_goal + // (meaningless without an active goal) stay searchable by default. + for searchable in [ + "File", + "Bash", + "Git", + "Run", + "tasks", + "git_blame", + "agent", + "workflow", + "get_goal", + "update_goal", + ] { assert!(should_default_defer_tool(searchable, &always_load)); } } diff --git a/crates/tui/src/core/engine/tests/test_cases_10.rs b/crates/tui/src/core/engine/tests/test_cases_10.rs index 79c4f6512f..563ccc1210 100644 --- a/crates/tui/src/core/engine/tests/test_cases_10.rs +++ b/crates/tui/src/core/engine/tests/test_cases_10.rs @@ -1,19 +1,17 @@ #[test] fn default_active_contract_keeps_discovery_and_core_tools_eager() { - const EXPECTED_NATIVE: [&str; 13] = [ + // Token diet: agent+workflow (21KB of schema) and get/update_goal + // (meaningless without an active goal) stay searchable by default. + const EXPECTED_NATIVE: [&str; 9] = [ "read", "write", "edit", "file_search", "bash", - "agent", - "workflow", "todo_write", - "create_goal", - "get_goal", - "update_goal", "task_shell_wait", "load_skill", + "create_goal", ]; assert_eq!( default_active_native_tool_names(), @@ -53,15 +51,8 @@ fn default_active_contract_keeps_discovery_and_core_tools_eager() { #[test] fn non_yolo_mode_retains_default_defer_policy() { let always_load = HashSet::new(); - for core in [ - "read", - "write", - "edit", - "bash", - "agent", - "todo_write", - "load_skill", - ] { + // Token diet: agent stays searchable even on wide surfaces. + for core in ["read", "write", "edit", "bash", "todo_write", "load_skill"] { assert!(!should_default_defer_tool(core, &always_load)); } for searchable in [ @@ -518,6 +509,7 @@ async fn measure_production_mode_tool_catalogs() -> serde_json::Value { McpAccess::PassiveSnapshot, route, "", + None, ) .await; let active = build.surface.active.clone().unwrap_or_default(); diff --git a/crates/tui/src/core/engine/tests/test_cases_13.rs b/crates/tui/src/core/engine/tests/test_cases_13.rs index a09689c39b..4b9672d363 100644 --- a/crates/tui/src/core/engine/tests/test_cases_13.rs +++ b/crates/tui/src/core/engine/tests/test_cases_13.rs @@ -1,5 +1,3 @@ - - #[test] fn agent_mode_elevates_writes_without_granting_network() { // #273 elevated Agent mode's sandbox so `curl`, package managers, and @@ -721,6 +719,7 @@ async fn fork_state_block_reuses_the_snapshot_body() { }; let fork_context = crate::tools::subagent::SubAgentForkContext { messages: engine.messages_with_turn_metadata(), + live_header: crate::prompt_zones::new_live_header_cell(), structured_state_block: state.to_system_block(), work_source: Some(engine.todo_source()), }; @@ -937,6 +936,7 @@ async fn same_turn_fork_carries_the_updated_todo() { .to_system_block(); let fork_context = crate::tools::subagent::SubAgentForkContext { messages: engine.messages_with_turn_metadata(), + live_header: crate::prompt_zones::new_live_header_cell(), structured_state_block: stable_block.clone(), work_source: Some(engine.todo_source()), }; @@ -1544,4 +1544,4 @@ async fn sync_session_migrates_one_checkpoint_and_strips_its_system_carrier() { } run.abort(); -} \ No newline at end of file +} diff --git a/crates/tui/src/core/engine/tests/test_cases_18.rs b/crates/tui/src/core/engine/tests/test_cases_18.rs index 64847961ed..916da33481 100644 --- a/crates/tui/src/core/engine/tests/test_cases_18.rs +++ b/crates/tui/src/core/engine/tests/test_cases_18.rs @@ -1183,6 +1183,7 @@ readline.createInterface({ input: process.stdin }).on('line', async line => { McpAccess::Connect, route, "", + None, ); tokio::pin!(build); std::future::poll_fn(|cx| { @@ -1703,6 +1704,7 @@ readline.createInterface({input:process.stdin}).on('line', line => { McpAccess::Connect, route, "discovery", + None, ) .await; (engine, build.surface, tmp, model) diff --git a/crates/tui/src/core/engine/tool_catalog.rs b/crates/tui/src/core/engine/tool_catalog.rs index 95dc2db2db..c41acb1e6a 100644 --- a/crates/tui/src/core/engine/tool_catalog.rs +++ b/crates/tui/src/core/engine/tool_catalog.rs @@ -51,16 +51,25 @@ pub(crate) fn is_tool_search_tool(name: &str) -> bool { #[rustfmt::skip] pub(crate) const DEFAULT_ACTIVE_NATIVE_TOOLS: &[&str] = &[ // Core work controls are eager; specialized tools stay searchable. - "read", "write", "edit", "file_search", "bash", "agent", "workflow", "todo_write", + "read", "write", "edit", "file_search", "bash", "todo_write", // Continuation instructions require these controls. Hiding them behind // discovery leaves a model unable to stop the work it was asked to run. - "create_goal", "get_goal", "update_goal", "task_shell_wait", + "task_shell_wait", // The pinned `## Skills` index tells the model to call `load_skill`, so // the tool has to be on the wire for that instruction to be true. Behind // `tool_search` it cost a discovery hop plus a `change:tool_surface` // re-pin every time a skill was used, against ~134 pinned bytes to have // it eager beside the index the prefix already carries. "load_skill", + // create_goal is eager: it is the entry point. Token diet: agent and + // workflow stay deferred despite being core-adjacent — their schemas + // cost ~21KB/turn and BM25 discovery (`tool_search`) finds them + // reliably when delegation is actually wanted (cued-delegation probe: + // discovered and executed; uncued sessions behave identically). + // get/update_goal cannot succeed without an active goal, so they ride + // `always_load` only while one exists (see the catalog build in + // `engine.rs`) instead of taxing every goalless turn. + "create_goal", ]; const CORE_ACTION_TOOL_FALLBACKS: &[CoreActionToolFallback] = &[ @@ -950,7 +959,12 @@ fn discover_tools_with_regex( let regex = compile_user_regex(query) .map_err(|err| ToolError::invalid_input(format!("Invalid regex query: {err}")))?; - let mut matches = Vec::new(); + // Name matches outrank haystack matches: a regex that hits the tool's + // own name is the strongest possible signal, and catalog (alphabetical) + // order otherwise lets an incidental mention evict the literal target + // from the bounded activation cache. + let mut name_matches = Vec::new(); + let mut hay_matches = Vec::new(); let mut scratch = ToolSearchScratch::default(); for tool in catalog { // tool_search loads definitions omitted from the current request. An @@ -960,15 +974,19 @@ fn discover_tools_with_regex( if !tool.defer_loading.unwrap_or(false) || is_tool_search_tool(&tool.name) { continue; } + // Lowercased, like the haystack path below: `Agent` must hit `agent`. + if regex.is_match(&tool.name.to_lowercase()) { + name_matches.push(tool.name.clone()); + continue; + } scratch.load(tool); if regex.is_match(&scratch.hay) { - matches.push(tool.name.clone()); - } - if matches.len() >= max_results { - break; + hay_matches.push(tool.name.clone()); } } - Ok(matches) + name_matches.extend(hay_matches); + name_matches.truncate(max_results); + Ok(name_matches) } fn discover_tools_with_bm25_like(catalog: &[Tool], query: &str, max_results: usize) -> Vec { diff --git a/crates/tui/src/core/engine/tool_catalog/tests.rs b/crates/tui/src/core/engine/tool_catalog/tests.rs index 4542d5b0ad..4ad844a2b4 100644 --- a/crates/tui/src/core/engine/tool_catalog/tests.rs +++ b/crates/tui/src/core/engine/tool_catalog/tests.rs @@ -97,6 +97,9 @@ fn published_synthetic_names_agree_with_the_synthetic_predicate() { #[test] fn first_turn_surface_is_stable_across_plan_work_and_operate() { + // Token diet: agent+workflow (21KB of schema) and get/update_goal + // (meaningless without an active goal) stay deferred; discovery via + // tool_search restores them on demand. assert_eq!( DEFAULT_ACTIVE_NATIVE_TOOLS, &[ @@ -105,22 +108,15 @@ fn first_turn_surface_is_stable_across_plan_work_and_operate() { "edit", "file_search", "bash", - "agent", - "workflow", "todo_write", - "create_goal", - "get_goal", - "update_goal", "task_shell_wait", - "load_skill" + "load_skill", + "create_goal" ] ); let expected = [ - "agent", "bash", "create_goal", - "get_goal", - "update_goal", "edit", "file_search", "load_skill", @@ -128,7 +124,6 @@ fn first_turn_surface_is_stable_across_plan_work_and_operate() { "task_shell_wait", "todo_write", "tool_search", - "workflow", "write", ] .into_iter() @@ -347,7 +342,7 @@ fn unknown_and_wildcard_allowlists_keep_mcp_startup() { } #[test] -fn compact_surface_keeps_agent_and_workflow_eager() { +fn compact_surface_keeps_agent_and_workflow_deferred() { let catalog = build_model_tool_catalog_with_surface( [ "read", @@ -373,12 +368,30 @@ fn compact_surface_keeps_agent_and_workflow_eager() { .iter() .find(|definition| definition.name == name) .and_then(|definition| definition.defer_loading), - Some(false), + Some(true), "{name}" ); } } +#[test] +fn deferred_core_tools_restore_through_always_load() { + // The diet's escape hatch: discovery/activation and the goal-aware + // catalog build in `engine.rs` re-eager exactly these names. + let mut catalog = ["agent", "workflow", "get_goal", "update_goal", "read"] + .into_iter() + .map(tool) + .collect::>(); + let always_load = ["agent", "workflow", "get_goal", "update_goal"] + .into_iter() + .map(str::to_string) + .collect::>(); + apply_native_tool_deferral(&mut catalog, &always_load); + for definition in &catalog { + assert_eq!(definition.defer_loading, Some(false), "{}", definition.name); + } +} + /// The per-tool Registry paragraph is gone; the catalog builder must leave the /// shell tool's description exactly as the registry produced it, so the KV /// prefix stays byte-stable and there is one Registry authority (the prompt). diff --git a/crates/tui/src/core/engine/turn_loop/model_step.rs b/crates/tui/src/core/engine/turn_loop/model_step.rs index e13f8440ca..cb3c98eaba 100644 --- a/crates/tui/src/core/engine/turn_loop/model_step.rs +++ b/crates/tui/src/core/engine/turn_loop/model_step.rs @@ -18,7 +18,14 @@ impl Engine { } = prepared; let child_job = self.child_job(); if let Some(job) = child_job.as_ref() { - if let Err(error) = job.project(&self.session.messages, job.steps()).await { + if let Err(error) = job + .project( + &self.session.messages, + job.steps(), + stream_request.system.as_ref(), + ) + .await + { return PhaseResult::Return(( TurnOutcomeStatus::Failed, Some(format!( @@ -103,6 +110,11 @@ impl Engine { .saturating_add(transport_retries.load(std::sync::atomic::Ordering::Relaxed)); let stream = match stream_result { Ok(s) => { + // Physical-cache population, nested execution and result + // projection alone do not establish model delivery. + self.session + .working_set + .observe_navigation_delivery(&stream_request.messages); if let Some(job) = child_job.as_ref() { job.provider_responded(); } @@ -430,6 +442,38 @@ impl Engine { job.response_settled(!response_stream_failed); } turn.add_parent_usage(&usage); + if let Some(child) = self.child_host.as_mut() + && child.fork_cache_checked + && !child.fork_trial_first_response_logged + && !child_reporting + { + child.fork_trial_first_response_logged = true; + #[allow(clippy::print_stderr)] + if crate::prompt_zones::fork_trial_diag_enabled() { + eprintln!( + "{}", + serde_json::json!({ + "codewhale_fork_trial": "first_response", + "agent_id": child.authority.owner_agent_id, + "inherited": child.fork_cache_inherited.is_some(), + "usage_reported": usage_reported, + "stream_failed": response_stream_failed, + "input": usage.input_tokens, + "hit": usage.prompt_cache_hit_tokens, + "miss": usage.prompt_cache_miss_tokens, + "system_bytes": serde_json::to_vec(&stream_request.system).ok().map(|bytes| bytes.len()), + "tools_bytes": serde_json::to_vec(&stream_request.tools).ok().map(|bytes| bytes.len()), + "history_bytes": serde_json::to_vec(&stream_request.messages).ok().map(|bytes| bytes.len()), + }) + ); + } + } + if self.child_host.is_none() + && let Ok(mut cell) = self.live_header.lock() + && let Some(snapshot) = cell.as_mut() + { + snapshot.last_hit_tokens = usage.prompt_cache_hit_tokens; + } turn.note_parent_prompt_len(self.session.messages.len()); self.session.latest_parent_input_tokens = turn.latest_parent_input_tokens; if usage_reported { diff --git a/crates/tui/src/core/engine/turn_loop/preparation.rs b/crates/tui/src/core/engine/turn_loop/preparation.rs index ecc7e1b304..2adb3213c9 100644 --- a/crates/tui/src/core/engine/turn_loop/preparation.rs +++ b/crates/tui/src/core/engine/turn_loop/preparation.rs @@ -220,11 +220,115 @@ impl Engine { .await; } - let active_tools = active_tools_for_request( + let mut active_tools = active_tools_for_request( &progress.tool_catalog, &progress.active_tool_names, strict_tool_mode, ); + let fork_route = client.effective_route_envelope(&self.session.model, chrono::Utc::now()); + let fork_receipt = client.cache_route_receipt(); + let fork_job = self.child_job(); + let child_reporting = self.child_report(); + let mut fork_header_changed = false; + if let Some(child) = self.child_host.as_mut() { + if child.fork_cache_inherited.as_ref().is_some_and(|snapshot| { + client.wire_format() + != Some(codewhale_config::provider::WireFormat::ChatCompletions) + || client.model() != self.session.model + || fork_receipt.is_none() + || snapshot.route_receipt.as_ref() != fork_receipt.as_ref() + || snapshot.model != fork_route.model + || snapshot.provider != fork_route.provider + || snapshot.provider_identity != fork_route.provider_identity + || snapshot.endpoint_fingerprint != fork_route.endpoint_fingerprint + }) { + child.fork_cache_inherited = None; + child.fork_cache_prefix.clear(); + self.session.system_prompt = Some(child.system_prompt.clone()); + fork_header_changed = true; + } + if !child.fork_cache_checked && turn.step == 0 && !child_reporting { + child.fork_cache_checked = true; + let snapshot = fork_job + .as_ref() + .filter(|job| job.fork_context) + .and_then(|_| child.authority.runtime.fork_context.as_ref()) + .and_then(|context| { + context + .live_header + .lock() + .ok() + .and_then(|cell| cell.clone()) + }); + if client.model() == self.session.model + && let Some(wire) = client.wire_format() + { + let decision = crate::prompt_zones::resolve_fork_inherit( + crate::prompt_zones::fork_inherit_enabled(), + snapshot.as_ref(), + &fork_route.model, + fork_route.provider, + &fork_route.provider_identity, + wire, + fork_route.endpoint_fingerprint.as_deref(), + fork_receipt.as_ref(), + active_tools.as_deref().unwrap_or_default(), + self.session.messages.len(), + ); + #[allow(clippy::print_stderr)] + if crate::prompt_zones::fork_trial_diag_enabled() { + eprintln!( + "{}", + serde_json::json!({ + "codewhale_fork_trial": "decision", + "agent_id": child.authority.owner_agent_id, + "inherited": decision.inherit, + "reason": decision.reason, + "prefix": decision.prefix_tools.len(), + "parent_n": snapshot.as_ref().map_or(0, |value| value.active_names.len()), + }) + ); + } + if decision.inherit + && let Some(snapshot) = snapshot + { + self.session.system_prompt = snapshot.system.clone(); + child.fork_cache_prefix = decision + .prefix_tools + .iter() + .map(|tool| tool.name.clone()) + .collect(); + let prefix = &child.fork_cache_prefix; + progress.tool_catalog.sort_by_key(|tool| { + prefix + .iter() + .position(|name| name == &tool.name) + .unwrap_or(prefix.len()) + }); + child.fork_cache_inherited = Some(snapshot); + fork_header_changed = true; + } + } + } + if let Some(tools) = active_tools.as_mut() { + let prefix = &child.fork_cache_prefix; + tools.sort_by_key(|tool| { + prefix + .iter() + .position(|name| name == &tool.name) + .unwrap_or(prefix.len()) + }); + } + } + if fork_header_changed { + self.session.last_system_prompt_hash = + Some(system_prompt_hash(self.session.system_prompt.as_ref())); + self.session.pending_prefix_change_reason = Some("fork-cache".to_string()); + self.emit_session_updated().await; + if self.cancel_token.is_cancelled() { + return PhaseResult::Return((TurnOutcomeStatus::Interrupted, None)); + } + } // The report already has a bounded text-only captured request. It // cannot authorize an extra paid summarizer or mutate its evidence. if !self.child_report() { @@ -657,6 +761,61 @@ impl Engine { // first call) so we can resend it on a transparent retry below // when the wire dies before any content was streamed (#103). let stream_request = request; + let cache_receipt = client.cache_route_receipt(); + if self.child_host.is_none() + && (client.wire_format() + != Some(codewhale_config::provider::WireFormat::ChatCompletions) + || client.model() != stream_request.model + || cache_receipt.is_none()) + && let Ok(mut cell) = self.live_header.lock() + { + *cell = None; + } + + if self.child_host.is_none() + && crate::prompt_zones::fork_inherit_enabled() + && client.wire_format() == Some(codewhale_config::provider::WireFormat::ChatCompletions) + && client.model() == stream_request.model + && cache_receipt.is_some() + && let Some(tools_json) = crate::prompt_zones::wire_tool_catalog_json( + stream_request.tools.as_deref().unwrap_or_default(), + ) + && let Ok(mut cell) = self.live_header.lock() + { + let route = client.effective_route_envelope(&stream_request.model, chrono::Utc::now()); + let prior = cell.as_ref().filter(|old| { + old.model == route.model + && old.provider == route.provider + && old.provider_identity == route.provider_identity + && old.endpoint_fingerprint == route.endpoint_fingerprint + }); + let carried = crate::prompt_zones::carry_hot_forward( + prior, + &stream_request.system, + &tools_json, + &stream_request.messages, + cache_receipt.as_ref(), + ); + *cell = Some(crate::prompt_zones::LiveHeaderSnapshot { + system: stream_request.system.clone(), + tools_json, + active_names: stream_request + .tools + .as_deref() + .unwrap_or_default() + .iter() + .map(|tool| tool.name.clone()) + .collect(), + model: route.model, + provider: route.provider, + provider_identity: route.provider_identity, + endpoint_fingerprint: route.endpoint_fingerprint, + route_receipt: cache_receipt, + history: stream_request.messages.clone(), + last_hit_tokens: carried, + }); + } + // Superfast Decision Gate (shadow mode, off by default). When // SUPERFAST_ENABLED is set, this spawns a detached task that asks // a small System One decision model about the user's turn and only diff --git a/crates/tui/src/core/runtime_contract/model.rs b/crates/tui/src/core/runtime_contract/model.rs index 2a550b8123..f92c5a9f61 100644 --- a/crates/tui/src/core/runtime_contract/model.rs +++ b/crates/tui/src/core/runtime_contract/model.rs @@ -17,6 +17,12 @@ use codewhale_models::{MessageRequest, MessageResponse}; pub trait ModelClient: Send + Sync { fn provider_name(&self) -> &str; fn model(&self) -> &str; + fn wire_format(&self) -> Option { + None + } + fn cache_route_receipt(&self) -> Option { + None + } /// Concrete route base for billing classification, when this client can /// prove one. Provider-neutral injected clients leave it unknown. fn billing_base_url(&self) -> Option<&str> { @@ -76,6 +82,13 @@ where LlmClient::model(self) } + fn wire_format(&self) -> Option { + LlmClient::wire_format(self) + } + fn cache_route_receipt(&self) -> Option { + LlmClient::cache_route_receipt(self) + } + fn billing_base_url(&self) -> Option<&str> { LlmClient::billing_base_url(self) } diff --git a/crates/tui/src/external_credentials.rs b/crates/tui/src/external_credentials.rs index 78256d581d..d5c50d448a 100644 --- a/crates/tui/src/external_credentials.rs +++ b/crates/tui/src/external_credentials.rs @@ -138,7 +138,7 @@ pub(crate) fn read_codewhale_owned_to_string(path: &Path) -> Result io::Result { +pub(crate) fn open_secure_regular_file(path: &Path, require_owner_only: bool) -> io::Result { use std::ffi::CString; use std::os::fd::FromRawFd; use std::os::unix::ffi::OsStrExt; @@ -243,7 +243,7 @@ fn open_secure_regular_file(path: &Path, require_owner_only: bool) -> io::Result } #[cfg(windows)] -fn open_secure_regular_file(path: &Path, require_owner_only: bool) -> io::Result { +pub(crate) fn open_secure_regular_file(path: &Path, require_owner_only: bool) -> io::Result { use std::ffi::OsString; use std::os::windows::ffi::OsStringExt; use std::os::windows::fs::{MetadataExt, OpenOptionsExt}; @@ -632,7 +632,10 @@ fn reject_windows_reparse_components(path: &Path) -> io::Result<()> { } #[cfg(not(any(unix, windows)))] -fn open_secure_regular_file(_path: &Path, _require_owner_only: bool) -> io::Result { +pub(crate) fn open_secure_regular_file( + _path: &Path, + _require_owner_only: bool, +) -> io::Result { Err(io::Error::new( io::ErrorKind::Unsupported, "secure external credential reads are unsupported on this platform", diff --git a/crates/tui/src/hooks/executor.rs b/crates/tui/src/hooks/executor.rs index 60656fe727..10c87767ed 100644 --- a/crates/tui/src/hooks/executor.rs +++ b/crates/tui/src/hooks/executor.rs @@ -6786,6 +6786,10 @@ command = "echo project" // here would never fire. This table is checked in both directions, so // a rename fails on the missing entry and a classifier change fails on // the mismatched category. + // Token diet: agent+workflow and get/update_goal are deferred by + // default (discoverable via tool_search), so they left the eager + // table; their classifications are pinned with the other + // deferred tools below instead. const EXPECTED: &[(&str, &str)] = &[ ("read", "safe"), ("write", "file_write"), @@ -6794,15 +6798,8 @@ command = "echo project" // keeps the classification it already had as a deferred tool. ("file_search", "other"), ("bash", "shell"), - // The router itself touches nothing a hook needs to gate. - ("agent", "other"), - ("workflow", "other"), ("todo_write", "safe"), - // Goal controls retain their existing hook classification when - // promoted from deferred discovery to the eager catalog. ("create_goal", "other"), - ("get_goal", "other"), - ("update_goal", "other"), ("task_shell_wait", "other"), // Reads the skill registry, not caller-named paths, so it keeps // the classification it already had as a deferred tool. Making it @@ -6828,6 +6825,14 @@ command = "echo project" ); } + // Deferred by default, still classified: the router touches + // nothing a hook needs to gate, and the goal controls keep + // their classification when promoted to eager. + assert_eq!(tool_category_for("agent", None), "other"); + assert_eq!(tool_category_for("workflow", None), "other"); + assert_eq!(tool_category_for("get_goal", None), "other"); + assert_eq!(tool_category_for("update_goal", None), "other"); + // The shell surface. assert_eq!(tool_category_for("Bash", None), "shell"); // Retained: shell.rs stamps this for the shell_env event. diff --git a/crates/tui/src/llm_client/mod.rs b/crates/tui/src/llm_client/mod.rs index bdf2d0a3f3..8b76a711cb 100644 --- a/crates/tui/src/llm_client/mod.rs +++ b/crates/tui/src/llm_client/mod.rs @@ -58,6 +58,13 @@ pub trait LlmClient: Send + Sync { /// Returns the model identifier being used fn model(&self) -> &str; + fn wire_format(&self) -> Option { + None + } + fn cache_route_receipt(&self) -> Option { + None + } + /// Creates a non-streaming message completion fn create_message( &self, diff --git a/crates/tui/src/plugins/registry.rs b/crates/tui/src/plugins/registry.rs index 1ddadf3840..84749be080 100644 --- a/crates/tui/src/plugins/registry.rs +++ b/crates/tui/src/plugins/registry.rs @@ -1196,7 +1196,7 @@ pub(crate) fn open_existing_regular_file( } #[cfg(unix)] -fn validate_opened_regular_file(path: &Path, file: &fs::File) -> Result<(), String> { +pub(crate) fn validate_opened_regular_file(path: &Path, file: &fs::File) -> Result<(), String> { use std::os::unix::fs::MetadataExt as _; let metadata = file @@ -1212,7 +1212,7 @@ fn validate_opened_regular_file(path: &Path, file: &fs::File) -> Result<(), Stri } #[cfg(windows)] -fn validate_opened_regular_file(path: &Path, file: &fs::File) -> Result<(), String> { +pub(crate) fn validate_opened_regular_file(path: &Path, file: &fs::File) -> Result<(), String> { const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; let metadata = file .metadata() @@ -1232,7 +1232,7 @@ fn validate_opened_regular_file(path: &Path, file: &fs::File) -> Result<(), Stri } #[cfg(all(not(unix), not(windows)))] -fn validate_opened_regular_file(path: &Path, file: &fs::File) -> Result<(), String> { +pub(crate) fn validate_opened_regular_file(path: &Path, file: &fs::File) -> Result<(), String> { let metadata = file .metadata() .map_err(|e| format!("failed to inspect opened {}: {e}", path.display()))?; diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index aa45a30993..f8eead2dac 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -16410,6 +16410,7 @@ impl RuntimeThreadManager { persist_services_enabled: false, hook_executor: Some(Arc::clone(&thread_hooks)), handle_store: crate::tools::handle::new_shared_handle_store(), + code_observations: crate::tools::handle::new_shared_handle_store(), rlm_sessions: crate::rlm::session::new_shared_rlm_session_store(), media_originals_dir: crate::media_originals::default_store_dir(), }, diff --git a/crates/tui/src/sandbox/read_guard.rs b/crates/tui/src/sandbox/read_guard.rs index 603ab67bec..cb2bb680aa 100644 --- a/crates/tui/src/sandbox/read_guard.rs +++ b/crates/tui/src/sandbox/read_guard.rs @@ -295,7 +295,7 @@ impl ReadDenylist { let resolved = canonicalize_best_effort(requested); let via_symlink = resolved != literal; - for candidate in [&literal, &resolved] { + for candidate in std::iter::once(&literal).chain(via_symlink.then_some(&resolved)) { if self.deny_env_files && is_env_file(candidate) { return Err(ReadDenial { requested: requested.to_path_buf(), @@ -780,6 +780,10 @@ fn fold_component(component: &std::ffi::OsStr) -> OsString { /// (`Path::starts_with` is already component-wise; the case folding is what /// forces the manual walk.) fn path_is_within(candidate: &Path, root: &Path) -> bool { + if !cfg!(any(target_os = "macos", target_os = "windows")) { + return candidate.starts_with(root); + } + let mut root_components = root.components().map(|c| fold_component(c.as_os_str())); let mut candidate_components = candidate .components() diff --git a/crates/tui/src/skills/mod.rs b/crates/tui/src/skills/mod.rs index bd9dfd6d85..6e2e7a4206 100644 --- a/crates/tui/src/skills/mod.rs +++ b/crates/tui/src/skills/mod.rs @@ -1643,7 +1643,7 @@ pub fn render_available_skills_context_for_workspace_with_mode_and_plugins( } /// Progressive-disclosure contract: the model sees a bounded page of skill -/// names, descriptions, and paths, then uses `load_skill` for the complete +/// names and descriptions, then uses `load_skill` for the complete /// catalogue or a specific `SKILL.md` body. /// /// Test-only single-directory variant. Production callers scan the complete @@ -1776,65 +1776,10 @@ fn replace_prompt_path_root(text: &str, root: &str, replacement: &str) -> String out } -/// Render a skill path without leaking private absolute paths into the -/// system-prompt prefix (#4632): workspace skills become workspace-relative, -/// home-dir skills become `~/…`, and anything else is reduced to its trailing -/// components so the prefix stays free of user-identifying absolute paths. -/// Skill paths in the prompt are consumed by the model as text, not by the -/// platform's shell, so normalize Windows separators to forward slashes: -/// the catalog renders identically on every platform (#5473). -fn prompt_display(path: &Path) -> String { - path.display() - .to_string() - .replace(std::path::MAIN_SEPARATOR, "/") -} - -fn privacy_safe_skill_path(path: &Path, workspace: &Path) -> String { - if let Ok(rel) = path.strip_prefix(workspace) { - return prompt_display(rel); - } - if let Some(home) = crate::config::effective_home_dir() - && let Ok(rel) = path.strip_prefix(&home) - { - return format!("~/{}", prompt_display(rel)); - } - match (path.parent().and_then(Path::file_name), path.file_name()) { - (Some(dir), Some(file)) => { - format!("…/{}/{}", dir.to_string_lossy(), file.to_string_lossy()) - } - _ => path - .file_name() - .map(|file| file.to_string_lossy().into_owned()) - .unwrap_or_else(|| "SKILL.md".to_string()), - } -} - -fn path_is_within_root(path: &Path, root: &Path) -> bool { - if path.starts_with(root) { - return true; - } - let Some(canonical_path) = fs::canonicalize(path).ok() else { - return false; - }; - let Some(canonical_root) = fs::canonicalize(root).ok() else { - return false; - }; - canonical_path.starts_with(canonical_root) -} - -fn prompt_skill_path( - path: &Path, - workspace: &Path, - configured_skills_root: Option<&Path>, -) -> Option { - if let Some(root) = configured_skills_root - && path_is_within_root(path, root) - { - return None; - } - Some(privacy_safe_skill_path(path, workspace)) -} - +// Token diet: the ambient index carries names + descriptions only, never +// on-disk paths — `load_skill` resolves names through the registry. (The +// former privacy-safe path renderers lived here; deleting them also deletes +// the #4632/#5473 surface they existed to defend.) #[cfg(test)] fn render_skills_block(registry: &SkillRegistry, locale: &str, workspace: &Path) -> Option { render_skills_block_with_configured_root( @@ -1984,17 +1929,16 @@ Skills are optional instruction packs. This index exposes routing metadata; bodi // instructions or consuming prompt budget. .filter(|skill| skill.invocation.model_invocable()) .map(|skill| { - // Native skills expose the real on-disk path captured at discovery. - // Plugin skills expose only their reviewed snapshot identity so the - // model cannot bypass the content-bound trust receipt via a mutable - // source path. Paths render privacy-safe (workspace-relative or - // ~/…) so the prompt prefix never embeds absolute user paths - // (#4632). A caller-provided skills root omits its physical path - // because that root may change per session; load_skill still - // resolves the stable skill name through the internal registry. - let display_path = prompt_skill_path(&skill.path, workspace, configured_skills_root); + // Token diet: native skills expose no on-disk path. `load_skill` + // resolves the stable skill name through the internal registry, + // so the path only invited the slower read-the-file bypass the + // tool description already steers away from — at ~50 bytes a row, + // every turn, in the cache-pinned prefix. Plugin skills keep + // their reviewed snapshot identity: the trust receipt must stay + // visible so the model cannot mistake a snapshot for a mutable + // source path. let source = match &skill.source { - SkillSource::Native => display_path.map(|path| format!("file: {path}")), + SkillSource::Native => None, SkillSource::Plugin { plugin_id, plugin_name, diff --git a/crates/tui/src/skills/tests.rs b/crates/tui/src/skills/tests.rs index 7d8a98b542..364e518891 100644 --- a/crates/tui/src/skills/tests.rs +++ b/crates/tui/src/skills/tests.rs @@ -326,7 +326,7 @@ fn prompt_warning_sanitizer_handles_non_utf8_configured_roots() { } #[test] -fn render_available_skills_context_lists_paths_and_usage() { +fn render_available_skills_context_lists_names_and_usage() { let tmpdir = TempDir::new().unwrap(); create_skill_dir( &tmpdir, @@ -337,17 +337,16 @@ fn render_available_skills_context_lists_paths_and_usage() { let rendered = crate::skills::render_available_skills_context(&tmpdir.path().join("skills")) .expect("skill context"); - // #4632: paths render relative to the skills base dir (privacy-safe), - // so the assertion checks the workspace-relative form. - let expected_path = super::prompt_display(&std::path::Path::new("test-skill").join("SKILL.md")); - + // Token diet: names + descriptions only. `load_skill` resolves the + // name through the registry, so no on-disk path is rendered at all + // (#4632's surface is gone with the paths). assert!(rendered.contains("## Skills")); assert!(rendered.contains("- test-skill: A test skill")); assert!(rendered.contains("call `load_skill` with that exact name before starting the work")); assert!(rendered.contains("do not expand tool, approval, or trust authority")); assert!( - rendered.contains(&expected_path), - "expected path {expected_path:?} not in rendered output" + !rendered.contains("test-skill/SKILL.md"), + "no on-disk paths in the index" ); assert!(!rendered.contains(tmpdir.path().to_str().unwrap_or("/nonexistent"))); assert!(rendered.contains("### Usage")); @@ -397,12 +396,12 @@ fn workspace_prompt_omits_disabled_skills_without_configured_directory() { } #[test] -fn render_available_skills_context_uses_real_dir_name_not_frontmatter_name() { - // Regression: when a community-installed or manually-placed skill +fn render_available_skills_context_resolves_by_name_without_invented_paths() { + // Regression (pathless form): when a community-installed skill // lives in a directory whose name differs from its frontmatter - // `name`, the rendered prompt must point to the real on-disk file - // path, not //SKILL.md (which does - // not exist). + // `name`, the row carries the registry name and no path at all — + // `load_skill` resolves the name to the real on-disk file, so no + // caller can reconstruct a stale /SKILL.md path. let tmpdir = TempDir::new().unwrap(); create_skill_dir( &tmpdir, @@ -413,18 +412,29 @@ fn render_available_skills_context_uses_real_dir_name_not_frontmatter_name() { let rendered = crate::skills::render_available_skills_context(&tmpdir.path().join("skills")) .expect("skill context"); - // #4632: rendered relative to the skills base dir; the regression - // intent (real dir name, not frontmatter name) is unchanged. - let real_path = super::prompt_display(&std::path::Path::new("weird-dir-name").join("SKILL.md")); - let stale_path = super::prompt_display(&std::path::Path::new("friendly-name").join("SKILL.md")); - assert!( - rendered.contains(&real_path), - "expected real on-disk path {real_path:?} in rendered output, got:\n{rendered}" + rendered.contains("- friendly-name: drift case"), + "registry-name row present, got:\n{rendered}" + ); + let (index, warnings) = rendered + .split_once("\n### Skill load warnings\n") + .expect("the real name/directory mismatch must remain visible"); + assert!( + !index.contains("weird-dir-name/SKILL.md"), + "no dir-derived path in the index:\n{index}" + ); + assert!( + warnings.contains("weird-dir-name/SKILL.md: frontmatter name `friendly-name` differs from directory `weird-dir-name`"), + "the warning must identify the real source:\n{warnings}" + ); + let registry = super::SkillRegistry::discover(&tmpdir.path().join("skills")); + assert_eq!( + registry.get("friendly-name").expect("name lookup").path, + tmpdir.path().join("skills/weird-dir-name/SKILL.md") ); assert!( - !rendered.contains(&stale_path), - "rendered output must not invent a path under the frontmatter name:\n{rendered}" + !rendered.contains("friendly-name/SKILL.md"), + "no frontmatter-derived path:\n{rendered}" ); } @@ -2162,9 +2172,7 @@ fn configured_skill_prompt_uses_a_stable_root_in_entries_and_warnings() { .expect("configured skill context"); assert!(rendered.contains("- visual-design: Design assets\n")); - assert!(rendered.contains( - "- workspace-skill: Workspace skill (file: .claude/skills/workspace-skill/SKILL.md)" - )); + assert!(rendered.contains("- workspace-skill: Workspace skill\n")); assert!( rendered .contains("in /visual-design/SKILL.md is not a safe command name") @@ -2174,7 +2182,7 @@ fn configured_skill_prompt_uses_a_stable_root_in_entries_and_warnings() { } #[test] -fn default_workspace_skill_prompt_preserves_its_discoverable_path() { +fn default_workspace_skill_prompt_omits_paths_in_favour_of_load_skill() { let _env_lock = crate::test_support::lock_test_env(); super::clear_skill_discovery_cache(); let tmpdir = TempDir::new().unwrap(); @@ -2205,9 +2213,7 @@ fn default_workspace_skill_prompt_preserves_its_discoverable_path() { ) .expect("workspace skill context"); - assert!(rendered.contains( - "- workspace-skill: Workspace skill (file: .agents/skills/workspace-skill/SKILL.md)" - )); + assert!(rendered.contains("- workspace-skill: Workspace skill\n")); } #[test] diff --git a/crates/tui/src/tools/codemode.rs b/crates/tui/src/tools/codemode.rs index acde966b8d..dd6184bb7e 100644 --- a/crates/tui/src/tools/codemode.rs +++ b/crates/tui/src/tools/codemode.rs @@ -1524,6 +1524,97 @@ mod tests { } } + #[tokio::test] + async fn native_navigation_resolves_real_source_and_refuses_stale_or_escaped_reads() { + let dir = tempfile::tempdir().unwrap(); + let source = "const LURE: &str = r#\"\nfn forged() {}\n\"#;\npub fn wanted() -> &'static str {\n \"NAV_BODY\"\n}\nfn sibling() {}\n"; + std::fs::write(dir.path().join("nav.rs"), source).unwrap(); + let context = ToolContext::new(dir.path()); + let registry = ToolRegistryBuilder::new() + .with_tool(Arc::new(ReadTool)) + .build(context.clone()); + let code = "const r = await tools.call('read', {path: 'nav.rs', limit: 1}); return r.metadata.navigation || null;"; + let result = execute_tools_tool(&json!({"code": code}), ®istry, &context) + .await + .unwrap(); + assert!(result.success, "{}", result.content); + let body: Value = serde_json::from_str(&result.content).unwrap(); + let navigation = &body["body"]["return"]; + assert_eq!( + navigation["producer"], "rust_syntax_v1", + "read must publish revision-qualified syntax evidence" + ); + let symbols = navigation["symbols"].as_array().unwrap(); + assert!( + !symbols.iter().any(|symbol| symbol["name"] == "forged"), + "a function-looking raw string is not a declaration" + ); + let reference = symbols + .iter() + .find(|symbol| symbol["name"] == "wanted") + .unwrap()["read"] + .clone(); + assert_eq!(reference["path"], "nav.rs"); + assert_eq!(reference["offset"], 4); + assert_eq!(reference["limit"], 3); + let wanted = ReadTool.execute(reference.clone(), &context).await.unwrap(); + assert!( + wanted + .content + .starts_with("pub fn wanted() -> &'static str {\n \"NAV_BODY\"\n}"), + "{}", + wanted.content + ); + assert!( + !wanted + .content + .split("\n\n[") + .next() + .unwrap() + .contains("fn sibling") + ); + + let child = context.clone().with_state_namespace("navigation-child"); + let child_read = ReadTool.execute(reference.clone(), &child).await.unwrap(); + assert_eq!( + child_read.metadata.as_ref().unwrap()["navigation"], + *navigation, + "shared physical knowledge is revision-stable, not parent delivery state" + ); + let child_workspace = dir.path().join("child"); + std::fs::create_dir(&child_workspace).unwrap(); + let restricted = + ToolContext::new(&child_workspace).with_runtime_services(context.runtime.clone()); + let mut escaped = reference.clone(); + escaped["path"] = json!("../nav.rs"); + assert!( + matches!( + ReadTool.execute(escaped, &restricted).await, + Err(ToolError::PathEscape { .. }) + ), + "a warmed shared cache must not grant workspace access" + ); + + std::fs::write( + dir.path().join("nav.rs"), + source.replace("NAV_BODY", "NAV_EDIT"), + ) + .unwrap(); + let stale = ReadTool + .execute(reference, &child) + .await + .expect_err("same-size external edit must invalidate the reference"); + assert!(stale.to_string().contains("stale"), "{stale}"); + let refreshed = ReadTool + .execute(json!({"path": "nav.rs", "limit": 1}), &child) + .await + .unwrap(); + assert_ne!( + refreshed.metadata.as_ref().unwrap()["navigation"]["source_revision"], + navigation["source_revision"] + ); + } + #[tokio::test] async fn program_composes_nested_read_and_returns_receipt() { let (_dir, note) = workspace_with_note(); diff --git a/crates/tui/src/tools/diff_format.rs b/crates/tui/src/tools/diff_format.rs index bb3549b26b..9d83e84f58 100644 --- a/crates/tui/src/tools/diff_format.rs +++ b/crates/tui/src/tools/diff_format.rs @@ -1,13 +1,12 @@ -//! Build unified-diff strings for tool results. +//! Build unified-diff strings for tool-result metadata. //! //! `edit_file` and `write_file` capture the file contents before and after -//! the mutation and emit a unified diff at the head of their `ToolResult` -//! output. The TUI's `output_looks_like_diff` detector then routes the -//! payload through `diff_render::render_diff`, which renders it with line -//! numbers and coloured `+`/`-` gutters (#505). -//! -//! The diff is also a strict UX upgrade for the model — it sees exactly -//! which lines changed instead of a one-line summary. +//! the mutation and stash a unified diff in the result `metadata` (under +//! `mutation.diff`) for the TUI, which routes it through +//! `diff_render::render_diff` with line numbers and coloured `+`/`-` +//! gutters (#505). The model-facing `content` stays a one-line receipt — +//! the model learns what changed from the edit echo (touched symbols and +//! impacted callers), not from the diff. use similar::TextDiff; diff --git a/crates/tui/src/tools/echolocation.rs b/crates/tui/src/tools/echolocation.rs new file mode 100644 index 0000000000..ce28447e66 --- /dev/null +++ b/crates/tui/src/tools/echolocation.rs @@ -0,0 +1,3426 @@ +//! Echolocation: bounded navigation observations for tool results. +//! +//! No repository-wide index, background work or new dependencies. Like a whale sounding +//! its pod, every read, grep, file search, and project map echoes its +//! surroundings fresh from source — sibling podmates, symbols, import +//! roots, resolved file links — rendered deterministically (sorted, +//! capped, no timestamps) so a repeated call is byte-identical and +//! cache-friendly. Anything the scanners cannot resolve is omitted, +//! never guessed. Sounding languages: Rust, Python, JavaScript/ +//! TypeScript, Go (symbols everywhere; links for Rust/Python/JS; +//! anything else renders podmates only). +//! +//! KV-cache effect: tool-result history only. Soundings never enter the +//! session-pinned prefix. +//! Lowercase Rust reads also consume bounded, revision-qualified syntax facts +//! computed with the existing syn parser. RuntimeToolServices owns their shared +//! physical cache; WorkingSet separately owns each conversation's deliveries. +//! +//! Tuning: `CODEWHALE_ECHO` gates sections for ablation trials +//! (comma-separated `off:` entries, e.g. `off:links,off:mates`; +//! section names: mates, symbols, imports, links, callers, pods, +//! soundings, navigation). Unset or malformed means everything on. +//! +//! Terminal buzz: the read footer deepens when the model is closing in. +//! Two distinct files read in a pod (see +//! `ToolContext::pod_visit_count`) put the next read there into buzz +//! mode, which adds the callers section — podmates whose resolved +//! links include this file. Sparse clicks while searching, +//! high-resolution echoes at close range. Deterministic for a fixed +//! history, like everything else here. +//! +//! Known lookalikes (legacy line scanners, not the Rust syntax producer): comment markers +//! inside string literals can mute following lines until the closer +//! (`"/*"` swallows until `"*/"`); Python doc fences toggle on every +//! `"""`/`'''` occurrence, so mixed kinds on one line desync the +//! span. Keywords match at line start only, so the common failure +//! mode is a missed symbol; forging one needs a keyword at a line +//! start inside a multi-line string. Links need that AND an +//! on-disk target, so they forge only when both coincide. + +use std::collections::{BTreeMap, BTreeSet}; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use ignore::WalkBuilder; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +const NAVIGATION_PRODUCER: &str = "rust_syntax_v1"; +const NAVIGATION_MARKER: &str = "[Code navigation: "; +const MAX_NAVIGATION_SOURCE_BYTES: usize = 256 * 1024; +const MAX_NAVIGATION_BYTES: usize = 4 * 1024; +const MAX_NAVIGATION_PATH_BYTES: usize = 512; +pub(crate) const MAX_DELIVERED_REFS: usize = 32; + +/// An actionable snapshot-qualified line window, not a permission grant or +/// proof that its body was delivered. Coordinates are 1-based lines, not +/// LSP UTF-16 columns or byte offsets. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CodeReadReference { + pub path: String, + pub offset: usize, + pub limit: usize, + pub expected_revision: String, +} + +impl CodeReadReference { + fn valid(&self) -> bool { + !self.path.is_empty() + && self.path.len() <= MAX_NAVIGATION_PATH_BYTES + && Path::new(&self.path) + .components() + .all(|part| matches!(part, std::path::Component::Normal(_))) + && self.offset > 0 + && self.limit > 0 + && self.offset.checked_add(self.limit - 1).is_some() + && valid_source_revision(&self.expected_revision) + } +} + +pub(crate) fn valid_source_revision(revision: &str) -> bool { + revision.strip_prefix("sha256:").is_some_and(|digest| { + digest.len() == 64 + && digest + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + }) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct NavigationSymbol { + name: String, + read: CodeReadReference, +} + +/// Syntax only: no macro expansion, cfg evaluation, name/type resolution or +/// LSP authority. V1 covers outer functions and impl methods in inline +/// modules; ranges exclude preceding attributes/modifiers and local items. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct SourceNavigation { + pub(crate) producer: String, + path: String, + pub(crate) source_revision: String, + status: String, + reason: Option, + symbols: Vec, + symbols_omitted: usize, + unscanned_modules: usize, +} + +pub(crate) fn navigation_cache_identity(workspace: &Path, path: &Path) -> Option<(String, String)> { + if path.extension()?.to_str()? != "rs" || path.to_str().is_none() { + return None; + } + let relative = workspace_relative(workspace, path)?; + if relative.len() > MAX_NAVIGATION_PATH_BYTES { + return None; + } + let root = workspace.canonicalize().ok()?; + // Bound keys as well as payloads; do not retain a full workspace path + // per record outside HandleStore's payload byte budget. + let namespace = format!( + "{NAVIGATION_PRODUCER}:{}", + super::file::content_hash(root.to_str()?.as_bytes()) + ); + Some((namespace, relative)) +} + +impl SourceNavigation { + pub(crate) fn matches(&self, path: &str, revision: &str) -> bool { + self.producer == NAVIGATION_PRODUCER + && self.path == path + && self.source_revision == revision + } + + pub(crate) fn from_source( + path: String, + revision: String, + source: &str, + utf8_valid: bool, + ) -> Self { + let mut result = Self { + producer: NAVIGATION_PRODUCER.to_string(), + path, + source_revision: revision, + status: "ready".to_string(), + reason: None, + symbols: Vec::new(), + symbols_omitted: 0, + unscanned_modules: 0, + }; + let unavailable = if !utf8_valid { + Some("invalid_utf8") + } else if source.len() > MAX_NAVIGATION_SOURCE_BYTES { + Some("source_byte_bound") + } else { + None + }; + if let Some(reason) = unavailable { + result.status = "unavailable".to_string(); + result.reason = Some(reason.to_string()); + return result; + } + match syn::parse_file(source) { + Ok(file) => result.visit_items(&file.items, "", 0), + Err(_) => { + result.status = "unavailable".to_string(); + result.reason = Some("syntax_error".to_string()); + } + } + while serde_json::to_vec(&result).is_ok_and(|bytes| bytes.len() > MAX_NAVIGATION_BYTES) { + if result.symbols.pop().is_none() { + break; + } + result.symbols_omitted += 1; + } + result + } + + fn visit_items(&mut self, items: &[syn::Item], scope: &str, depth: usize) { + for item in items { + match item { + syn::Item::Fn(function) => { + self.note_function(scope, &function.sig, &function.block) + } + syn::Item::Impl(implementation) => { + let scope = format!( + "{scope}impl@{}::", + implementation.impl_token.span.start().line + ); + for item in &implementation.items { + if let syn::ImplItem::Fn(function) = item { + self.note_function(&scope, &function.sig, &function.block); + } + } + } + syn::Item::Mod(module) => { + if let Some((_, items)) = &module.content { + if depth >= 16 { + self.unscanned_modules += 1; + } else { + self.visit_items( + items, + &format!("{scope}{}::", module.ident), + depth + 1, + ); + } + } + } + _ => {} + } + } + } + + fn note_function(&mut self, scope: &str, signature: &syn::Signature, block: &syn::Block) { + let name = format!("{scope}{}", signature.ident); + let start = signature.fn_token.span.start().line; + let end = block.brace_token.span.close().end().line; + if self.symbols.len() >= MAX_SYMBOLS + || name.len() > MAX_NAME_LEN + || start == 0 + || end < start + { + self.symbols_omitted += 1; + return; + } + self.symbols.push(NavigationSymbol { + name, + read: CodeReadReference { + path: self.path.clone(), + offset: start, + limit: end - start + 1, + expected_revision: self.source_revision.clone(), + }, + }); + } + + /// Whole actionable objects under half the existing footer budget. + /// Metadata retains the larger bounded view. + fn render_footer(&self) -> String { + if echo_off("navigation") { + return String::new(); + } + let mut shown = self.clone(); + shown.symbols.clear(); + shown.symbols_omitted += self.symbols.len(); + for symbol in &self.symbols { + shown.symbols.push(symbol.clone()); + shown.symbols_omitted -= 1; + if serde_json::to_string(&shown).is_ok_and(|text| { + text.len() + NAVIGATION_MARKER.len() + 3 > POD_FOOTER_MAX_BYTES / 2 + }) { + shown.symbols.pop(); + shown.symbols_omitted += 1; + break; + } + } + let footer = format!( + "\n\n{NAVIGATION_MARKER}{}]", + serde_json::to_string(&shown).unwrap_or_default() + ); + if footer.len() <= POD_FOOTER_MAX_BYTES / 2 { + footer + } else { + String::new() + } + } +} + +/// Delivery bookkeeping from projected tool-result text only. JSON strings +/// are not recursively decoded: a log/args preview is not an explicitly +/// returned reference. None of these references authorizes a future read. +pub(crate) fn delivered_navigation_refs(text: &str) -> Vec { + fn collect(value: &Value, refs: &mut Vec, nodes: &mut usize, depth: usize) { + if *nodes == 0 || depth > 16 || refs.len() >= MAX_DELIVERED_REFS { + return; + } + *nodes -= 1; + if value + .as_object() + .is_some_and(|object| object.len() == 4 && object.contains_key("expected_revision")) + && let Ok(reference) = serde_json::from_value::(value.clone()) + && reference.valid() + { + if !refs.contains(&reference) { + refs.push(reference); + } + return; + } + match value { + Value::Object(object) => { + for value in object.values() { + collect(value, refs, nodes, depth + 1); + } + } + Value::Array(values) => { + for value in values { + collect(value, refs, nodes, depth + 1); + } + } + _ => {} + } + } + let mut refs = Vec::new(); + let value = serde_json::from_str::(text).ok().or_else(|| { + let start = text.rfind(NAVIGATION_MARKER)? + NAVIGATION_MARKER.len(); + serde_json::Deserializer::from_str(&text[start..]) + .into_iter::() + .next()? + .ok() + }); + if let Some(value) = value { + collect(&value, &mut refs, &mut 2048, 0); + } + refs +} + +/// Hard budget for the rendered footer, in bytes. +pub const POD_FOOTER_MAX_BYTES: usize = 1200; + +/// Max podmate names listed before "+N more". +const MAX_PODMATES: usize = 12; +/// Max symbols listed before "+N more". +const MAX_SYMBOLS: usize = 24; +/// Max import roots listed before "+N more". +const MAX_IMPORT_ROOTS: usize = 12; +/// Max characters kept per sounded name. +const MAX_NAME_LEN: usize = 64; +/// Max matched dirs charted per grep result before `pods_omitted` counts. +const MAX_GREP_PODS: usize = 8; +/// Max mates listed per grep pod. +const MAX_GREP_MATES: usize = 8; +/// Max key files sounded per project map. +const MAX_MAP_SOUNDINGS: usize = 8; +/// Files bigger than this are never sounded. +const MAX_SOUNDING_BYTES: u64 = 8 * 1024 * 1024; +/// Max resolved file links listed before "+N more". +const MAX_LINKS: usize = 8; +/// Max callers (reverse links) listed before "+N more". +const MAX_CALLERS: usize = 8; +/// Mate files bigger than this are not scanned for callers. +const MAX_CALLER_SCAN_BYTES: u64 = 256 * 1024; +const MAX_CALLER_SCAN_FILES: usize = 32; +/// Tracked pod reads before the next read there enters the buzz. +pub(crate) const BUZZ_VISIT_THRESHOLD: usize = 2; +/// Max touched symbols named per edit echo before "+N more". +const MAX_EDIT_TOUCHED: usize = 6; +/// Max impacted callers named per edit echo before "+N more". +const MAX_EDIT_CALLERS: usize = 4; +/// Hard byte budget for one edit echo line. +const EDIT_ECHO_MAX_BYTES: usize = 400; +/// Changed spans attributed per edit; past this the edit is a rewrite +/// and the first spans already name its symbols. +const MAX_EDIT_SPANS: usize = 32; +/// Time budget for the touched-symbol diff. Myers is quadratic in the +/// worst case; past this `similar` approximates instead of blocking +/// the edit path, and the echo degrades to first-span attribution. +const EDIT_DIFF_TIMEOUT_MS: u64 = 100; + +/// Ablation gate: true when `CODEWHALE_ECHO` disables `section`. +/// Parsed once per process; unset or malformed means everything on. +fn echo_off(section: &str) -> bool { + static GATES: OnceLock> = OnceLock::new(); + GATES + .get_or_init(|| { + std::env::var("CODEWHALE_ECHO") + .map(|raw| parse_echo_gates(&raw)) + .unwrap_or_default() + }) + .contains(section) +} + +/// Parse `off:,…` entries; anything else is ignored (fail-open: +/// a typo never silences soundings in production). +fn parse_echo_gates(raw: &str) -> BTreeSet { + raw.split(',') + .filter_map(|entry| entry.trim().strip_prefix("off:")) + .map(|name| name.trim().to_string()) + .filter(|name| !name.is_empty()) + .collect() +} + +/// A sounded chart for one file. Empty sections render as nothing; a chart +/// with nothing to say renders as the empty string. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct EchoChart { + podmates: Vec, + podmate_total: usize, + symbols: Vec, + symbol_total: usize, + import_roots: Vec, + import_total: usize, + links: Vec, + link_total: usize, + callers: Vec, + caller_total: usize, + caller_candidates_omitted: usize, +} + +/// Shorten workspace-relative links to bare names when they live in +/// the same pod (directory) as the sounded file: the pod frame makes +/// them unambiguous, and the mates list shows the same names. +/// Cross-pod links keep their relative path. Never fails closed the +/// wrong way — on any doubt the full path stays. +fn pod_relative_names(workspace: &Path, file_path: &Path, links: Vec) -> Vec { + let Some(home) = file_path.parent() else { + return links; + }; + // Compare in workspace-relative space: joining the workspace here + // breaks when it is non-canonical but the file path is canonical + // (macOS /var, Windows \\?\ verbatim paths). + let home_rel = workspace_relative(workspace, home); + links + .into_iter() + .map(|link| { + let short = + Path::new(&link).parent().and_then(|parent| parent.to_str()) == home_rel.as_deref(); + if short { + Path::new(&link) + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or(link) + } else { + link + } + }) + .collect() +} + +/// Sound an echolocation chart for a file that was just read. +/// `workspace` anchors link display (workspace-relative paths); +/// `file_path` is the resolved path (for the sibling sounding); `text` +/// is the full decoded buffer (for symbols, imports, and links); `buzz` +/// adds the callers section when the model is closing in on this pod. +/// Ready navigation supplies symbols from this same admitted source snapshot, +/// bypassing the legacy symbol scan. +/// Never fails: anything unreadable is omitted from the chart rather +/// than failing the read. +pub(crate) fn sound_echolocation( + workspace: &Path, + file_path: &Path, + text: &str, + buzz: bool, + navigation: Option<&SourceNavigation>, +) -> EchoChart { + // Share only this chart's visible-entry snapshot. Caller candidates still + // pass their current path, credential, and secure-open checks below. + let entries = file_path + .parent() + .and_then(list_dir_entries) + .unwrap_or_default(); + let (_, mut podmates) = sound_podmates(file_path, &entries); + let podmate_total = podmates.len(); + podmates.truncate(MAX_PODMATES); + let navigation = navigation.filter(|navigation| navigation.status == "ready"); + let ( + mut symbols, + mut import_roots, + mut links, + link_total, + mut callers, + caller_total, + caller_candidates_omitted, + ) = if let Some(lang) = detect_lang(file_path) { + let mut import_roots = BTreeSet::new(); + let (links, link_total) = sound_links( + workspace, + file_path, + text, + lang, + Some(&mut import_roots), + None, + ); + let (callers, caller_total, caller_candidates_omitted) = if buzz { + sound_callers(workspace, file_path, Some(entries)) + } else { + (Vec::new(), 0, 0) + }; + ( + if navigation.is_some() { + Vec::new() + } else { + sound_symbols(text, lang) + }, + import_roots.into_iter().collect::>(), + links, + link_total, + callers, + caller_total, + caller_candidates_omitted, + ) + } else { + (Vec::new(), Vec::new(), Vec::new(), 0, Vec::new(), 0, 0) + }; + let symbol_total = if let Some(navigation) = navigation { + symbols = navigation + .symbols + .iter() + .map(|symbol| format!("fn {}:{}", symbol.name, symbol.read.offset)) + .collect(); + symbols.len() + navigation.symbols_omitted + } else { + symbols.len() + }; + symbols.truncate(MAX_SYMBOLS); + // Squeeze, all lossless: same-pod links and callers render bare + // (the pod frame disambiguates), and import roots a shown link + // already names drop — the link says the same thing with a path + // attached. Totals count what renders, so "+N more" stays honest. + // Each transform follows its section gate: an `off:links` trial + // measures links alone, never roots too. + if !echo_off("links") { + links = pod_relative_names(workspace, file_path, links); + } + if !echo_off("callers") { + callers = pod_relative_names(workspace, file_path, callers); + } + if !echo_off("links") && !echo_off("imports") { + let link_stems: Vec<&str> = links + .iter() + .filter_map(|link| Path::new(link).file_stem()?.to_str()) + .collect(); + import_roots.retain(|root| { + if link_stems.contains(&root.as_str()) { + return false; + } + // Package roots resolve under their own dir + // (`pkg` under `pkg/__init__.py`). + let prefix = format!("{root}/"); + !links.iter().any(|link| link.starts_with(&prefix)) + }); + } + let import_total = import_roots.len(); + import_roots.truncate(MAX_IMPORT_ROOTS); + links.truncate(MAX_LINKS); + callers.truncate(MAX_CALLERS); + EchoChart { + podmates, + podmate_total, + symbols, + symbol_total, + import_roots, + import_total, + links, + link_total, + callers, + caller_total, + caller_candidates_omitted, + } +} + +/// Sound a miss echo for a read that failed with NotFound: the parent +/// pod's mates, so a typo'd path costs one correction instead of a +/// list-dir round trip. `None` when the parent cannot be listed or the +/// mates gate is off. Callers must only invoke this for genuine +/// NotFound failures — never for denylist/permission errors, where an +/// echo would answer the probe it refused. +pub fn sound_miss_echo(file_path: &Path) -> Option { + if echo_off("mates") { + return None; + } + let entries = file_path + .parent() + .and_then(list_dir_entries) + .unwrap_or_default(); + let (pod_dir, mut mates) = sound_podmates(file_path, &entries); + if mates.is_empty() { + return None; + } + const MAX_MISS_MATES: usize = 8; + let total = mates.len(); + mates.truncate(MAX_MISS_MATES); + let dir = pod_dir.as_deref().unwrap_or("."); + Some(format!( + "\n\n[Miss echo: {dir} holds: {}]", + render_capped(&mates, total) + )) +} + +impl EchoChart { + /// Render the footer. Deterministic for a fixed file and directory: + /// sorted entries, fixed caps, byte budget, no timestamps. + pub fn render_footer(&self) -> String { + self.render_footer_with_budget(POD_FOOTER_MAX_BYTES) + } + + pub(crate) fn render_with_navigation(&self, navigation: Option<&SourceNavigation>) -> String { + let Some(navigation) = navigation else { + return self.render_footer(); + }; + let footer = navigation.render_footer(); + self.render_footer_with_budget(POD_FOOTER_MAX_BYTES - footer.len()) + &footer + } + + fn render_footer_with_budget(&self, budget: usize) -> String { + let mut out = String::new(); + // No dir: the footer trails the read of this exact file, so the + // pod frame is already known (miss echoes and grep pods keep + // theirs — those orient across paths, not within one). + if !echo_off("mates") && self.podmate_total > 0 { + out.push_str(&format!( + "\n\n[Pod ({}): {}]", + self.podmate_total, + render_capped(&self.podmates, self.podmate_total) + )); + } + let mut sounding = String::new(); + if !echo_off("symbols") && self.symbol_total > 0 { + sounding.push_str(&format!( + "{}: {}", + count_noun(self.symbol_total, "symbol", "symbols"), + render_capped(&self.symbols, self.symbol_total) + )); + } + if !echo_off("imports") && self.import_total > 0 { + if !sounding.is_empty() { + sounding.push_str("; "); + } + sounding.push_str(&format!( + "imports: {}", + render_capped(&self.import_roots, self.import_total) + )); + } + if !echo_off("links") && self.link_total > 0 { + if !sounding.is_empty() { + sounding.push_str("; "); + } + sounding.push_str(&format!( + "links: {}", + render_capped(&self.links, self.link_total) + )); + } + if !echo_off("callers") && self.caller_total > 0 { + if !sounding.is_empty() { + sounding.push_str("; "); + } + sounding.push_str(&format!( + "heard by: {}", + render_capped(&self.callers, self.caller_total) + )); + } + if !echo_off("callers") && self.caller_candidates_omitted > 0 { + if !sounding.is_empty() { + sounding.push_str("; "); + } + sounding.push_str(&format!( + "{} caller candidates unscanned", + self.caller_candidates_omitted + )); + } + if !sounding.is_empty() { + out.push_str(&format!("\n\n[Sound: {sounding}]")); + } + truncate_bytes(&out, budget) + } +} + +/// Languages with line-scanner soundings. Everything else is prose: +/// podmates only, no symbols/imports/links. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SoundLang { + Rust, + Python, + JavaScript, + Go, +} + +/// Sounding language from file extension. `None` renders podmates only. +fn detect_lang(file_path: &Path) -> Option { + let ext = file_path.extension()?.to_str()?; + match ext { + "rs" => Some(SoundLang::Rust), + "py" | "pyi" => Some(SoundLang::Python), + "js" | "jsx" | "mjs" | "cjs" | "ts" | "tsx" | "mts" | "cts" => Some(SoundLang::JavaScript), + "go" => Some(SoundLang::Go), + _ => None, + } +} + +/// Sibling entries of the file's directory: sorted display names (current +/// file marked, directories suffixed) plus the short directory display. +/// A directory that cannot be listed degrades to an empty pod, silently. +fn sound_podmates(file_path: &Path, mates: &[(String, String)]) -> (Option, Vec) { + let parent = match file_path.parent() { + Some(parent) => parent, + None => return (None, Vec::new()), + }; + let current = file_path + .file_name() + .map(|name| name.to_string_lossy().into_owned()); + let display = mates + .iter() + .map(|(raw, shown)| { + let mut shown = shown.clone(); + if Some(raw) == current.as_ref() { + shown.push_str(" (this file)"); + } + shown + }) + .collect(); + (Some(short_dir_display(parent)), display) +} + +/// Visibility gates a pod listing must honor: the calling search hides +/// these names, so pods must not enumerate them either. The denylist +/// always applies; glob/extension/gitignore gates apply when set. +/// `glob_root` anchors glob matching and gitignore stacking, exactly +/// like the calling walk's root. +pub(crate) struct PodVisibility<'a> { + pub include: &'a [String], + pub exclude: &'a [String], + pub extensions: &'a [String], + pub glob_root: &'a Path, + pub gitignore: bool, +} + +impl PodVisibility<'_> { + fn allows_entry(&self, abs: &Path, is_dir: bool) -> bool { + if abs + .file_name() + .is_some_and(|name| name.to_string_lossy().starts_with('.')) + || crate::sandbox::read_guard::active().check(abs).is_err() + { + return false; + } + let rel = abs + .strip_prefix(self.glob_root) + .unwrap_or(abs) + .to_string_lossy() + .replace('\\', "/"); + !self + .exclude + .iter() + .any(|pat| super::search::matches_glob(&rel, pat)) + && (is_dir + || ((self.include.is_empty() + || self + .include + .iter() + .any(|pat| super::search::matches_glob(&rel, pat))) + && (self.extensions.is_empty() + || super::file_search::extension_matches(abs, self.extensions)))) + } + /// Directed-read visibility: no search gates, denylist still on. + fn directed(glob_root: &Path) -> PodVisibility<'_> { + PodVisibility { + include: &[], + exclude: &[], + extensions: &[], + glob_root, + gitignore: false, + } + } +} + +/// Sorted `(raw, display)` entries of a dir: dotfiles skipped, dirs +/// suffixed with `/`, visibility gates applied. `None` when the dir +/// cannot be listed at all (distinct from an empty dir). +fn list_dir_entries(dir: &Path) -> Option> { + list_dir_entries_filtered(dir, &PodVisibility::directed(dir)) +} + +/// [`list_dir_entries`] plus the caller's search gates: denylist, +/// excludes, includes/extensions (files only, like the walks), and +/// optional parent/global/repository ignore rules. +fn list_dir_entries_filtered(dir: &Path, vis: &PodVisibility<'_>) -> Option> { + let mut builder = WalkBuilder::new(dir); + builder + .hidden(false) + .follow_links(false) + .require_git(false) + .max_depth(Some(1)) + .parents(vis.gitignore) + .ignore(vis.gitignore) + .git_ignore(vis.gitignore) + .git_global(vis.gitignore) + .git_exclude(vis.gitignore); + let mut entries = builder.build(); + let root = entries.next()?.ok()?; + if !root.file_type().is_some_and(|kind| kind.is_dir()) { + return None; + } + let mut mates = Vec::new(); + for entry in entries.flatten() { + let raw = entry.file_name().to_string_lossy().into_owned(); + let abs = entry.path(); + let is_dir = entry.file_type().is_some_and(|kind| kind.is_dir()); + if !vis.allows_entry(abs, is_dir) { + continue; + } + let mut display = raw.clone(); + if is_dir { + display.push('/'); + } + mates.push((raw, display)); + } + mates.sort(); + Some(mates) +} + +/// One sounded pod for a grep result: the matched directory and its +/// entries, matched files marked, capped with an honest total. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct MatchPod { + pub dir: String, + pub mates: Vec, + pub mate_total: usize, +} + +/// Sound pods for search matches: distinct matched dirs, sorted and +/// capped, each with its entries (matched files marked ` (match)`). +/// Entries honor `vis` — the calling search's own gates — so pods +/// never enumerate names the search hides. Returns the pods plus the +/// count of dirs beyond the cap. A dir that cannot be listed still +/// charts its matched files (already search-gated), so a pod is never +/// silently empty. Deterministic for a fixed tree. +pub(crate) fn sound_match_pods( + resolve_root: &Path, + files: &[String], + vis: &PodVisibility<'_>, +) -> (Vec, usize) { + if echo_off("pods") { + return (Vec::new(), 0); + } + let mut by_dir: BTreeMap> = BTreeMap::new(); + for file in files { + if file.is_empty() { + continue; + } + let (dir, name) = split_match_path(file); + by_dir.entry(dir).or_default().insert(name); + } + let mut pods = Vec::new(); + let mut omitted = 0; + for (dir, matched) in &by_dir { + if pods.len() >= MAX_GREP_PODS { + omitted += 1; + continue; + } + pods.push(sound_one_match_pod(resolve_root, dir, matched, vis)); + } + (pods, omitted) +} + +/// Split a match path into (dir display, file name); `.` for the root. +fn split_match_path(file: &str) -> (String, String) { + let path = Path::new(file); + let name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + let dir = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .map(slash_path) + .unwrap_or_else(|| ".".to_string()); + (dir, name) +} + +fn sound_one_match_pod( + resolve_root: &Path, + dir: &str, + matched: &BTreeSet, + vis: &PodVisibility<'_>, +) -> MatchPod { + let full = Path::new(dir); + let full = if full.is_absolute() { + full.to_path_buf() + } else { + resolve_root.join(full) + }; + let mut mates: Vec = match list_dir_entries_filtered(&full, vis) { + Some(entries) => entries + .into_iter() + .map(|(raw, mut shown)| { + if matched.contains(&raw) { + shown.push_str(" (match)"); + } + shown + }) + .collect(), + // The fallback repeats matched names through the same visibility + // gates as enumeration; it never discovers additional siblings. + None => matched + .iter() + .filter(|name| vis.allows_entry(&full.join(name), false)) + .map(|name| format!("{name} (match)")) + .collect(), + }; + let mate_total = mates.len(); + mates.truncate(MAX_GREP_MATES); + if mate_total > mates.len() { + mates.push(format!("+{} more", mate_total - mates.len())); + } + MatchPod { + dir: dir.to_string(), + mates, + mate_total, + } +} + +/// Sound one line of symbols for each of the first key files of a +/// project map (sorted, capped). Returns the soundings plus the count +/// of key files beyond the cap. Unreadable, oversized, non-Rust, or +/// symbol-free files are skipped silently. +pub(crate) fn sound_key_files( + root: &Path, + key_files: &[String], +) -> (BTreeMap, usize) { + if echo_off("soundings") { + return (BTreeMap::new(), 0); + } + let mut sorted: Vec<&str> = key_files.iter().map(String::as_str).collect(); + sorted.sort(); + let omitted = sorted.len().saturating_sub(MAX_MAP_SOUNDINGS); + let mut soundings = BTreeMap::new(); + for name in sorted.into_iter().take(MAX_MAP_SOUNDINGS) { + if let Some(sounding) = sound_file_symbols(root, &root.join(name)) { + soundings.insert(name.to_string(), sounding); + } + } + (soundings, omitted) +} + +/// Sound one line of symbols for a single file: `N symbols: a, b, …`. +/// `None` when unreadable, oversized, non-Rust, or symbol-free. +fn sound_file_symbols(root: &Path, path: &Path) -> Option { + let lang = detect_lang(path)?; + super::file::enforce_read_denylist(path, "read").ok()?; + let selected_path = path.canonicalize().ok()?; + workspace_relative(root, &selected_path)?; + if super::file::is_codewhale_credential_path(&selected_path) { + return None; + } + super::file::enforce_read_denylist(&selected_path, "read").ok()?; + let file = crate::external_credentials::open_secure_regular_file(&selected_path, false).ok()?; + crate::plugins::registry::validate_opened_regular_file(&selected_path, &file).ok()?; + if file.metadata().ok()?.len() > MAX_SOUNDING_BYTES { + return None; + } + let mut text = String::new(); + file.take(MAX_SOUNDING_BYTES + 1) + .read_to_string(&mut text) + .ok()?; + if text.len() as u64 > MAX_SOUNDING_BYTES { + return None; + } + let mut symbols = sound_symbols(&text, lang); + let total = symbols.len(); + if total == 0 { + return None; + } + symbols.truncate(MAX_SYMBOLS); + Some(format!( + "{}: {}", + count_noun(total, "symbol", "symbols"), + render_capped(&symbols, total) + )) +} + +/// Last two normal components of a directory, for a compact pod label. +fn short_dir_display(parent: &Path) -> String { + let parts: Vec = parent + .components() + .filter_map(|component| match component { + std::path::Component::Normal(part) => Some(part.to_string_lossy().into_owned()), + _ => None, + }) + .collect(); + if parts.is_empty() { + return ".".to_string(); + } + let start = parts.len().saturating_sub(2); + format!("{}/", parts[start..].join("/")) +} + +/// Item lines in file order, per language. +fn sound_symbols(text: &str, lang: SoundLang) -> Vec { + // Symbols carry 1-based source lines (`fn main:12`) so the model can + // page straight at them — the sounding already sees past truncation, + // and now it can point there too. + sound_numbered_symbols(text, lang) + .into_iter() + .map(|(n, symbol)| format!("{symbol}:{n}")) + .collect() +} + +/// Structured symbol sounding: 1-based line + bare `kind name`, in file +/// order. The edit echo attributes changed spans against these lines. +fn sound_numbered_symbols(text: &str, lang: SoundLang) -> Vec<(usize, String)> { + match lang { + SoundLang::Rust => code_lines(text) + .filter_map(|(n, line)| parse_item_line(line).map(|s| (n, s))) + .collect(), + SoundLang::Python => py_code_lines(text) + .filter_map(|(n, line)| parse_py_item(line).map(|s| (n, s))) + .collect(), + SoundLang::JavaScript => code_lines(text) + .filter_map(|(n, line)| parse_js_item(line).map(|s| (n, s))) + .collect(), + SoundLang::Go => code_lines(text) + .filter_map(|(n, line)| parse_go_item(line).map(|s| (n, s))) + .collect(), + } +} + +/// Changed old/new-side spans as (old side, 1-based start, line count). +/// Deletions use the old side; replacements attribute both sides. Reuses +/// the existing diff under its hard timeout and span budget. +fn changed_spans(before: &str, after: &str) -> Vec<(bool, usize, usize)> { + if before == after { + return Vec::new(); + } + similar::TextDiff::configure() + .timeout(std::time::Duration::from_millis(EDIT_DIFF_TIMEOUT_MS)) + .diff_lines(before, after) + .ops() + .iter() + .flat_map(|op| match op { + similar::DiffOp::Equal { .. } => vec![], + similar::DiffOp::Delete { + old_index, old_len, .. + } => { + vec![(true, old_index + 1, *old_len)] + } + similar::DiffOp::Insert { + new_index, new_len, .. + } => { + vec![(false, new_index + 1, *new_len)] + } + similar::DiffOp::Replace { + old_index, + old_len, + new_index, + new_len, + } => { + vec![ + (true, old_index + 1, *old_len), + (false, new_index + 1, *new_len), + ] + } + }) + .take(MAX_EDIT_SPANS) + .collect() +} + +/// Symbols the mutation touched, in diff order: declarations born +/// inside a changed span, else the nearest declaration above it (the +/// edit landed inside that symbol's body). Returns the shown names +/// plus the distinct total for the "+N more" tail. +fn touched_symbols(file_path: &Path, before: &str, after: &str) -> (Vec, usize) { + let empty = (Vec::new(), 0); + let Some(lang) = detect_lang(file_path) else { + return empty; + }; + let old_symbols = sound_numbered_symbols(before, lang); + let new_symbols = sound_numbered_symbols(after, lang); + let mut touched: Vec = Vec::new(); + for (old_side, start, len) in changed_spans(before, after) { + let symbols = if old_side { &old_symbols } else { &new_symbols }; + let mut born_inside = false; + for (line, name) in symbols { + if *line >= start && line.saturating_sub(start) < len { + born_inside = true; + let symbol = format!("{name}:{line}"); + if !touched.contains(&symbol) { + touched.push(symbol); + } + } + } + if !born_inside + && let Some((line, name)) = symbols.iter().rev().find(|(line, _)| *line <= start) + { + let symbol = format!("{name}:{line}"); + if !touched.contains(&symbol) { + touched.push(symbol); + } + } + } + let total = touched.len(); + touched.truncate(MAX_EDIT_TOUCHED); + (touched, total) +} + +/// Model-visible edit echo: which symbols the write/edit touched and +/// which podmates link this file (impacted callers). The model-facing +/// mutation receipt is one line and the diff rides metadata for the +/// TUI, so without this the model never learns what its edit hit. +/// `None` when there is nothing to say. Gated by the existing +/// `symbols`/`callers` sections; budgeted hard. +pub fn sound_edit_echo( + workspace: &Path, + file_path: &Path, + before: &str, + after: &str, +) -> Option { + if before == after { + return None; + } + let mut halves = Vec::new(); + if !echo_off("symbols") { + let (shown, total) = touched_symbols(file_path, before, after); + if total > 0 { + halves.push(format!("touched {}", render_capped(&shown, total))); + } + } + if !echo_off("callers") { + let (callers, total, omitted) = sound_callers(workspace, file_path, None); + if total > 0 { + let shown = &callers[..callers.len().min(MAX_EDIT_CALLERS)]; + halves.push(format!("heard by {}", render_capped(shown, total))); + } + if omitted > 0 { + halves.push(format!("{omitted} caller candidates unscanned")); + } + } + if halves.is_empty() { + return None; + } + Some(truncate_bytes( + &format!("[Edit echo: {}]", halves.join("; ")), + EDIT_ECHO_MAX_BYTES, + )) +} + +/// Source lines with `//` lines and `/* */` spans removed. Crude on +/// purpose: string literals containing comment markers are a documented +/// lookalike class (see module docs), not silently misread structure. +fn code_lines(text: &str) -> impl Iterator { + let mut in_block = false; + text.lines().enumerate().filter_map(move |(idx, raw)| { + let mut line = raw.trim_start(); + if in_block { + match line.find("*/") { + Some(end) => { + in_block = false; + line = line[end + 2..].trim_start(); + } + None => return None, + } + } + if line.starts_with("//") { + return None; + } + if line.as_bytes().contains(&b'/') + && let Some(start) = line.find("/*") + { + let after_open = &line[start + 2..]; + match after_open.find("*/") { + Some(end) => { + let head = line[..start].trim_end(); + let tail = after_open[end + 2..].trim_start(); + line = if head.is_empty() { tail } else { head }; + if line.is_empty() { + return None; + } + } + None => { + in_block = true; + line = line[..start].trim_end(); + if line.is_empty() { + return None; + } + } + } + } + Some((idx + 1, line)) + }) +} + +/// Parse one `fn|struct|enum|trait|type|mod|static|const|impl|union|macro` +/// line into `kind name`. Qualifiers (`pub`, `pub(..)`, `async`, `const`, +/// `unsafe`, `extern "ABI"`) are stripped first. +fn parse_item_line(mut line: &str) -> Option { + loop { + line = line.trim_start(); + if let Some(rest) = strip_keyword(line, "pub") { + line = strip_visibility_group(rest.trim_start())?; + continue; + } + if let Some(rest) = strip_keyword(line, "extern") { + line = strip_abi_string(rest.trim_start())?; + continue; + } + let mut stripped = false; + for qualifier in ["async", "unsafe"] { + if let Some(rest) = strip_keyword(line, qualifier) { + line = rest; + stripped = true; + break; + } + } + if stripped { + continue; + } + // `const fn` (qualifier) vs `const NAME` (item): strip only when + // `fn` follows, otherwise the keyword table below owns it. + if let Some(rest) = strip_keyword(line, "const") + && strip_keyword(rest.trim_start(), "fn").is_some() + { + line = rest; + continue; + } + break; + } + for (keyword, label) in [ + ("fn", "fn"), + ("struct", "struct"), + ("enum", "enum"), + ("trait", "trait"), + ("type", "type"), + ("mod", "mod"), + ("static", "static"), + ("const", "const"), + ("union", "union"), + ("impl", "impl"), + ("macro_rules", "macro"), + ("macro", "macro"), + ] { + if let Some(rest) = strip_keyword(line, keyword) { + let rest = if keyword == "macro_rules" { + rest.strip_prefix('!').unwrap_or(rest) + } else { + rest + }; + let name = item_head(rest, keyword == "impl")?; + return Some(format!("{label} {name}")); + } + } + None +} + +/// Strip `keyword` only on a word boundary (so `fn` never matches `fnx`). +fn strip_keyword<'a>(line: &'a str, keyword: &str) -> Option<&'a str> { + let rest = line.strip_prefix(keyword)?; + match rest.chars().next() { + None => Some(rest), + Some(next) if next.is_alphanumeric() || next == '_' => None, + _ => Some(rest), + } +} + +/// Skip a `pub(..)` visibility group; bare `pub` falls through unchanged. +fn strip_visibility_group(rest: &str) -> Option<&str> { + match rest.strip_prefix('(') { + Some(after) => Some(&after[after.find(')')? + 1..]), + None => Some(rest), + } +} + +/// Skip an `extern "..."` ABI string; bare `extern` falls through unchanged. +fn strip_abi_string(rest: &str) -> Option<&str> { + match rest.strip_prefix('"') { + Some(after) => Some(&after[after.find('"')? + 1..]), + None => Some(rest), + } +} + +/// The sounded name: first identifier for items, the whole head (bounded) +/// for `impl` blocks (`Foo`, `Trait for Foo`, ` Foo`). +fn item_head(rest: &str, is_impl: bool) -> Option { + let rest = rest.trim_start(); + if is_impl { + let mut head = rest.split(['{', ';']).next()?.trim(); + if let Some(where_at) = head.find(" where ") { + head = head[..where_at].trim(); + } + if head.is_empty() { + return None; + } + return Some(truncate_name(head)); + } + // Capped before collecting: a minified line can run megabytes, + // and the sounding must never allocate the file to name it. + let name: String = rest + .chars() + .take_while(|next| next.is_alphanumeric() || *next == '_') + .take(MAX_NAME_LEN + 1) + .collect(); + if name.is_empty() || name.starts_with(|c: char| c.is_ascii_digit()) { + return None; + } + Some(truncate_name(&name)) +} + +/// First `::` segment of a `use` path (`serde`, `crate`, `super`). +fn parse_use_root(line: &str) -> Option { + let mut line = line.trim_start(); + if let Some(rest) = strip_keyword(line, "pub") { + line = strip_visibility_group(rest.trim_start())?.trim_start(); + } + let rest = strip_keyword(line, "use")?.trim_start(); + let head = rest.split([';', '{', ' ', '\t']).next()?.trim(); + let root = head.split("::").next()?.trim(); + valid_root(root) +} + +/// Resolve `use`/`mod` edges to workspace-relative files that exist on +/// disk (sorted, with an honest total; the caller caps). Only module +/// files (`x.rs` / `x/mod.rs`) resolve — single items living directly +/// in a root file, external crates, and `#[path]` remaps are left to +/// the import roots rather than guessed. +/// A membership-only caller may stop at an admitted target; in that mode +/// the other links and total are partial and must not be rendered. +fn sound_links( + workspace: &Path, + file_path: &Path, + text: &str, + lang: SoundLang, + mut import_roots: Option<&mut BTreeSet>, + stop_at: Option<&str>, +) -> (Vec, usize) { + let mut links = BTreeSet::new(); + match lang { + SoundLang::Rust => { + for (_, line) in code_lines(text) { + // code_lines left-trims after tracking comments; only these + // initial bytes can introduce use/pub/mod for the parsers below. + if !matches!(line.as_bytes().first(), Some(b'u' | b'p' | b'm')) { + continue; + } + if let Some(roots) = import_roots.as_deref_mut() { + roots.extend(parse_use_root(line)); + } + let link = if let Some(segments) = parse_use_segments(line) { + resolve_use_link(workspace, file_path, &segments) + } else { + parse_mod_decl(line) + .and_then(|child| resolve_child_module(workspace, file_path, &child)) + }; + if let Some(link) = link { + let found = stop_at == Some(link.as_str()); + links.insert(link); + if found { + break; + } + } + } + } + SoundLang::Python => { + 'lines: for (_, line) in py_code_lines(text) { + if let Some(roots) = import_roots.as_deref_mut() { + roots.extend(parse_py_import_roots(line)); + } + for link in resolve_py_links(workspace, file_path, line) { + let found = stop_at == Some(link.as_str()); + links.insert(link); + if found { + break 'lines; + } + } + } + } + SoundLang::JavaScript => { + for (_, line) in code_lines(text) { + if let Some(roots) = import_roots.as_deref_mut() { + roots.extend(parse_js_import_root(line)); + } + if let Some(link) = resolve_js_link(workspace, file_path, line) { + let found = stop_at == Some(link.as_str()); + links.insert(link); + if found { + break; + } + } + } + } + // Go imports name packages (dirs), not files: nothing to link. + SoundLang::Go => { + if let Some(roots) = import_roots { + roots.extend(code_lines(text).filter_map(|(_, line)| parse_go_import_root(line))); + } + } + } + if let Some(own) = workspace_relative(workspace, file_path) { + links.remove(&own); + } + let total = links.len(); + (links.into_iter().collect(), total) +} + +/// Full `use` segments (`crate::config::Config` → all three). Groups, +/// globs, and `as` renames cut at the module prefix. +fn parse_use_segments(line: &str) -> Option> { + let mut line = line.trim_start(); + if let Some(rest) = strip_keyword(line, "pub") { + line = strip_visibility_group(rest.trim_start())?.trim_start(); + } + let rest = strip_keyword(line, "use")?.trim_start(); + let head = rest.split([';', '{', '*']).next()?.trim(); + let head = head.split(" as ").next()?.trim(); + let segments: Vec = head + .split("::") + .map(str::trim) + .filter(|part| !part.is_empty()) + .map(str::to_string) + .collect(); + if segments.is_empty() + || segments + .iter() + .any(|part| part.len() > MAX_NAME_LEN || !is_ident(part)) + { + return None; + } + Some(segments) +} + +/// `mod name;` child decl. Inline `mod name {` is a symbol, not an edge. +fn parse_mod_decl(line: &str) -> Option { + let mut line = line.trim_start(); + if let Some(rest) = strip_keyword(line, "pub") { + line = strip_visibility_group(rest.trim_start())?.trim_start(); + } + let rest = strip_keyword(line, "mod")?.trim_start(); + let name: String = rest + .chars() + .take_while(|next| next.is_alphanumeric() || *next == '_') + .take(MAX_NAME_LEN + 1) + .collect(); + if name.is_empty() || name.len() > MAX_NAME_LEN || !is_ident(&name) { + return None; + } + match rest[name.len()..].trim_start().chars().next() { + Some(';') => Some(name), + _ => None, + } +} + +fn is_ident(part: &str) -> bool { + // No leading digit: not an identifier in any sounded language, so a + // digit-led token is line noise, never a name. + !part.is_empty() + && part + .chars() + .next() + .is_some_and(|first| !first.is_ascii_digit()) + && part.chars().all(|c| c.is_alphanumeric() || c == '_') +} + +fn resolve_use_link(workspace: &Path, file_path: &Path, segments: &[String]) -> Option { + let first = segments.first()?.as_str(); + match first { + "crate" => { + let root = find_crate_root(workspace, file_path)?; + resolve_module_file(workspace, &root, &segments[1..]) + } + "super" => { + let depth = segments + .iter() + .take_while(|part| part.as_str() == "super") + .count(); + let mut base = super_base(file_path)?; + for _ in 1..depth { + base = base.parent()?.to_path_buf(); + } + resolve_module_file(workspace, &base, &segments[depth..]) + } + "self" => { + let base = self_base(file_path)?; + resolve_module_file(workspace, &base, &segments[1..]) + } + _ => None, + } +} + +/// `mod child;` resolves under the current module: the file's own dir +/// for `mod.rs`, a child dir named for the file stem otherwise. +fn resolve_child_module(workspace: &Path, file_path: &Path, child: &str) -> Option { + let base = self_base(file_path)?; + resolve_module_file(workspace, &base, std::slice::from_ref(&child.to_string())) +} + +/// Longest-prefix module-file match: `a::b::Item` tries `a/b.rs`, +/// `a/b/mod.rs`, then `a.rs`, `a/mod.rs`. First hit on disk wins. +fn resolve_module_file(workspace: &Path, base: &Path, rest: &[String]) -> Option { + for width in (1..=rest.len()).rev() { + let joined = rest[..width].join("/"); + for candidate in [ + base.join(format!("{joined}.rs")), + base.join(&joined).join("mod.rs"), + ] { + // Reject impossible links cheaply; this is not read admission. + // Existing candidates still pass every guard below. + if !candidate.is_file() { + continue; + } + if super::file::enforce_read_denylist(&candidate, "read").is_err() { + continue; + } + let Ok(selected_path) = candidate.canonicalize() else { + continue; + }; + if super::file::is_codewhale_credential_path(&selected_path) + || super::file::enforce_read_denylist(&selected_path, "read").is_err() + { + continue; + } + if selected_path.is_file() { + return workspace_relative(workspace, &selected_path); + } + } + } + None +} + +/// `self::` / `mod x;` base: own dir for `mod.rs` and the crate roots +/// (`lib.rs`/`main.rs`, whose children live beside them, not under a +/// `/` dir), else `/`. +fn self_base(file_path: &Path) -> Option { + let dir = file_path.parent()?; + if file_path + .file_name() + .is_some_and(|name| name == "mod.rs" || name == "lib.rs" || name == "main.rs") + { + return Some(dir.to_path_buf()); + } + let stem = file_path.file_stem()?; + Some(dir.join(stem)) +} + +/// First-level `super::` base: parent dir for `mod.rs` (whose own dir +/// is the current module), else the file's own dir. +fn super_base(file_path: &Path) -> Option { + let dir = file_path.parent()?; + if file_path.file_name().is_some_and(|name| name == "mod.rs") { + dir.parent().map(Path::to_path_buf) + } else { + Some(dir.to_path_buf()) + } +} + +/// Crate root: nearest ancestor (inclusive of the file's dir) holding +/// `main.rs` or `lib.rs`, never walking past the workspace. +fn find_crate_root(workspace: &Path, file_path: &Path) -> Option { + let mut current = file_path.parent()?.to_path_buf(); + loop { + if current.join("main.rs").is_file() || current.join("lib.rs").is_file() { + return Some(current); + } + if current == workspace { + return None; + } + current = current.parent()?.to_path_buf(); + } +} + +/// Render a path with `/` separators on every platform, so sounded +/// paths are byte-identical on Windows, macOS, and Linux (portable +/// tests, shared prompt caches). Components are re-joined rather than +/// string-replaced so a literal `\` in a file name survives, and `.` +/// / `..` segments pass through untouched for [`dot_clean`]. +fn slash_path(path: &Path) -> String { + path.components() + .map(|component| component.as_os_str().to_string_lossy()) + .collect::>() + .join("/") +} + +fn workspace_relative(workspace: &Path, path: &Path) -> Option { + if let Some(rel) = path.strip_prefix(workspace).ok().map(slash_path) { + return Some(rel); + } + // The tool layer hands us canonical file paths while the workspace + // may be non-canonical (macOS /var -> /private/var tempdirs, + // Windows \\?\ verbatim paths). Canonicalize both sides before + // giving up, or every caller/link match silently misses there. + let workspace = workspace.canonicalize().ok()?; + let path = path.canonicalize().ok()?; + path.strip_prefix(&workspace).ok().map(slash_path) +} + +/// Python source lines: `#` comments cut, triple-quote doc spans +/// skipped. Crude on purpose: a `#` inside a string literal mangles +/// the tail, but item/import keywords match at line start, so the +/// mangling cannot forge a symbol. Doc-span tracking toggles on every +/// `"""` / `'''` occurrence — one-liner docstrings toggle twice (no +/// net change); mixed kinds on one line are a documented lookalike. +fn py_code_lines(text: &str) -> impl Iterator { + let mut in_doc = false; + text.lines().enumerate().filter_map(move |(idx, raw)| { + let mut line = raw.trim(); + if in_doc { + let fences = line.matches("\"\"\"").count() + line.matches("'''").count(); + if fences % 2 == 1 { + in_doc = false; + } + return None; + } + if let Some(hash) = line.find('#') { + line = line[..hash].trim_end(); + } + let fences = line.matches("\"\"\"").count() + line.matches("'''").count(); + if fences % 2 == 1 { + in_doc = true; + return None; + } + if fences > 0 { + // One-liner docstring as the whole logical line: nothing + // else on it can be a symbol. + let stripped = line.replace("\"\"\"", "").replace("'''", ""); + if stripped.trim().is_empty() { + return None; + } + } + if line.is_empty() { + return None; + } + Some((idx + 1, line)) + }) +} + +/// `def name` / `class Name` (any indent — methods included), +/// `async def` unwrapped. +fn parse_py_item(line: &str) -> Option { + let line = line.trim_start(); + let line = strip_keyword(line, "async").unwrap_or(line).trim_start(); + for (keyword, label) in [("def", "def"), ("class", "class")] { + if let Some(rest) = strip_keyword(line, keyword) { + let name: String = rest + .trim_start() + .chars() + .take_while(|next| next.is_alphanumeric() || *next == '_') + .take(MAX_NAME_LEN + 1) + .collect(); + if !name.is_empty() + && name.len() <= MAX_NAME_LEN + && !name.starts_with(|c: char| c.is_ascii_digit()) + { + return Some(format!("{label} {name}")); + } + return None; + } + } + None +} + +/// Import roots of one line: every `import a, b.c` member, the +/// `from` module (`a.b` → `a`), or the import-list leaves for +/// pure-relative modules (`from . import sib` → `sib`). Semicolons +/// split statements. +fn parse_py_import_roots(line: &str) -> Vec { + let line = line.trim_start(); + let mut out = Vec::new(); + if let Some(rest) = strip_keyword(line, "import") { + for stmt in rest.split(';') { + for part in stmt.split(',') { + let part = part.split(" as ").next().unwrap_or("").trim(); + let root = part.split('.').next().unwrap_or("").trim(); + if let Some(root) = valid_root(root) { + out.push(root); + } + } + } + return out; + } + if let Some(rest) = strip_keyword(line, "from") { + let mut parts = rest.split(" import "); + let module = parts.next().unwrap_or("").trim(); + if module.trim_start_matches('.').is_empty() { + // Pure-relative: the module names no package, so leaf names + // come from the import list (`from . import sib` → `sib`), + // which stem-shadowing then drops when the link resolves. + let list = parts.next().unwrap_or(""); + for part in list.split(',') { + let name = part + .split(" as ") + .next() + .unwrap_or("") + .trim_matches(['(', ')', ' ', '\t']); + let leaf = name.split('.').next().unwrap_or("").trim(); + if let Some(root) = valid_root(leaf) { + out.push(root); + } + } + return out; + } + let root = py_module_root(module); + if !root.is_empty() { + out.push(root); + } + } + out +} + +/// Root of a Python module path: first named segment after any +/// leading dots (`..parent.x` → `parent`). Pure-relative modules never +/// reach here — the caller takes leaf names from the import list. +fn py_module_root(module: &str) -> String { + let dots = module.chars().take_while(|c| *c == '.').count(); + let named = module[dots..].split('.').next().unwrap_or("").trim(); + valid_root(named).unwrap_or_default() +} + +fn valid_root(root: &str) -> Option { + if root.is_empty() || root.len() > MAX_NAME_LEN || !is_ident(root) { + return None; + } + Some(root.to_string()) +} + +/// Resolve one Python import line to existing files. Relative imports +/// walk up from the file's dir; absolute ones try the file's dir (flat +/// scripts) then the workspace root (packages). Both `mod.py` and +/// `pkg/__init__.py` shapes. Existence is the honesty guard. +fn resolve_py_links(workspace: &Path, file_path: &Path, line: &str) -> Vec { + let line = line.trim_start(); + let mut out = Vec::new(); + if let Some(rest) = strip_keyword(line, "import") { + // `import a, b.c as d` (semicolons split statements first). + for stmt in rest.split(';') { + for part in stmt.split(',') { + let part = part.split(" as ").next().unwrap_or("").trim(); + let segments: Vec<&str> = part.split('.').map(str::trim).collect(); + if segments.iter().all(|s| is_ident(s)) { + out.extend(resolve_py_module(workspace, file_path, 0, &segments)); + } + } + } + return out; + } + if let Some(rest) = strip_keyword(line, "from") { + // `from [..]mod import names` — parenthesized/continued name + // lists don't matter; only the module part resolves. + let module = rest.split(" import ").next().unwrap_or("").trim(); + let dots = module.chars().take_while(|c| *c == '.').count(); + let segments: Vec<&str> = module[dots..] + .split('.') + .map(str::trim) + .filter(|s| !s.is_empty()) + .collect(); + if segments.iter().all(|s| is_ident(s)) { + // `from . import sib` names the leaf in the import list, + // not the module: resolve each imported name as a sibling. + if dots > 0 && segments.is_empty() { + if let Some(names) = rest.split(" import ").nth(1) { + for name in names + .replace(['(', ')'], " ") + .split(',') + .map(|n| n.split(" as ").next().unwrap_or("").trim()) + .filter(|n| is_ident(n)) + { + out.extend(resolve_py_module( + workspace, + file_path, + dots, + std::slice::from_ref(&name), + )); + } + } + return out; + } + out.extend(resolve_py_module(workspace, file_path, dots, &segments)); + } + return out; + } + out +} + +/// Resolve Python module segments: `dots` levels up from the file's dir +/// (0 = absolute: same dir first, workspace root as fallback), then +/// `x.py` or `x/__init__.py`. Same-dir wins outright — no double links. +fn resolve_py_module( + workspace: &Path, + file_path: &Path, + dots: usize, + segments: &[&str], +) -> Vec { + if segments.is_empty() { + return Vec::new(); + } + if dots == 0 { + if let Some(dir) = file_path.parent() { + let hits = py_candidates(workspace, dir, segments); + if !hits.is_empty() { + return hits; + } + } + return py_candidates(workspace, workspace, segments); + } + let mut base = match file_path.parent() { + Some(dir) => dir.to_path_buf(), + None => return Vec::new(), + }; + for _ in 1..dots { + match base.parent() { + Some(parent) => base = parent.to_path_buf(), + None => return Vec::new(), + } + } + py_candidates(workspace, &base, segments) +} + +/// `x.py` / `x/__init__.py` under one base, workspace-relative. +fn py_candidates(workspace: &Path, base: &Path, segments: &[&str]) -> Vec { + let joined = segments.join("/"); + [ + base.join(format!("{joined}.py")), + base.join(&joined).join("__init__.py"), + ] + .into_iter() + .filter_map(|candidate| { + if !candidate.is_file() { + return None; + } + super::file::enforce_read_denylist(&candidate, "read").ok()?; + let selected_path = candidate.canonicalize().ok()?; + if super::file::is_codewhale_credential_path(&selected_path) + || super::file::enforce_read_denylist(&selected_path, "read").is_err() + { + return None; + } + selected_path + .is_file() + .then(|| workspace_relative(workspace, &selected_path)) + .flatten() + }) + .collect() +} + +/// JS/TS item lines: `function`/`class`, `const`/`let`/`var` arrows, +/// `type`/`interface`/`enum`. `export`/`default`/`async`/`declare`/ +/// `abstract` unwrapped. Method shorthand skipped (indistinguishable +/// from calls at line-scan resolution — documented omission). +fn parse_js_item(line: &str) -> Option { + let mut line = line.trim_start(); + for qualifier in ["export", "default", "declare", "abstract", "async"] { + if let Some(rest) = strip_keyword(line, qualifier) { + line = rest.trim_start(); + } + } + for (keyword, label) in [ + ("function", "function"), + ("class", "class"), + ("interface", "interface"), + ("enum", "enum"), + ] { + if let Some(rest) = strip_keyword(line, keyword) { + let rest = rest + .trim_start() + .strip_prefix('*') + .unwrap_or(rest) + .trim_start(); + let name = ident_head(rest)?; + return Some(format!("{label} {name}")); + } + } + if let Some(rest) = strip_keyword(line, "type") { + let name = ident_head(rest.trim_start())?; + return Some(format!("type {name}")); + } + for qualifier in ["const", "let", "var"] { + if let Some(rest) = strip_keyword(line, qualifier) { + let rest = rest.trim_start(); + let name = ident_head(rest)?; + let after = rest[name.len()..].trim_start(); + let body = after.strip_prefix('=').unwrap_or("").trim_start(); + // Arrow/function value, not data: `= (`, `= async`, `= function`, `=>`. + if body.starts_with('(') + || strip_keyword(body, "async").is_some() + || strip_keyword(body, "function").is_some() + || body.contains("=>") + { + return Some(format!("{qualifier} {name}")); + } + return None; + } + } + None +} + +fn ident_head(rest: &str) -> Option { + let name: String = rest + .chars() + .take_while(|next| next.is_alphanumeric() || *next == '_' || *next == '$') + .take(MAX_NAME_LEN + 1) + .collect(); + if name.is_empty() + || name.len() > MAX_NAME_LEN + || name.starts_with(|c: char| c.is_ascii_digit()) + { + return None; + } + Some(name) +} + +/// JS/TS import roots: relative specs resolve to the first named +/// segment past the dots (`./server` → `server`); scoped `@s/n` kept +/// whole; otherwise the first path segment. Covers `import`, +/// `export-from`, `require`, and dynamic `import()`. +fn parse_js_import_root(line: &str) -> Option { + let spec = js_import_spec(line)?; + if spec.starts_with('.') { + let tail = spec.trim_start_matches('.').trim_start_matches('/'); + let first = tail.split('/').next().unwrap_or("").trim(); + return valid_root(first); + } + if let Some(scoped) = spec.strip_prefix('@') { + let mut parts = scoped.split('/'); + let (scope, name) = (parts.next()?, parts.next()?); + if scope.is_empty() || name.is_empty() { + return None; + } + return Some(format!("@{scope}/{name}")); + } + let root = spec.split('/').next()?.trim(); + valid_root(root) +} + +/// The module specifier of an import-ish line, unquoted. +fn js_import_spec(line: &str) -> Option { + let line = line.trim_start(); + // Side-effect and from-forms: `import 'x'`, `import … from 'x'`, `export … from 'x'`. + if strip_keyword(line, "import").is_some() || strip_keyword(line, "export").is_some() { + return quoted_tail(line); + } + // `require('x')`, `import('x')` anywhere a call starts the line. + // The opener needs a boundary behind it: `myrequire(` and a call + // spelled inside a string literal (`"require('x')"`) must not forge + // imports. + for opener in ["require(", "import("] { + // Every occurrence: a `myrequire(` earlier on the line must not + // shadow a real call later on it. + let mut start = 0; + while let Some(rel) = line[start..].find(opener) { + let at = start + rel; + let bounded = line[..at].chars().next_back().is_none_or(|prev| { + !(prev.is_alphanumeric() + || prev == '_' + || prev == '$' + || prev == '\'' + || prev == '"' + || prev == '`') + }); + start = at + opener.len(); + if !bounded { + continue; + } + let after = line[start..].trim_start(); + // A computed argument (`require(name)`) is not a specifier; + // keep scanning rather than bailing on the whole line. + let Some(quote) = after.chars().next() else { + continue; + }; + if quote == '\'' || quote == '"' || quote == '`' { + return after[1..].split(quote).next().map(str::to_string); + } + } + } + None +} + +/// The module specifier: first quoted string after `from` when present +/// (`import … from 'x'`), else the first quoted string on the line +/// (side-effect `import 'x'`). Scoping to `from` keeps a trailing +/// string literal (`log("hi")`) from shadowing the specifier, and the +/// earliest opening quote of any kind wins, so a later apostrophe in +/// a trailing expression can never shadow the specifier either. +fn quoted_tail(line: &str) -> Option { + // Both separators are 6 bytes; the max rfind wins either way. + let tail = [" from ", " from\t"] + .iter() + .filter_map(|sep| line.rfind(sep)) + .max() + .map(|at| &line[at + 6..]) + .unwrap_or(line); + let (pos, quote) = ['\'', '"', '`'] + .iter() + .filter_map(|quote| tail.find(*quote).map(|pos| (pos, *quote))) + .min()?; + // `pos` is the byte index of a one-byte quote, so `pos + 1` is a + // boundary. + tail[pos + 1..] + .split(quote) + .next() + .filter(|spec| !spec.is_empty()) + .map(str::to_string) +} + +/// JS/TS extension search order for relative links. +const JS_LINK_EXTENSIONS: [&str; 8] = ["js", "ts", "jsx", "tsx", "mjs", "cjs", "mts", "cts"]; + +/// Resolve relative JS/TS specifiers (`./x`, `../x`) against the file's +/// dir: bare, +extensions, +`/index`+extensions. Bare package names +/// never resolve (node_modules is not walked). Query/hash suffixes cut. +fn resolve_js_link(workspace: &Path, file_path: &Path, line: &str) -> Option { + let mut spec = js_import_spec(line)?; + if !spec.starts_with('.') { + return None; + } + spec = spec.split(['?', '#']).next()?.to_string(); + let dir = file_path.parent()?; + let target = dir.join(&spec); + let mut candidates = vec![target.clone()]; + for ext in JS_LINK_EXTENSIONS { + candidates.push(target.with_extension(ext)); + } + for ext in JS_LINK_EXTENSIONS { + candidates.push(target.join(format!("index.{ext}"))); + } + for candidate in candidates { + if !candidate.is_file() { + continue; + } + if super::file::enforce_read_denylist(&candidate, "read").is_err() { + continue; + } + let Ok(selected_path) = candidate.canonicalize() else { + continue; + }; + if super::file::is_codewhale_credential_path(&selected_path) + || super::file::enforce_read_denylist(&selected_path, "read").is_err() + { + continue; + } + if selected_path.is_file() { + return workspace_relative(workspace, &selected_path).map(|rel| dot_clean(&rel)); + } + } + None +} + +/// Lexically collapse `a/../b` (no FS touch — `canonicalize` would +/// resolve symlinks like `/tmp` and break the workspace prefix). +fn dot_clean(rel: &str) -> String { + let mut parts: Vec<&str> = Vec::new(); + for part in rel.split('/') { + if part == ".." { + parts.pop(); + } else if !part.is_empty() && part != "." { + parts.push(part); + } + } + parts.join("/") +} + +/// Go item lines: `func Name`, methods as `func Type.Method`, +/// `type Name …`. +fn parse_go_item(line: &str) -> Option { + let line = line.trim_start(); + if let Some(rest) = strip_keyword(line, "func") { + let rest = rest.trim_start(); + if let Some(after) = rest.strip_prefix('(') { + // Method: receiver `(name Type)` or `(Type)`. + let receiver = after.split(')').next()?.trim(); + let typ = receiver.split_whitespace().last()?; + let typ = typ.trim_start_matches('*').trim_start_matches("[]"); + let after = after.split(')').nth(1)?.trim_start(); + let name = ident_head(after)?; + if typ.is_empty() || !is_ident(typ) { + return None; + } + return Some(format!("func {typ}.{name}")); + } + let name = ident_head(rest)?; + return Some(format!("func {name}")); + } + if let Some(rest) = strip_keyword(line, "type") { + let rest = rest.trim_start(); + // Single decl only; `type ( … )` blocks list names bare — + // resolving those needs block tracking, documented omission. + if rest.starts_with('(') { + return None; + } + let name = ident_head(rest)?; + return Some(format!("type {name}")); + } + None +} + +/// Go import roots: last path segment (package name by convention). +/// Covers `import` lines (with optional alias) and the bare quoted +/// entries of `import ( … )` blocks. Anything else starting with an +/// identifier (`return "x"`) is not an import — the alias shape is +/// indistinguishable at line resolution, so keyword-less aliased +/// entries are a documented miss. +fn parse_go_import_root(line: &str) -> Option { + let line = line.trim_start(); + let rest = match strip_keyword(line, "import") { + Some(rest) => { + let rest = rest.trim_start(); + // Optional alias: `name "…"`, `_ "…"`, `. "…"`. + if rest.starts_with('"') || rest.starts_with('`') { + rest + } else { + rest.split_whitespace().nth(1)?.trim_start() + } + } + None => { + if line.starts_with('"') || line.starts_with('`') { + line + } else { + return None; + } + } + }; + let quote = rest.chars().next()?; + if quote != '"' && quote != '`' { + return None; + } + let path = rest[1..].split(quote).next()?; + let root = path.split('/').next_back()?.trim(); + valid_root(root) +} + +/// Reverse links: podmates whose own resolved links include this file. +/// The echo coming back. Exact by construction (reuses the link +/// resolver, no name guessing) and bounded (same-dir mates only, +/// oversized mates skipped, visibility-filtered like the pod itself). +fn sound_callers( + workspace: &Path, + file_path: &Path, + entries: Option>, +) -> (Vec, usize, usize) { + let own = match workspace_relative(workspace, file_path) { + Some(own) => own, + None => return (Vec::new(), 0, 0), + }; + let parent = match file_path.parent() { + Some(parent) => parent, + None => return (Vec::new(), 0, 0), + }; + let mates: Vec<_> = entries + .unwrap_or_else(|| list_dir_entries(parent).unwrap_or_default()) + .into_iter() + .filter_map(|(raw, _)| { + let path = parent.join(raw); + let lang = detect_lang(&path)?; + (path != file_path).then_some((path, lang)) + }) + .collect(); + let omitted = mates.len().saturating_sub(MAX_CALLER_SCAN_FILES); + let mut callers = BTreeSet::new(); + for (mate_path, mate_lang) in mates.into_iter().take(MAX_CALLER_SCAN_FILES) { + if super::file::enforce_read_denylist(&mate_path, "read").is_err() { + continue; + } + let Ok(selected_path) = mate_path.canonicalize() else { + continue; + }; + if workspace_relative(workspace, &selected_path).is_none() { + continue; + } + if super::file::is_codewhale_credential_path(&selected_path) + || super::file::enforce_read_denylist(&selected_path, "read").is_err() + { + continue; + } + let Ok(file) = crate::external_credentials::open_secure_regular_file(&selected_path, false) + else { + continue; + }; + if crate::plugins::registry::validate_opened_regular_file(&selected_path, &file).is_err() + || !file + .metadata() + .is_ok_and(|meta| meta.len() <= MAX_CALLER_SCAN_BYTES) + { + continue; + } + let mut text = String::new(); + if file + .take(MAX_CALLER_SCAN_BYTES + 1) + .read_to_string(&mut text) + .is_err() + || text.len() as u64 > MAX_CALLER_SCAN_BYTES + { + continue; + } + let (links, _) = sound_links( + workspace, + &selected_path, + &text, + mate_lang, + None, + Some(&own), + ); + if links.iter().any(|link| link == &own) + && let Some(rel) = workspace_relative(workspace, &selected_path) + { + callers.insert(rel); + } + } + let total = callers.len(); + (callers.into_iter().collect(), total, omitted) +} + +fn truncate_name(head: &str) -> String { + if head.chars().count() <= MAX_NAME_LEN { + return head.to_string(); + } + let kept: String = head.chars().take(MAX_NAME_LEN).collect(); + format!("{kept}…") +} + +fn count_noun(count: usize, singular: &str, plural: &str) -> String { + if count == 1 { + format!("1 {singular}") + } else { + format!("{count} {plural}") + } +} + +fn render_capped(items: &[String], total: usize) -> String { + let mut out = items.join(", "); + if total > items.len() { + if !out.is_empty() { + out.push_str(", "); + } + out.push_str(&format!("+{} more", total - items.len())); + } + out +} + +/// Hard byte cap at a char boundary, with an ellipsis marker when cut. +fn truncate_bytes(text: &str, max_bytes: usize) -> String { + if text.len() <= max_bytes { + return text.to_string(); + } + let mut end = max_bytes.saturating_sub("…".len()); + while !text.is_char_boundary(end) { + end = end.saturating_sub(1); + } + format!("{}…", &text[..end]) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::time::{Duration, Instant}; + + fn sound_import_roots(text: &str, lang: SoundLang) -> BTreeSet { + let dir = tempfile::tempdir().expect("tempdir"); + let mut roots = BTreeSet::new(); + sound_links( + dir.path(), + &dir.path().join("probe.rs"), + text, + lang, + Some(&mut roots), + None, + ); + roots + } + + fn open_visibility(glob_root: &Path) -> PodVisibility<'_> { + PodVisibility { + include: &[], + exclude: &[], + extensions: &[], + glob_root, + gitignore: false, + } + } + + #[test] + fn miss_echo_names_parent_mates_for_typoed_paths() { + let dir = tempfile::tempdir().expect("tempdir"); + for name in ["alpha.rs", "beta.rs", "gamma.rs"] { + fs::write(dir.path().join(name), "x").expect("fixture"); + } + let echo = sound_miss_echo(&dir.path().join("alpah.rs")).expect("echo"); + assert!(echo.starts_with("\n\n[Miss echo: "), "{echo}"); + assert!(echo.contains("alpha.rs"), "{echo}"); + assert!(echo.contains("beta.rs"), "{echo}"); + assert!(echo.contains("gamma.rs"), "{echo}"); + assert!(!echo.contains("alpah.rs"), "{echo}"); + } + + #[test] + fn miss_echo_caps_mates_and_reports_the_rest() { + let dir = tempfile::tempdir().expect("tempdir"); + for i in 0..12 { + fs::write(dir.path().join(format!("f{i:02}.rs")), "x").expect("fixture"); + } + let echo = sound_miss_echo(&dir.path().join("nope.rs")).expect("echo"); + assert!(echo.contains("+4 more"), "{echo}"); + assert!(echo.len() < 600, "miss echo stays small: {echo}"); + } + + #[test] + fn miss_echo_is_none_when_parent_is_unlistable() { + let dir = tempfile::tempdir().expect("tempdir"); + let missing = dir.path().join("no-such-dir").join("nope.rs"); + assert_eq!(sound_miss_echo(&missing), None); + } + + const SAMPLE: &str = r#"use std::collections::BTreeSet; +use std::path::Path; +use crate::tools::echolocation::sound_echolocation; +use super::MAX_SYMBOLS; +pub use serde_json::Value as JsonValue; +use tokio::fs::{ + self, + File, +}; + +// a comment mentioning fn ghost +/// doc comment: struct Phantom +/* block comment: enum Shade */ +#[derive(Debug)] +pub struct Whale { + name: String, +} + +pub(crate) async fn swim() {} +unsafe extern "C" fn breach() {} +const DEPTH: u32 = 11_000; +static CURRENT: &str = "cold"; +type Pod = Vec; +trait Navigable {} +impl Navigable for Whale {} +impl Whale { + fn dive(&self) {} +} +mod abyss; +macro_rules! song { + () => {}; +} +fnx not_an_item; +define nothing; +"#; + + #[test] + fn items_sound_in_file_order() { + let symbols = sound_symbols(SAMPLE, SoundLang::Rust); + let names: Vec<&str> = symbols.iter().map(String::as_str).collect(); + assert_eq!( + names, + [ + "struct Whale:15", + "fn swim:19", + "fn breach:20", + "const DEPTH:21", + "static CURRENT:22", + "type Pod:23", + "trait Navigable:24", + "impl Navigable for Whale:25", + "impl Whale:26", + "fn dive:27", + "mod abyss:29", + "macro song:30", + ] + ); + } + + #[test] + fn import_roots_dedupe_and_sort() { + let roots: Vec = sound_import_roots(SAMPLE, SoundLang::Rust) + .into_iter() + .collect(); + assert_eq!(roots, ["crate", "serde_json", "std", "super", "tokio"]); + } + + #[test] + fn block_comments_stay_silent_across_lines() { + let text = "/*\nfn hidden_one() {}\nuse hidden::root;\n*/\nfn visible() {}\n"; + assert_eq!(sound_symbols(text, SoundLang::Rust), ["fn visible:5"]); + assert!(sound_import_roots(text, SoundLang::Rust).is_empty()); + } + + #[test] + fn code_after_a_closed_comment_still_sounds() { + assert_eq!( + sound_symbols("/* note */ fn after() {}\n", SoundLang::Rust), + ["fn after:1"] + ); + assert_eq!( + sound_symbols("let x = 1; /* fn nope() {} */\n", SoundLang::Rust), + Vec::::new() + ); + } + + #[test] + fn podmates_sort_mark_and_cap() { + let dir = tempfile::tempdir().expect("tempdir"); + for name in ["zebra.rs", "alpha.rs", "main.rs", ".hidden", "beta"] { + fs::write(dir.path().join(name), "x").expect("fixture"); + } + fs::create_dir(dir.path().join("shoal")).expect("subdir"); + let chart = sound_echolocation( + dir.path(), + &dir.path().join("main.rs"), + "fn main() {}\n", + false, + None, + ); + assert_eq!(chart.podmate_total, 5); + assert_eq!( + chart.podmates, + [ + "alpha.rs", + "beta", + "main.rs (this file)", + "shoal/", + "zebra.rs", + ] + ); + } + + #[test] + fn long_pods_truncate_with_a_count() { + let dir = tempfile::tempdir().expect("tempdir"); + for index in 0..20 { + fs::write(dir.path().join(format!("file-{index:02}.rs")), "x").expect("fixture"); + } + let chart = sound_echolocation( + dir.path(), + &dir.path().join("file-00.rs"), + "x\n", + false, + None, + ); + assert_eq!(chart.podmate_total, 20); + assert_eq!(chart.podmates.len(), MAX_PODMATES); + let footer = chart.render_footer(); + assert!(footer.contains("+8 more"), "{footer}"); + assert!(footer.len() <= POD_FOOTER_MAX_BYTES); + } + + #[test] + fn footer_is_deterministic_and_budgeted() { + let dir = tempfile::tempdir().expect("tempdir"); + let long = "n".repeat(200); + let mut text = String::new(); + for index in 0..100 { + text.push_str(&format!("fn {long}_{index}() {{}}\n")); + } + fs::write(dir.path().join("big.rs"), &text).expect("fixture"); + let path = dir.path().join("big.rs"); + let first = sound_echolocation(dir.path(), &path, &text, false, None).render_footer(); + let second = sound_echolocation(dir.path(), &path, &text, false, None).render_footer(); + assert_eq!(first, second); + assert!(first.len() <= POD_FOOTER_MAX_BYTES, "{}", first.len()); + assert!(first.starts_with("\n\n[Pod ("), "{first}"); + } + + #[test] + fn prose_files_get_podmates_but_no_sounding() { + let dir = tempfile::tempdir().expect("tempdir"); + fs::write(dir.path().join("notes.txt"), "fn not_rust() {}\n").expect("fixture"); + let footer = sound_echolocation( + dir.path(), + &dir.path().join("notes.txt"), + "fn not_rust() {}\n", + false, + None, + ) + .render_footer(); + assert!(footer.contains("[Pod ("), "{footer}"); + assert!(!footer.contains("[Sound:"), "{footer}"); + } + + #[test] + fn unlistable_non_source_renders_empty() { + let chart = sound_echolocation(Path::new("."), Path::new("lonely"), "hello\n", false, None); + assert_eq!(chart.render_footer(), ""); + } + + #[test] + fn whole_footer_shape() { + let dir = tempfile::tempdir().expect("tempdir"); + fs::write(dir.path().join("lib.rs"), SAMPLE).expect("fixture"); + fs::write(dir.path().join("main.rs"), "fn main() {}\n").expect("fixture"); + let footer = + sound_echolocation(dir.path(), &dir.path().join("lib.rs"), SAMPLE, false, None) + .render_footer(); + assert!(footer.contains("12 symbols"), "{footer}"); + assert!(footer.contains("fn swim"), "{footer}"); + assert!( + footer.contains("imports: crate, serde_json, std, super, tokio"), + "{footer}" + ); + assert!(footer.contains("lib.rs (this file), main.rs"), "{footer}"); + } + + #[test] + fn match_pods_group_mark_and_sort() { + let dir = tempfile::tempdir().expect("tempdir"); + fs::create_dir(dir.path().join("net")).expect("subdir"); + fs::create_dir(dir.path().join("store")).expect("subdir"); + for name in [ + "net/server.rs", + "net/client.rs", + "store/cache.rs", + "main.rs", + ] { + fs::write(dir.path().join(name), "x").expect("fixture"); + } + let vis = open_visibility(dir.path()); + let (pods, omitted) = sound_match_pods( + dir.path(), + &["store/cache.rs".to_string(), "net/server.rs".to_string()], + &vis, + ); + assert_eq!(omitted, 0); + assert_eq!(pods.len(), 2); + assert_eq!(pods[0].dir, "net"); + assert_eq!(pods[0].mates, ["client.rs", "server.rs (match)"]); + assert_eq!(pods[1].dir, "store"); + assert_eq!(pods[1].mates, ["cache.rs (match)"]); + } + + #[test] + fn match_pods_cap_dirs_and_mates() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut files = Vec::new(); + for index in 0..12 { + let sub = format!("pod-{index:02}"); + fs::create_dir(dir.path().join(&sub)).expect("subdir"); + for mate in 0..12 { + fs::write(dir.path().join(&sub).join(format!("m{mate}.rs")), "x").expect("fixture"); + } + files.push(format!("{sub}/m0.rs")); + } + let vis = open_visibility(dir.path()); + let (pods, omitted) = sound_match_pods(dir.path(), &files, &vis); + assert_eq!(pods.len(), MAX_GREP_PODS); + assert_eq!(omitted, 12 - MAX_GREP_PODS); + assert_eq!(pods[0].mate_total, 12); + assert!(pods[0].mates.last().expect("cap").starts_with("+4 more")); + } + + #[test] + fn match_pods_chart_matched_files_when_dir_unlistable() { + let dir = tempfile::tempdir().expect("tempdir"); + let vis = open_visibility(dir.path()); + let (pods, omitted) = sound_match_pods( + dir.path(), + &["gone/deep.rs".to_string(), "".to_string()], + &vis, + ); + assert_eq!(omitted, 0); + assert_eq!(pods.len(), 1); + assert_eq!(pods[0].dir, "gone"); + assert_eq!(pods[0].mates, ["deep.rs (match)"]); + } + + #[test] + fn match_pods_honor_excludes_and_includes() { + let dir = tempfile::tempdir().expect("tempdir"); + for name in ["keep.rs", "skip.log", "notes.md"] { + fs::write(dir.path().join(name), "x").expect("fixture"); + } + let open = open_visibility(dir.path()); + let exclude = ["*.log".to_string()]; + let vis = PodVisibility { + exclude: &exclude, + ..open + }; + let (pods, _) = sound_match_pods(dir.path(), &["keep.rs".to_string()], &vis); + assert_eq!(pods[0].mates, ["keep.rs (match)", "notes.md"]); + let include = ["*.rs".to_string()]; + let vis = PodVisibility { + include: &include, + ..open + }; + let (pods, _) = sound_match_pods(dir.path(), &["keep.rs".to_string()], &vis); + assert_eq!(pods[0].mates, ["keep.rs (match)"]); + } + + #[test] + fn match_pods_honor_gitignore_stacking() { + let dir = tempfile::tempdir().expect("tempdir"); + fs::write(dir.path().join(".gitignore"), "ignored.txt\n").expect("fixture"); + for name in ["ignored.txt", "keep.txt"] { + fs::write(dir.path().join(name), "x").expect("fixture"); + } + let open = open_visibility(dir.path()); + let vis = PodVisibility { + gitignore: true, + ..open + }; + let (pods, _) = sound_match_pods(dir.path(), &["keep.txt".to_string()], &vis); + assert_eq!(pods[0].mates, ["keep.txt (match)"]); + } + + #[test] + fn match_pods_hide_denied_siblings() { + let dir = tempfile::tempdir().expect("tempdir"); + let secret = dir.path().join("secret.txt"); + for name in ["open.txt", "secret.txt"] { + fs::write(dir.path().join(name), "x").expect("fixture"); + } + let prior = crate::sandbox::read_guard::active(); + crate::sandbox::read_guard::set_active(crate::sandbox::read_guard::ReadDenylist::build( + true, + &[secret], + &[], + )); + let vis = open_visibility(dir.path()); + let (pods, _) = sound_match_pods(dir.path(), &["open.txt".to_string()], &vis); + crate::sandbox::read_guard::set_active((*prior).clone()); + assert_eq!(pods[0].mates, ["open.txt (match)"]); + } + + #[test] + fn key_files_sound_rust_and_skip_the_rest() { + let dir = tempfile::tempdir().expect("tempdir"); + fs::write(dir.path().join("main.rs"), "fn main() {}\nfn serve() {}\n").expect("fixture"); + fs::write(dir.path().join("notes.md"), "# hi\n").expect("fixture"); + let (soundings, omitted) = + sound_key_files(dir.path(), &["notes.md".to_string(), "main.rs".to_string()]); + assert_eq!(omitted, 0); + assert_eq!(soundings.len(), 1); + assert_eq!( + soundings.get("main.rs").map(String::as_str), + Some("2 symbols: fn main:1, fn serve:2") + ); + } + + #[test] + fn links_resolve_crate_super_self_and_mod_edges() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + for path in [ + "src/main.rs", + "src/config.rs", + "src/net/mod.rs", + "src/net/server.rs", + "src/net/http.rs", + ] { + let full = root.join(path); + fs::create_dir_all(full.parent().expect("parent")).expect("mkdir"); + fs::write(&full, "x\n").expect("fixture"); + } + // crate:: + super:: + external (skipped) + missing (skipped). + let text = "use crate::config::Config;\n\ + use crate::net::http::Handler;\n\ + use super::server::serve;\n\ + use tokio::fs::File;\n\ + use crate::missing::Thing;\n"; + let (links, total) = sound_links( + root, + &root.join("src/net/http.rs"), + text, + SoundLang::Rust, + None, + None, + ); + // crate::net::http resolves to the file itself: dropped as a self-link. + assert_eq!(total, 2); + assert_eq!(links, ["src/config.rs", "src/net/server.rs"]); + // self:: + mod decl from a mod.rs. + let (links, _) = sound_links( + root, + &root.join("src/net/mod.rs"), + "use self::http::Handler;\nmod server;\nmod absent;\n", + SoundLang::Rust, + None, + None, + ); + assert_eq!(links, ["src/net/http.rs", "src/net/server.rs"]); + // mod decl from a plain file resolves under /. + fs::create_dir_all(root.join("src/net/http")).expect("mkdir"); + fs::write(root.join("src/net/http/router.rs"), "x\n").expect("fixture"); + let (links, _) = sound_links( + root, + &root.join("src/net/http.rs"), + "mod router;\nmod nope;\n", + SoundLang::Rust, + None, + None, + ); + assert_eq!(links, ["src/net/http/router.rs"]); + } + + #[test] + fn links_skip_self_and_inline_mods() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + fs::create_dir_all(root.join("src")).expect("mkdir"); + fs::write(root.join("src/main.rs"), "fn main() {}\n").expect("fixture"); + fs::write(root.join("src/config.rs"), "pub struct Config;\n").expect("fixture"); + // `use crate::config` from config.rs itself: self-link dropped. + // Inline `mod tests {` is a symbol, never an edge. + let (links, total) = sound_links( + root, + &root.join("src/config.rs"), + "use crate::config::Config;\nmod tests {\n}\n", + SoundLang::Rust, + None, + None, + ); + assert_eq!(total, 0); + assert!(links.is_empty()); + } + + #[test] + fn links_render_in_footer() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + fs::create_dir_all(root.join("src")).expect("mkdir"); + fs::write(root.join("src/main.rs"), "fn main() {}\n").expect("fixture"); + let footer = sound_echolocation( + root, + &root.join("src/main.rs"), + "use crate::config::Config;\nfn main() {}\n", + false, + None, + ) + .render_footer(); + // config.rs does not exist: imports show, links stay silent. + assert!(footer.contains("imports: crate"), "{footer}"); + assert!(!footer.contains("links:"), "{footer}"); + fs::write(root.join("src/config.rs"), "pub struct Config;\n").expect("fixture"); + let footer = sound_echolocation( + root, + &root.join("src/main.rs"), + "use crate::config::Config;\nfn main() {}\n", + false, + None, + ) + .render_footer(); + assert!(footer.contains("links: config.rs"), "{footer}"); + } + + #[test] + fn shadowed_import_roots_drop_by_stem_and_package_path() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + let text = "import a\nimport pkg\n"; + fs::write(root.join("main.py"), text).expect("fixture"); + fs::write(root.join("a.py"), "x = 1\n").expect("fixture"); + fs::create_dir(root.join("pkg")).expect("mkdir"); + fs::write(root.join("pkg/__init__.py"), "y = 2\n").expect("fixture"); + let footer = + sound_echolocation(root, &root.join("main.py"), text, false, None).render_footer(); + // Both roots are named by their links: file stem (`a` under + // `a.py`) and package path (`pkg` under `pkg/__init__.py`). + // Sounded paths are `/`-separated on every OS; vacuous on + // unix, load-bearing on Windows CI. + assert!(!footer.contains("imports:"), "{footer}"); + assert!(footer.contains("links: a.py, pkg/__init__.py"), "{footer}"); + assert!(!footer.contains('\\'), "{footer}"); + } + + #[test] + fn key_files_cap_with_omitted_count() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut keys = Vec::new(); + for index in 0..12 { + let name = format!("k{index:02}.rs"); + fs::write(dir.path().join(&name), "fn f() {}\n").expect("fixture"); + keys.push(name); + } + let (soundings, omitted) = sound_key_files(dir.path(), &keys); + assert_eq!(soundings.len(), MAX_MAP_SOUNDINGS); + assert_eq!(omitted, 12 - MAX_MAP_SOUNDINGS); + let again = sound_key_files(dir.path(), &keys); + assert_eq!(again, (soundings, omitted)); + } + + #[test] + fn callers_are_exact_reverse_links() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + fs::create_dir_all(root.join("src/net")).expect("mkdir"); + fs::write(root.join("src/main.rs"), "fn main() {}\n").expect("fixture"); + fs::write( + root.join("src/net/mod.rs"), + "pub mod server;\npub mod client;\n", + ) + .expect("fixture"); + fs::write( + root.join("src/net/server.rs"), + "use crate::config::Config;\nfn serve() {}\n", + ) + .expect("fixture"); + fs::write( + root.join("src/net/client.rs"), + "use super::server::serve;\nfn connect() {}\n", + ) + .expect("fixture"); + let (callers, total, omitted) = sound_callers(root, &root.join("src/net/server.rs"), None); + assert_eq!(omitted, 0); + assert_eq!(total, 2); + assert_eq!(callers, ["src/net/client.rs", "src/net/mod.rs"]); + for n in 0..=MAX_CALLER_SCAN_FILES { + fs::write(root.join(format!("src/net/z{n:03}.rs")), "fn spare() {}\n") + .expect("fixture"); + } + let (bounded, bounded_total, omitted) = + sound_callers(root, &root.join("src/net/server.rs"), None); + assert_eq!(bounded, callers); + assert_eq!(bounded_total, total); + assert_eq!(omitted, 3); + } + + #[test] + fn callers_stay_inside_the_pod() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + fs::create_dir_all(root.join("src/net")).expect("mkdir"); + fs::write(root.join("src/main.rs"), "fn main() {}\n").expect("fixture"); + fs::write(root.join("src/config.rs"), "pub struct Config;\n").expect("fixture"); + fs::write( + root.join("src/net/server.rs"), + "use crate::config::Config;\nfn serve() {}\n", + ) + .expect("fixture"); + // server.rs links to config.rs, but lives in another pod: + // same-pod callers stay silent rather than half-answer. + let (callers, total, omitted) = sound_callers(root, &root.join("src/config.rs"), None); + assert_eq!(omitted, 0); + assert_eq!(total, 0); + assert!(callers.is_empty()); + } + + #[test] + fn buzz_adds_heard_by_and_stays_deterministic() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + fs::create_dir_all(root.join("src/net")).expect("mkdir"); + fs::write(root.join("src/main.rs"), "fn main() {}\n").expect("fixture"); + fs::write(root.join("src/net/mod.rs"), "pub mod server;\n").expect("fixture"); + let text = "fn serve() {}\n"; + fs::write(root.join("src/net/server.rs"), text).expect("fixture"); + let plain = sound_echolocation(root, &root.join("src/net/server.rs"), text, false, None) + .render_footer(); + assert!(!plain.contains("heard by"), "{plain}"); + let first = sound_echolocation(root, &root.join("src/net/server.rs"), text, true, None) + .render_footer(); + let second = sound_echolocation(root, &root.join("src/net/server.rs"), text, true, None) + .render_footer(); + assert_eq!(first, second); + assert!(first.contains("heard by: mod.rs"), "{first}"); + } + + #[test] + fn echo_gates_parse_off_entries_and_ignore_noise() { + assert!(parse_echo_gates("").is_empty()); + assert!(parse_echo_gates("mates").is_empty()); + let gates = parse_echo_gates("off:links, off:mates ,bogus,off:,off:links"); + assert_eq!( + gates.into_iter().collect::>(), + ["links".to_string(), "mates".to_string()] + ); + } + + const PY_SAMPLE: &str = r#"import os, sys as system +from pkg.sub import thing +from . import sibling +from ..parent import helper +import localmod + +"""Module docstring mentioning def ghost.""" + +class Whale: + """Docstring with class Phantom inside.""" + + def swim(self): + pass + + async def dive(self): + pass + +def surface(): + # def sunk: + return 1 + +define_not_a_keyword = True +"#; + + #[test] + fn python_sounds_defs_classes_and_imports() { + let symbols = sound_symbols(PY_SAMPLE, SoundLang::Python); + assert_eq!( + symbols, + [ + "class Whale:9", + "def swim:12", + "def dive:15", + "def surface:18", + ] + ); + let roots: Vec = sound_import_roots(PY_SAMPLE, SoundLang::Python) + .into_iter() + .collect(); + assert_eq!(roots, ["localmod", "os", "parent", "pkg", "sibling", "sys"]); + } + + #[test] + fn python_links_resolve_relative_and_absolute() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + for path in [ + "app.py", + "util.py", + "pkg/__init__.py", + "pkg/mod.py", + "pkg/sub/__init__.py", + "pkg/sub/deep.py", + ] { + let full = root.join(path); + fs::create_dir_all(full.parent().expect("parent")).expect("mkdir"); + fs::write(&full, "x\n").expect("fixture"); + } + // Absolute: same dir wins; package shape at root. + let (links, _) = sound_links( + root, + &root.join("app.py"), + "import util\nfrom pkg.sub import thing\nimport os\n", + SoundLang::Python, + None, + None, + ); + assert_eq!(links, ["pkg/sub/__init__.py", "util.py"]); + // Relative: `from . import sib` + `from .. import x`. + let (links, _) = sound_links( + root, + &root.join("pkg/sub/deep.py"), + "from . import sib\nfrom .. import mod\nfrom ... import nowhere\n", + SoundLang::Python, + None, + None, + ); + assert_eq!(links, ["pkg/mod.py"]); + } + + const JS_SAMPLE: &str = r#"import React from 'react'; +import { serve } from "./server"; +import '@scope/pkg/sub'; +import './setup'; +const fs = require("fs"); +const dynamic = await import("./chunk"); + +export async function swim() {} +export default class Whale {} +const dive = (x) => x; +let data = 5; +type Alias = string; +interface Shape {} +enum Color { Red } + +if (x) { y(); } +"#; + + #[test] + fn js_sounds_functions_classes_and_imports() { + let symbols = sound_symbols(JS_SAMPLE, SoundLang::JavaScript); + assert!( + symbols.contains(&"function swim:8".to_string()), + "{symbols:?}" + ); + assert!( + symbols.contains(&"class Whale:9".to_string()), + "{symbols:?}" + ); + assert!( + symbols.contains(&"const dive:10".to_string()), + "{symbols:?}" + ); + assert!( + symbols.contains(&"type Alias:12".to_string()), + "{symbols:?}" + ); + assert!( + symbols.contains(&"interface Shape:13".to_string()), + "{symbols:?}" + ); + assert!( + symbols.contains(&"enum Color:14".to_string()), + "{symbols:?}" + ); + assert!(!symbols.iter().any(|s| s.contains("data")), "{symbols:?}"); + let roots: Vec = sound_import_roots(JS_SAMPLE, SoundLang::JavaScript) + .into_iter() + .collect(); + assert_eq!( + roots, + ["@scope/pkg", "chunk", "fs", "react", "server", "setup"] + ); + } + + #[test] + fn js_links_resolve_relative_only() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + fs::create_dir_all(root.join("src/ui")).expect("mkdir"); + for path in ["src/app.ts", "src/server.ts", "src/ui/index.tsx"] { + fs::write(root.join(path), "x\n").expect("fixture"); + } + let (links, _) = sound_links( + root, + &root.join("src/app.ts"), + "import { s } from './server';\nimport { U } from './ui';\nimport _ from 'lodash';\n", + SoundLang::JavaScript, + None, + None, + ); + assert_eq!(links, ["src/server.ts", "src/ui/index.tsx"]); + } + + const GO_SAMPLE: &str = r#"package net + +import ( + "fmt" +) + +import "os" +import alias "example.com/foo/bar" +import _ "example.com/foo/side" + +func Serve() {} +func (s *Server) Handle() {} +func (s Server) Close() {} +type Server struct{} +type ( + Grouped int +) + +func main() { + fmt.Println("hi") + return "literal" +} +"#; + + #[test] + fn go_sounds_funcs_methods_types_and_imports() { + let symbols = sound_symbols(GO_SAMPLE, SoundLang::Go); + assert!( + symbols.contains(&"func Serve:11".to_string()), + "{symbols:?}" + ); + assert!( + symbols.contains(&"func Server.Handle:12".to_string()), + "{symbols:?}" + ); + assert!( + symbols.contains(&"func Server.Close:13".to_string()), + "{symbols:?}" + ); + assert!( + symbols.contains(&"type Server:14".to_string()), + "{symbols:?}" + ); + assert!( + !symbols.iter().any(|s| s.contains("Grouped")), + "{symbols:?}" + ); + let roots: Vec = sound_import_roots(GO_SAMPLE, SoundLang::Go) + .into_iter() + .collect(); + assert_eq!(roots, ["bar", "fmt", "os", "side"]); + // Go names packages, not files: no links by design. + let dir = tempfile::tempdir().expect("tempdir"); + let (links, total) = sound_links( + dir.path(), + &dir.path().join("x.go"), + GO_SAMPLE, + SoundLang::Go, + None, + None, + ); + assert_eq!(total, 0); + assert!(links.is_empty()); + } + + #[test] + fn detect_lang_routes_by_extension() { + assert_eq!(detect_lang(Path::new("a.rs")), Some(SoundLang::Rust)); + assert_eq!(detect_lang(Path::new("a.py")), Some(SoundLang::Python)); + assert_eq!(detect_lang(Path::new("a.pyi")), Some(SoundLang::Python)); + assert_eq!(detect_lang(Path::new("a.ts")), Some(SoundLang::JavaScript)); + assert_eq!(detect_lang(Path::new("a.tsx")), Some(SoundLang::JavaScript)); + assert_eq!(detect_lang(Path::new("a.jsx")), Some(SoundLang::JavaScript)); + assert_eq!(detect_lang(Path::new("a.mjs")), Some(SoundLang::JavaScript)); + assert_eq!(detect_lang(Path::new("a.go")), Some(SoundLang::Go)); + assert_eq!(detect_lang(Path::new("a.txt")), None); + assert_eq!(detect_lang(Path::new("Makefile")), None); + } + + #[test] + fn scanners_survive_adversarial_text() { + let long_line = "x".repeat(1_000_000); + let nasties = [ + "\0\0\0", + "fn \u{feff}\u{200b}\u{202e}evil() {}", + "/* unterminated", + "\"\"\"unterminated doc", + "def f(:\n pass", + "use \u{1f980}::x;", + "import \u{1f980}", + "func (((((", + "/* /* /* nested", + "'''\"\"\"'\"\" mixed", + "#\u{0}def fake():", + "\r\ndef crlf():\r\n", + "type (", + "export default", + "from", + "import", + long_line.as_str(), + ]; + for lang in [ + SoundLang::Rust, + SoundLang::Python, + SoundLang::JavaScript, + SoundLang::Go, + ] { + for nasty in nasties { + let first = sound_symbols(nasty, lang); + let second = sound_symbols(nasty, lang); + assert_eq!(first, second, "{lang:?} {nasty:?}"); + let _ = sound_import_roots(nasty, lang); + } + } + } + + #[test] + fn sounding_stays_bounded_and_fast_on_huge_files() { + let dir = tempfile::tempdir().expect("tempdir"); + let big = "fn f() {}\n".repeat(200_000); + let path = dir.path().join("big.rs"); + fs::write(&path, &big).expect("fixture"); + let start = Instant::now(); + let first = sound_echolocation(dir.path(), &path, &big, true, None).render_footer(); + let second = sound_echolocation(dir.path(), &path, &big, true, None).render_footer(); + assert_eq!(first, second); + assert!(first.len() <= POD_FOOTER_MAX_BYTES, "{}", first.len()); + // Absurdly generous: catches hangs and super-linear blowups, + // not machine speed (measured separately for the thesis). + assert!( + start.elapsed() < Duration::from_secs(10), + "{:?}", + start.elapsed() + ); + } + + #[test] + fn sounding_is_stable_on_real_repo_files() { + // Byte-stability on the real thing (not just fixtures), with + // wall-time reported for cost analysis (`--nocapture`). + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let mut sounded = 0; + for rel in [ + "src/core/engine/turn_loop.rs", + "src/tools/spec.rs", + "src/tools/echolocation.rs", + ] { + let path = root.join(rel); + let text = fs::read_to_string(&path).expect("repo file"); + let first = sound_echolocation(root, &path, &text, true, None).render_footer(); + let second = sound_echolocation(root, &path, &text, true, None).render_footer(); + assert_eq!(first, second, "{rel}"); + assert!(first.len() <= POD_FOOTER_MAX_BYTES, "{rel}"); + sounded += 1; + } + assert_eq!(sounded, 3); + } + + #[test] + fn non_rust_footer_renders_full_sounding() { + let dir = tempfile::tempdir().expect("tempdir"); + fs::write(dir.path().join("swim.py"), PY_SAMPLE).expect("fixture"); + fs::write(dir.path().join("other.py"), "x = 1\n").expect("fixture"); + let footer = sound_echolocation( + dir.path(), + &dir.path().join("swim.py"), + PY_SAMPLE, + false, + None, + ) + .render_footer(); + assert!(footer.contains("[Pod ("), "{footer}"); + assert!(footer.contains("def swim"), "{footer}"); + assert!(footer.contains("imports:"), "{footer}"); + } + + #[test] + fn crate_root_self_and_mod_edges_resolve_beside_the_root() { + let dir = tempfile::tempdir().expect("tempdir"); + fs::write( + dir.path().join("lib.rs"), + "mod pod;\nuse self::song::Sing;\n", + ) + .expect("fixture"); + fs::write(dir.path().join("pod.rs"), "fn swim() {}\n").expect("fixture"); + fs::create_dir(dir.path().join("song")).expect("mkdir"); + fs::write(dir.path().join("song/mod.rs"), "fn sing() {}\n").expect("fixture"); + let text = fs::read_to_string(dir.path().join("lib.rs")).expect("read"); + let (links, _) = sound_links( + dir.path(), + &dir.path().join("lib.rs"), + &text, + SoundLang::Rust, + None, + None, + ); + assert!(links.contains(&"pod.rs".to_string()), "{links:?}"); + assert!(links.contains(&"song/mod.rs".to_string()), "{links:?}"); + } + + #[test] + fn earliest_quote_wins_the_specifier() { + assert_eq!( + js_import_spec("import x from \"mod\"; foo('bar')"), + Some("mod".to_string()) + ); + assert_eq!( + js_import_spec("import x from 'mod'; foo(\"bar\")"), + Some("mod".to_string()) + ); + assert_eq!( + js_import_spec("import 'side-effect'"), + Some("side-effect".to_string()) + ); + } + + #[test] + fn require_opener_needs_a_boundary() { + // `myrequire(` forges nothing … + assert_eq!(js_import_spec("const x = myrequire('a')"), None); + // … nor does a call spelled inside a string … + assert_eq!(js_import_spec("const s = \"require('a')\""), None); + // … but a real call later on the same line still sounds … + assert_eq!( + js_import_spec("myrequire('a'); const x = require('b')"), + Some("b".to_string()) + ); + // … as does a computed argument followed by a real one. + assert_eq!( + js_import_spec("require(name); require('c')"), + Some("c".to_string()) + ); + } + + #[test] + fn digit_led_tokens_are_never_names() { + assert!(sound_symbols("fn 1x() {}\nmod 2y;\n", SoundLang::Rust).is_empty()); + assert!(sound_symbols("def 2y():\n", SoundLang::Python).is_empty()); + assert!(sound_import_roots("use 123;\n", SoundLang::Rust).is_empty()); + assert_eq!( + sound_symbols("fn fine() {}\n", SoundLang::Rust), + ["fn fine:1"] + ); + } + + #[test] + fn minified_runs_never_blow_up_a_symbol() { + let run = "a".repeat(10_000); + let symbols = sound_symbols(&format!("fn {run}() {{}}\n"), SoundLang::Rust); + assert_eq!(symbols.len(), 1); + assert!(symbols[0].len() <= MAX_NAME_LEN + 8, "{}", symbols[0].len()); + let js = sound_symbols(&format!("function {run}() {{}}\n"), SoundLang::JavaScript); + assert!(js.is_empty(), "{js:?}"); + } + + #[test] + fn buzz_read_of_a_crowded_pod_stays_fast() { + // Worst-case shape: 200 mates × 50KB each, all re-scanned for + // callers on every buzz read. The 5s bound is orders past the + // expected tens of milliseconds — it trips only on pathological + // regression, never on a loaded machine. + let dir = tempfile::tempdir().expect("tempdir"); + let body = "fn swim() {}\n".repeat(4_000); + for i in 0..200 { + fs::write(dir.path().join(format!("mate{i:03}.rs")), &body).expect("fixture"); + } + let target = dir.path().join("target.rs"); + fs::write(&target, "fn target() {}\n").expect("fixture"); + let text = fs::read_to_string(&target).expect("read"); + let start = Instant::now(); + let chart = sound_echolocation(dir.path(), &target, &text, true, None); + let footer = chart.render_footer(); + let elapsed = start.elapsed(); + assert!(footer.len() <= POD_FOOTER_MAX_BYTES); + assert!( + elapsed < Duration::from_secs(5), + "buzz read took {elapsed:?}" + ); + } + + #[test] + #[cfg(unix)] + fn pod_listing_skips_symlinks_into_denied_trees() { + // The denylist filter is distinct from the dotfile skip: a + // non-dot symlink into a denied tree must not enumerate. Needs a + // real ~/.ssh to resolve against; honest skip otherwise (the + // read-level twin is + // `read_file_refuses_a_workspace_symlink_pointing_at_a_denied_tree`). + let _env_lock = crate::test_support::lock_test_env(); + let Some(home) = dirs::home_dir() else { + return; + }; + if !home.join(".ssh").is_dir() { + return; + } + let dir = tempfile::tempdir().expect("tempdir"); + std::os::unix::fs::symlink(home.join(".ssh"), dir.path().join("vault_link")) + .expect("symlink"); + fs::write(dir.path().join("app.rs"), "fn main() {}\n").expect("fixture"); + let entries = list_dir_entries(dir.path()).expect("listed"); + let raws: Vec<&str> = entries.iter().map(|(raw, _)| raw.as_str()).collect(); + assert!(raws.contains(&"app.rs"), "{raws:?}"); + assert!(!raws.contains(&"vault_link"), "{raws:?}"); + } + + #[test] + fn block_comments_mute_and_release_across_lines() { + let text = "/*\nfn fake() {}\n*/\nfn real() {}\n"; + let symbols = sound_symbols(text, SoundLang::Rust); + assert_eq!(symbols, ["fn real:4"]); + // A comment opener inside a string still mutes until the closer: + // the failure mode is a miss, never a forge. + let text = "let s = \"/*\";\nfn muted() {}\nlet t = \"*/\";\nfn kept() {}\n"; + let symbols = sound_symbols(text, SoundLang::Rust); + assert_eq!(symbols, ["fn kept:4"]); + } + + #[test] + fn py_decorators_methods_and_comment_fences() { + let text = "@route(\"/x\")\ndef handler():\n pass\nclass Pod:\n def swim(self):\n pass\n# a \"\"\" in a comment toggles nothing\ndef after():\n"; + let symbols = sound_symbols(text, SoundLang::Python); + assert!( + symbols.contains(&"def handler:2".to_string()), + "{symbols:?}" + ); + assert!(symbols.contains(&"class Pod:4".to_string()), "{symbols:?}"); + assert!(symbols.contains(&"def swim:5".to_string()), "{symbols:?}"); + assert!(symbols.contains(&"def after:8".to_string()), "{symbols:?}"); + } + + #[test] + fn js_anonymous_and_arrow_shapes() { + // Anonymous default export: no name to sound, no phantom. + assert!(sound_symbols("export default function() {}\n", SoundLang::JavaScript).is_empty()); + let symbols = sound_symbols( + "const swim = () => {};\nlet dive = async () => {};\nvar data = 42;\n", + SoundLang::JavaScript, + ); + assert!(symbols.contains(&"const swim:1".to_string()), "{symbols:?}"); + assert!(symbols.contains(&"let dive:2".to_string()), "{symbols:?}"); + assert!(!symbols.iter().any(|s| s.contains("data")), "{symbols:?}"); + } + + #[test] + fn edit_echo_names_touched_symbol_and_callers() { + let dir = tempfile::tempdir().expect("tempdir"); + fs::write(dir.path().join("a.py"), "import b\nprint(b.x)\n").expect("fixture"); + let before = "x = 1\ndef swim():\n return x\n"; + let after = "x = 1\ndef swim():\n return x + 1\n"; + fs::write(dir.path().join("b.py"), after).expect("fixture"); + let echo = + sound_edit_echo(dir.path(), &dir.path().join("b.py"), before, after).expect("echo"); + assert!(echo.contains("touched def swim:2"), "{echo}"); + assert!(echo.contains("heard by a.py"), "{echo}"); + } + + #[test] + fn edit_echo_attributes_body_edits_to_enclosing_symbol() { + let dir = tempfile::tempdir().expect("tempdir"); + // No mates: touched only, no heard-by half. + let before = "fn main() {\n let x = 1;\n println!(\"{x}\");\n}\nfn other() {}\n"; + let after = "fn main() {\n let x = 2;\n println!(\"{x}\");\n}\nfn other() {}\n"; + fs::write(dir.path().join("solo.rs"), after).expect("fixture"); + let echo = + sound_edit_echo(dir.path(), &dir.path().join("solo.rs"), before, after).expect("echo"); + assert_eq!(echo, "[Edit echo: touched fn main:1]"); + let deleted = touched_symbols( + &dir.path().join("solo.rs"), + "fn survivor() {}\nfn removed() {}\n", + "fn survivor() {}\n", + ); + assert_eq!(deleted, (vec!["fn removed:2".to_string()], 1)); + let replaced = touched_symbols( + &dir.path().join("solo.rs"), + "fn removed() {}\n", + "fn added() {}\n", + ); + assert_eq!( + replaced, + ( + vec!["fn removed:1".to_string(), "fn added:1".to_string()], + 2 + ) + ); + } + + #[test] + fn edit_echo_silent_when_nothing_to_say() { + let dir = tempfile::tempdir().expect("tempdir"); + fs::write(dir.path().join("note.txt"), "hi\n").expect("fixture"); + // Identical: no mutation, no echo. + assert!( + sound_edit_echo(dir.path(), &dir.path().join("note.txt"), "hi\n", "hi\n").is_none() + ); + // Unknown language, no mates: neither half can sound. + assert!( + sound_edit_echo(dir.path(), &dir.path().join("note.txt"), "hi\n", "yo\n").is_none() + ); + } + + #[test] + fn edit_echo_new_file_names_first_symbols() { + let dir = tempfile::tempdir().expect("tempdir"); + let after = "fn alpha() {}\nfn beta() {}\n"; + fs::write(dir.path().join("fresh.rs"), after).expect("fixture"); + let echo = + sound_edit_echo(dir.path(), &dir.path().join("fresh.rs"), "", after).expect("echo"); + assert!(echo.contains("touched fn alpha:1, fn beta:2"), "{echo}"); + } + + #[test] + fn unlistable_pod_fallback_skips_dotfiles_like_the_listed_path() { + let dir = tempfile::tempdir().expect("tempdir"); + let (pods, _) = sound_match_pods( + dir.path(), + &["nodir/.env".to_string(), "nodir/app.rs".to_string()], + &open_visibility(dir.path()), + ); + assert_eq!(pods.len(), 1); + assert!( + pods[0].mates.contains(&"app.rs (match)".to_string()), + "{:?}", + pods[0].mates + ); + assert!( + !pods[0].mates.iter().any(|mate| mate.contains(".env")), + "{:?}", + pods[0].mates + ); + } +} diff --git a/crates/tui/src/tools/file.rs b/crates/tui/src/tools/file.rs index f3c85e4e27..da91a1aa72 100644 --- a/crates/tui/src/tools/file.rs +++ b/crates/tui/src/tools/file.rs @@ -562,8 +562,9 @@ pub(crate) fn enforce_read_denylist(path: &Path, tool: &str) -> Result<(), ToolE pub(crate) fn is_codewhale_credential_path(path: &Path) -> bool { let candidate = canonical_path_for_credential_guard(path); - if let Ok(active_config) = codewhale_config::resolve_config_path(None) - && is_config_or_backup(&candidate, &active_config) + let active_config = codewhale_config::resolve_config_path(None).ok(); + if let Some(active_config) = &active_config + && is_config_or_backup(&candidate, active_config) { return true; } @@ -579,11 +580,24 @@ pub(crate) fn is_codewhale_credential_path(path: &Path) -> bool { let mut roots: Vec = Vec::with_capacity(3); roots.extend(codewhale_config::codewhale_home().ok()); roots.extend(codewhale_config::legacy_deepseek_home().ok()); - roots.extend( - codewhale_paths::user_home().map(|home| home.join(codewhale_config::CODEWHALE_APP_DIR)), - ); + // Without `CODEWHALE_HOME`, `codewhale_home()` already returned this exact + // ambient root, so pushing it again only re-canonicalizes identical + // config/backup/secrets paths. Keep it only when it adds a distinct root. + if let Some(ambient) = + codewhale_paths::user_home().map(|home| home.join(codewhale_config::CODEWHALE_APP_DIR)) + && !roots.contains(&ambient) + { + roots.push(ambient); + } for root in roots { - if is_config_or_backup(&candidate, &root.join(codewhale_config::CONFIG_FILE_NAME)) { + let config_path = root.join(codewhale_config::CONFIG_FILE_NAME); + // Only skip the exact config/backup pair already checked above. + // Distinct spellings still canonicalize; every secrets root is checked. + if active_config + .as_ref() + .is_none_or(|active| active.as_os_str() != config_path.as_os_str()) + && is_config_or_backup(&candidate, &config_path) + { return true; } @@ -818,6 +832,32 @@ async fn contract_mutation_result( ) -> ToolResult { let paths = [file_path.to_path_buf()]; let diagnostics = lsp_diagnostics_for_paths(context, &paths).await; + // Echolocation: the model-facing receipt is one line while the diff + // rides metadata for the TUI, so the echo tells the model what its + // edit hit (touched symbols) and what hears it (impacted callers). + // Deterministic, hard-budgeted, None when there is nothing to say. + let mut summary = summary; + let workspace = context.workspace.clone(); + let echo_path = file_path.to_path_buf(); + let echo_before = before.to_string(); + let echo_after = after.to_string(); + match tokio::task::spawn_blocking(move || { + crate::tools::echolocation::sound_edit_echo( + &workspace, + &echo_path, + &echo_before, + &echo_after, + ) + }) + .await + { + Ok(Some(echo)) => { + summary.push_str("\n\n"); + summary.push_str(&echo); + } + Ok(None) => {} + Err(error) => summary.push_str(&format!("\n\nEdit echo unavailable: {error}")), + } ToolResult::success(summary).with_metadata(json!({ "event": "file.mutation", "lsp_diagnostics": diagnostics, @@ -937,8 +977,20 @@ impl ReadFileTool { input: Value, context: &ToolContext, ) -> Result { - reject_primitive_unknown(&input, "read", &["path", "offset", "limit", "max_bytes"])?; + reject_primitive_unknown( + &input, + "read", + &["path", "offset", "limit", "max_bytes", "expected_revision"], + )?; let path_str = required_str(&input, "path")?; + let expected_revision = optional_str(&input, "expected_revision")?; + if let Some(revision) = expected_revision + && !crate::tools::echolocation::valid_source_revision(revision) + { + return Err(ToolError::invalid_input( + "expected_revision must be sha256: followed by 64 lowercase hexadecimal digits", + )); + } let offset = contract_nonnegative_int(&input, "offset")?; let limit = contract_nonnegative_int(&input, "limit")?; let max_bytes = effective_read_max_bytes(contract_nonnegative_int(&input, "max_bytes")?); @@ -961,20 +1013,33 @@ impl ReadFileTool { Err(error) => return Err(error), }; check_file_operation_cancelled(context)?; - let bytes = load_contract_source(&file_path, false, context) - .await? - .ok_or_else(|| { - ToolError::execution_failed(format!( - "Failed to read {}: file not found", - file_path.display() - )) - })?; + let bytes = match load_contract_source(&file_path, false, context).await? { + Some(bytes) => bytes, + None => { + let echo_path = file_path.clone(); + let echo = tokio::task::spawn_blocking(move || { + super::echolocation::sound_miss_echo(&echo_path) + }) + .await + .map_err(|error| ToolError::execution_failed(format!("Miss echo task: {error}")))?; + return Err(ToolError::execution_failed(format!( + "Failed to read {}: file not found{}", + file_path.display(), + echo.unwrap_or_default() + ))); + } + }; // #6283: every read response carries the file's byte size, line // count, and truncation flag so the caller can page deliberately // instead of discovering a huge file one window at a time. let size_bytes = bytes.len(); check_file_operation_cancelled(context)?; if let Some(mime_type) = primitive_image_mime(&bytes) { + if expected_revision.is_some() { + return Err(ToolError::execution_failed( + "Navigation reference is stale: the target is now an image. Read the path again without the old range/reference.", + )); + } // Decoding and any downscale are CPU-bound; keep them off the // runtime worker (#6149). let prepared = tokio::task::spawn_blocking(move || { @@ -995,7 +1060,37 @@ impl ReadFileTool { // The small-contract reader decodes non-image buffers as UTF-8 text with replacement // characters instead of refusing the whole read on one invalid byte. - let text = String::from_utf8_lossy(&bytes); + let workspace = context.workspace.clone(); + let observation_path = file_path.clone(); + let guarded = expected_revision.is_some(); + let (text, source_revision, utf8_valid, cache_identity) = + tokio::task::spawn_blocking(move || { + let cache_identity = crate::tools::echolocation::navigation_cache_identity( + &workspace, + &observation_path, + ); + let revision = (cache_identity.is_some() || guarded).then(|| content_hash(&bytes)); + let (text, utf8_valid) = match String::from_utf8(bytes) { + Ok(text) => (text, true), + Err(error) => ( + String::from_utf8_lossy(error.as_bytes()).into_owned(), + false, + ), + }; + (text, revision, utf8_valid, cache_identity) + }) + .await + .map_err(|error| { + ToolError::execution_failed(format!("Failed to decode source: {error}")) + })?; + check_file_operation_cancelled(context)?; + if let Some(expected) = expected_revision + && source_revision.as_deref() != Some(expected) + { + return Err(ToolError::execution_failed(format!( + "Navigation reference for {path_str} is stale. Read the path without the old range/expected_revision to obtain a new reference; do not reuse its offsets." + ))); + } // Count and select with byte boundaries, without a pointer per newline. let line_count = text.bytes().filter(|byte| *byte == b'\n').count() + 1; let requested_offset = offset.unwrap_or(1); @@ -1005,7 +1100,10 @@ impl ReadFileTool { "Offset {requested_offset} is beyond end of file ({line_count} lines total)" ))); } - let start_byte = if start == 0 { + let selected_lines = limit.unwrap_or(usize::MAX).min(line_count - start); + // An empty selection slices `""` whatever the range start is, so it + // needs a valid boundary, not the walked one: skip the prefix scan. + let start_byte = if start == 0 || selected_lines == 0 { 0 } else { text.match_indices('\n') @@ -1014,7 +1112,6 @@ impl ReadFileTool { .0 + 1 }; - let selected_lines = limit.unwrap_or(usize::MAX).min(line_count - start); let end_byte = if selected_lines == 0 { start_byte } else { @@ -1075,24 +1172,93 @@ impl ReadFileTool { } } - // This internal observation keeps hidden legacy edit replay working, - // but no hash or read-before-edit ceremony reaches the lowercase - // schema or result. + // Echolocation: stateless kin footer sounded fresh from source + // (no index, no new deps). Deterministic and hard-budgeted, so a + // repeated read is byte-identical. Two distinct files read in + // this pod put the next read there into the terminal buzz + // (callers included). + let buzz = file_path.parent().is_some_and(|parent| { + context.pod_visit_count(parent) >= crate::tools::echolocation::BUZZ_VISIT_THRESHOLD + }); + let cached_navigation = if let Some((namespace, path)) = &cache_identity { + context + .runtime + .code_observations + .lock() + .await + .get_json_named(namespace, path) + .cloned() + .and_then(|value| { + serde_json::from_value::(value) + .ok() + }) + .filter(|navigation| { + navigation.matches(path, source_revision.as_deref().unwrap_or_default()) + }) + } else { + None + }; + let workspace = context.workspace.clone(); + let echo_path = file_path.clone(); + let navigation_path = cache_identity.as_ref().map(|(_, path)| path.clone()); + let (echo, navigation) = tokio::task::spawn_blocking(move || { + let navigation = cached_navigation.or_else(|| { + navigation_path + .zip(source_revision) + .map(|(path, revision)| { + crate::tools::echolocation::SourceNavigation::from_source( + path, revision, &text, utf8_valid, + ) + }) + }); + let echo = crate::tools::echolocation::sound_echolocation( + &workspace, + &echo_path, + &text, + buzz, + navigation.as_ref(), + ) + .render_with_navigation(navigation.as_ref()); + (echo, navigation) + }) + .await + .map_err(|error| { + ToolError::execution_failed(format!("read echo worker failed: {error}")) + })?; + check_file_operation_cancelled(context)?; + output.push_str(&echo); + if let (Some((namespace, path)), Some(navigation)) = (&cache_identity, &navigation) { + let _ = context.runtime.code_observations.lock().await.insert_json( + namespace, + path, + serde_json::to_value(navigation) + .map_err(|error| ToolError::execution_failed(error.to_string()))?, + ); + } + + // Keep hidden legacy edit replay working. Ordinary reads remain + // unguarded; the optional navigation revision pins a selected range, + // not a read-before-edit permission ceremony. context.note_file_read(&file_path); + let mut metadata = json!({ + "evidence_routing": "inline", + // The budget this call actually enforced. The context + // compactor honors it so an already-bounded read is never + // truncated a second time on its way into the conversation. + "read_budget_bytes": max_bytes, + // #6283: paging contract. `size` is the whole file in bytes, + // `line_count` its total lines, `truncated` whether the file + // holds more than this response shows. + "size": size_bytes, + "truncated": truncated, + "line_count": line_count + }); + if let Some(navigation) = navigation { + metadata["navigation"] = serde_json::to_value(navigation) + .map_err(|error| ToolError::execution_failed(error.to_string()))?; + } Ok(RichToolResult::plain( - ToolResult::success(output).with_metadata(json!({ - "evidence_routing": "inline", - // The budget this call actually enforced. The context - // compactor honors it so an already-bounded read is never - // truncated a second time on its way into the conversation. - "read_budget_bytes": max_bytes, - // #6283: paging contract. `size` is the whole file in bytes, - // `line_count` its total lines, `truncated` whether the file - // holds more than this response shows. - "size": size_bytes, - "truncated": truncated, - "line_count": line_count - })), + ToolResult::success(output).with_metadata(metadata), )) } } @@ -1748,7 +1914,9 @@ impl WriteFileTool { drop(mutation_guard); let outcome = if existed_before { "updated" } else { "created" }; - let utf16_units = written.encode_utf16().count(); + // Bytes are bytes: the receipt reports what was written to disk, + // not UTF-16 code units (which differ on non-ASCII content). + let bytes_written = written.len(); Ok(contract_mutation_result( context, &file_path, @@ -1757,7 +1925,7 @@ impl WriteFileTool { &written, written.as_bytes(), outcome, - format!("Successfully wrote {utf16_units} bytes to {path_str}"), + format!("Successfully wrote {bytes_written} bytes to {path_str}"), ) .await) } @@ -1948,6 +2116,9 @@ struct ResolvedContractEdit { } fn normalize_contract_line_endings(text: &str) -> String { + if !text.as_bytes().contains(&b'\r') { + return text.to_owned(); + } text.replace("\r\n", "\n").replace('\r', "\n") } diff --git a/crates/tui/src/tools/file/tests.rs b/crates/tui/src/tools/file/tests.rs index 946ba77e68..ebf5cd0b9a 100644 --- a/crates/tui/src/tools/file/tests.rs +++ b/crates/tui/src/tools/file/tests.rs @@ -78,7 +78,10 @@ async fn contract_read_returns_a_file_of_more_than_two_thousand_short_lines_whol let result = ReadFileTool::execute_contract_read(json!({"path": "many.txt"}), &context) .await .expect("read result"); - assert_eq!(result.content, content); + assert!( + result.content.starts_with(&content), + "whole file first, pod footer after" + ); assert!( !result.content.contains("[Showing lines"), "no truncation footer" @@ -86,7 +89,7 @@ async fn contract_read_returns_a_file_of_more_than_two_thousand_short_lines_whol } #[tokio::test] -async fn contract_read_returns_an_ordinary_source_file_whole_without_a_footer() { +async fn contract_read_appends_a_pod_footer_to_a_whole_source_file() { let temporary = tempfile::tempdir().expect("tempdir"); let content = "fn main() {\n println!(\"hi\");\n}\n"; std::fs::write(temporary.path().join("main.rs"), content).expect("fixture"); @@ -94,7 +97,66 @@ async fn contract_read_returns_an_ordinary_source_file_whole_without_a_footer() let result = ReadFileTool::execute_contract_read(json!({"path": "main.rs"}), &context) .await .expect("read result"); - assert_eq!(result.content, content); + assert!( + result.content.starts_with(content), + "whole file first, pod footer after" + ); + assert!( + result.content.contains("[Pod ("), + "pod footer present: {}", + result.content + ); + assert!( + result.content.contains("[Sound: 1 symbol: fn main:1]"), + "sounded symbol present: {}", + result.content + ); +} + +#[tokio::test] +async fn contract_read_miss_echoes_parent_mates_on_not_found() { + let temporary = tempfile::tempdir().expect("tempdir"); + std::fs::write(temporary.path().join("alpha.rs"), "fn a() {}\n").expect("fixture"); + std::fs::write(temporary.path().join("beta.rs"), "fn b() {}\n").expect("fixture"); + let context = ToolContext::new(temporary.path()); + let error = ReadFileTool::execute_contract_read(json!({"path": "alpah.rs"}), &context) + .await + .expect_err("typo'd path must fail"); + match error { + ToolError::ExecutionFailed { message, .. } => { + assert!(message.contains("Failed to read"), "{message}"); + assert!(message.contains("[Miss echo: "), "{message}"); + assert!(message.contains("alpha.rs"), "{message}"); + assert!(message.contains("beta.rs"), "{message}"); + } + other => panic!("expected ordinary read failure, got {other:?}"), + } +} + +#[tokio::test] +async fn contract_read_refused_probe_gets_no_miss_echo() { + // Security gate: a denylisted path must fail WITHOUT the miss echo — + // listing the parent would answer the refused probe. The denylist + // check runs before the IO read, so the refusal wins whether or not + // the file exists. Mirrors the home-dir fixture pattern in + // file/tests/tools.rs (the guard snapshots HOME process-wide). + let _env_lock = crate::test_support::lock_test_env(); + let Some(home) = dirs::home_dir() else { + return; + }; + let temporary = tempfile::tempdir().expect("tempdir"); + let context = ToolContext::new(temporary.path()); + let probe = home.join(".ssh").join("id_ed25519_no_such_key"); + let error = + ReadFileTool::execute_contract_read(json!({"path": probe.to_string_lossy()}), &context) + .await + .expect_err("denylisted path must fail"); + let message = error.to_string(); + assert!(message.contains("deny-list"), "{message}"); + assert!( + !message.contains("[Miss echo: "), + "refused probe must not echo: {message}" + ); } #[test] @@ -173,7 +235,10 @@ async fn contract_read_max_bytes_raises_the_budget_for_one_call() { ) .await .expect("raised budget read"); - assert_eq!(raised.content, content); + assert!( + raised.content.starts_with(&content), + "whole file first, pod footer after" + ); // Above the hard maximum clamps down; the file still fits, so it is whole. let clamped = ReadFileTool::execute_contract_read( @@ -182,7 +247,10 @@ async fn contract_read_max_bytes_raises_the_budget_for_one_call() { ) .await .expect("clamped budget read"); - assert_eq!(clamped.content, content); + assert!( + clamped.content.starts_with(&content), + "whole file first, pod footer after" + ); } #[tokio::test] @@ -201,17 +269,14 @@ async fn contract_read_paginates_an_oversized_file_with_an_honest_budget_footer( let first = ReadFileTool::execute_contract_read(json!({"path": "big.txt"}), &context) .await .expect("first page"); - let footer = first - .content - .rsplit_once("\n\n") - .expect("footer present") - .1 - .to_string(); - assert_eq!( - footer, - "[Showing lines 1-100 of 2000 (1.9MB total, 100000-byte output budget). Use offset=101 to continue, or max_bytes up to 500000 to read more per call.]" + assert!( + first.content.contains( + "[Showing lines 1-100 of 2000 (1.9MB total, 100000-byte output budget). Use offset=101 to continue, or max_bytes up to 500000 to read more per call.]" + ), + "paging footer exact: {}", + first.content ); - let shown = first.content.rsplit_once("\n\n").expect("body").0; + let shown = first.content.split("\n\n[").next().expect("body"); assert_eq!(shown.lines().count(), 100); // The named continuation offset is exact: page two starts on line 101. @@ -307,8 +372,8 @@ async fn contract_read_pages_stay_bounded_and_cover_the_whole_file() { ); let body = result .content - .rsplit_once("\n\n[") - .map(|(body, _)| body) + .split("\n\n[") + .next() .unwrap_or(&result.content); seen.extend(body.lines().map(str::to_string)); let truncated = metadata["truncated"].as_bool().expect("truncated flag"); @@ -323,7 +388,7 @@ async fn contract_read_pages_stay_bounded_and_cover_the_whole_file() { } /// #6283: ordinary whole reads carry the same paging metadata (with -/// truncated=false) and keep their footer-free shape. +/// truncated=false); the pod footer follows the whole file. #[tokio::test] async fn contract_read_metadata_for_ordinary_whole_read() { let temporary = tempfile::tempdir().expect("tempdir"); @@ -334,7 +399,15 @@ async fn contract_read_metadata_for_ordinary_whole_read() { let result = ReadFileTool::execute_contract_read(json!({"path": "small.txt"}), &context) .await .expect("read result"); - assert_eq!(result.content, content); + assert!( + result.content.starts_with(content), + "whole file first, pod footer after" + ); + assert!( + result.content.contains("[Pod ("), + "pod footer present: {}", + result.content + ); let metadata = result.metadata.clone().expect("paging metadata"); assert_eq!(metadata["size"], content.len() as u64); assert_eq!(metadata["truncated"], false); @@ -399,9 +472,12 @@ async fn contract_read_reports_huge_first_line_with_exact_bash_fallback() { let result = ReadFileTool::execute_contract_read(json!({"path": "huge.txt"}), &context) .await .expect("read result"); - assert_eq!( - result.content, - "[Line 1 is 97.7KB, exceeds the 100000-byte output budget for this call. Use bash: sed -n '1p' huge.txt | head -c 100000]" + assert!( + result.content.starts_with( + "[Line 1 is 97.7KB, exceeds the 100000-byte output budget for this call. Use bash: sed -n '1p' huge.txt | head -c 100000]" + ), + "exact bash fallback first, pod footer after: {}", + result.content ); } @@ -417,9 +493,12 @@ async fn contract_read_offset_oob_and_limit_continuation_match_contract() { ) .await .expect("limited read"); - assert_eq!( - limited.content, - "two\n\n[1 more lines in file (13B total). Use offset=3 to continue.]" + assert!( + limited + .content + .starts_with("two\n\n[1 more lines in file (13B total). Use offset=3 to continue.]"), + "page plus paging footer first, pod footer after: {}", + limited.content ); let error = @@ -449,7 +528,7 @@ async fn contract_read_accepts_whole_number_floats_and_still_refuses_fractions() .expect("float-typed ranged read"); assert_eq!( ranged.content, - "two\n\n[1 more lines in file (13B total). Use offset=3 to continue.]" + "two\n\n[1 more lines in file (13B total). Use offset=3 to continue.]\n\n[Pod (1): lines.txt (this file)]" ); for (key, bad) in [ @@ -490,7 +569,11 @@ async fn contract_read_uses_magic_not_extension_for_images() { let text = ReadFileTool::execute_contract_read(json!({"path": "plain.png"}), &context) .await .expect("fake extension remains text"); - assert_eq!(text.content, "ordinary text"); + assert!( + text.content.starts_with("ordinary text"), + "text first, pod footer after: {}", + text.content + ); let image = ReadFileTool::execute_contract_read(json!({"path": "renamed.data"}), &context) .await .expect("real image uses typed transport"); @@ -701,6 +784,56 @@ async fn queued_parallel_contract_edits_preserve_both_changes() { ); } +#[tokio::test] +async fn contract_edit_echoes_touched_symbols_and_callers() { + let temporary = tempfile::tempdir().expect("tempdir"); + let context = ToolContext::new(temporary.path()); + std::fs::write(temporary.path().join("a.py"), "import b\nprint(b.swim())\n").expect("fixture"); + std::fs::write(temporary.path().join("b.py"), "def swim():\n return 1\n").expect("fixture"); + let result = EditFileTool::execute_contract_edits( + json!({ + "path": "b.py", + "edits": [{"oldText": "return 1", "newText": "return 2"}] + }), + &context, + ) + .await + .expect("edit runs"); + assert!( + result.content.contains("Successfully replaced"), + "{}", + result.content + ); + assert!( + result.content.contains("[Edit echo: touched def swim:1"), + "{}", + result.content + ); + assert!( + result.content.contains("heard by a.py"), + "{}", + result.content + ); +} + +#[tokio::test] +async fn contract_write_receipt_counts_bytes_not_utf16_units() { + // "héllo\n" is 7 bytes but 6 UTF-16 units; the receipt must say 7. + let temporary = tempfile::tempdir().expect("tempdir"); + let context = ToolContext::new(temporary.path()); + let result = WriteFileTool::execute_contract_write( + json!({"path": "note.txt", "content": "héllo\n"}), + &context, + ) + .await + .expect("write runs"); + assert!( + result.content.contains("Successfully wrote 7 bytes"), + "{}", + result.content + ); +} + #[tokio::test] async fn cancelled_queued_pi_write_never_starts() { let temporary = tempfile::tempdir().expect("tempdir"); @@ -1022,7 +1155,7 @@ async fn contract_read_newline_dense_ranges_preserve_trailing_and_empty_lines() ) .await .unwrap(); - assert_eq!(result.content, "\n"); + assert_eq!(result.content, "\n\n\n[Pod (1): dense.txt (this file)]"); let metadata = result.metadata.as_ref().unwrap(); assert_eq!(metadata["size"], text.len()); assert_eq!(metadata["line_count"], text.len() + 1); @@ -1035,7 +1168,7 @@ async fn contract_read_newline_dense_ranges_preserve_trailing_and_empty_lines() .unwrap(); assert_eq!( empty.content, - "\n\n[1 more lines in file (2.0MB total). Use offset=2097153 to continue.]" + "\n\n[1 more lines in file (2.0MB total). Use offset=2097153 to continue.]\n\n[Pod (1): dense.txt (this file)]" ); } @@ -1140,3 +1273,39 @@ async fn contract_read_admits_only_an_image_the_user_attached_from_outside_the_w .expect_err("an outside image the user never attached stays refused"); assert!(matches!(error, ToolError::PathEscape { .. }), "{error:?}"); } + +#[tokio::test] +async fn contract_read_enters_terminal_buzz_on_third_pod_visit() { + let temporary = tempfile::tempdir().expect("tempdir"); + let root = temporary.path(); + std::fs::create_dir_all(root.join("src/net")).expect("mkdir"); + std::fs::write(root.join("src/main.rs"), "fn main() {}\n").expect("fixture"); + std::fs::write(root.join("src/net/mod.rs"), "pub mod server;\n").expect("fixture"); + std::fs::write(root.join("src/net/server.rs"), "fn serve() {}\n").expect("fixture"); + std::fs::write(root.join("src/net/client.rs"), "fn connect() {}\n").expect("fixture"); + let context = ToolContext::new(root); + + // First two pod reads: full footer, no callers yet. + for path in ["src/net/mod.rs", "src/net/client.rs"] { + let result = ReadFileTool::execute_contract_read(json!({"path": path}), &context) + .await + .expect("read result"); + assert!(!result.content.contains("heard by"), "{path}"); + } + // Third distinct pod read: the buzz adds who links here. + let result = + ReadFileTool::execute_contract_read(json!({"path": "src/net/server.rs"}), &context) + .await + .expect("read result"); + assert!( + result.content.contains("heard by: mod.rs"), + "{}", + result.content + ); + // Another pod stays sparse: the buzz is per-pod, not global. + std::fs::write(root.join("src/other.rs"), "fn other() {}\n").expect("fixture"); + let result = ReadFileTool::execute_contract_read(json!({"path": "src/other.rs"}), &context) + .await + .expect("read result"); + assert!(!result.content.contains("heard by"), "{}", result.content); +} diff --git a/crates/tui/src/tools/file_search.rs b/crates/tui/src/tools/file_search.rs index bfda2a3560..782752bce4 100644 --- a/crates/tui/src/tools/file_search.rs +++ b/crates/tui/src/tools/file_search.rs @@ -113,7 +113,7 @@ impl ToolSpec for FileSearchTool { let extensions = parse_extensions(&input); let exclude_patterns = parse_exclude_patterns(&input); - let matches = search_files_async( + let result = search_files_async( query.to_string(), base_path, extensions, @@ -124,7 +124,7 @@ impl ToolSpec for FileSearchTool { context.follow_symlinks, ) .await?; - ToolResult::json(&matches).map_err(|e| ToolError::execution_failed(e.to_string())) + ToolResult::json(&result).map_err(|e| ToolError::execution_failed(e.to_string())) } } @@ -138,28 +138,49 @@ async fn search_files_async( cancel_token: Option, timeout: Duration, follow_symlinks: bool, -) -> Result, ToolError> { +) -> Result { run_blocking_file_search(timeout, cancel_token, move |worker_cancel_token| { - search_files( + let matches = search_files( &query, &base_path, - extensions, - exclude_patterns, + extensions.clone(), + exclude_patterns.clone(), limit, Some(&worker_cancel_token), follow_symlinks, - ) + )?; + check_cancelled(Some(&worker_cancel_token))?; + let paths: Vec = matches.iter().map(|item| item.path.clone()).collect(); + let vis = super::echolocation::PodVisibility { + include: &[], + exclude: &exclude_patterns, + extensions: &extensions, + glob_root: &base_path, + gitignore: true, + }; + let (pods, pods_omitted) = super::echolocation::sound_match_pods(&base_path, &paths, &vis); + let pods_json: Vec = pods + .iter() + .map(|pod| { + json!({ + "dir": pod.dir, "mates": pod.mates, "mate_total": pod.mate_total, + }) + }) + .collect(); + check_cancelled(Some(&worker_cancel_token))?; + Ok(json!({ "matches": matches, "pods": pods_json, "pods_omitted": pods_omitted })) }) .await } -async fn run_blocking_file_search( +async fn run_blocking_file_search( timeout: Duration, cancel_token: Option, search: F, -) -> Result, ToolError> +) -> Result where - F: FnOnce(CancellationToken) -> Result, ToolError> + Send + 'static, + F: FnOnce(CancellationToken) -> Result + Send + 'static, + T: Send + 'static, { if cancel_token .as_ref() @@ -423,7 +444,7 @@ fn check_cancelled(cancel_token: Option<&CancellationToken>) -> Result<(), ToolE Ok(()) } -fn extension_matches(path: &Path, extensions: &[String]) -> bool { +pub(crate) fn extension_matches(path: &Path, extensions: &[String]) -> bool { let Some(ext) = path.extension().and_then(|e| e.to_str()) else { return false; }; @@ -578,6 +599,36 @@ mod tests { assert!(result.content.contains("main.rs")); } + #[tokio::test] + async fn test_file_search_charts_match_pods() { + let tmp = tempdir().expect("tempdir"); + let root = tmp.path(); + std::fs::create_dir_all(root.join("src/net")).expect("mkdir"); + std::fs::write(root.join("src/net/server.rs"), "fn serve() {}\n").expect("write"); + std::fs::write(root.join("src/net/client.rs"), "fn connect() {}\n").expect("write"); + + let ctx = ToolContext::new(root.to_path_buf()); + let result = FileSearchTool + .execute(json!({"query": "server", "limit": 5}), &ctx) + .await + .expect("execute"); + + assert!(result.success); + let body: Value = serde_json::from_str(&result.content).expect("json result"); + assert!(!body["matches"].as_array().expect("matches").is_empty()); + let pods = body["pods"].as_array().expect("pods array"); + assert_eq!(pods.len(), 1); + assert_eq!(pods[0]["dir"], "src/net"); + let mates: Vec<&str> = pods[0]["mates"] + .as_array() + .expect("mates") + .iter() + .filter_map(Value::as_str) + .collect(); + assert!(mates.contains(&"server.rs (match)"), "{mates:?}"); + assert!(mates.contains(&"client.rs"), "{mates:?}"); + } + #[tokio::test] async fn test_file_search_respects_gitignore() { let tmp = tempdir().expect("tempdir"); @@ -633,9 +684,9 @@ mod tests { .expect("execute"); assert!(result.success); - let matches: Value = serde_json::from_str(&result.content).expect("search json"); + let body: Value = serde_json::from_str(&result.content).expect("search json"); assert!( - matches + body["matches"] .as_array() .expect("matches") .iter() @@ -666,9 +717,9 @@ mod tests { .expect("execute"); assert!(result.success); - let matches: Value = serde_json::from_str(&result.content).expect("search json"); + let body: Value = serde_json::from_str(&result.content).expect("search json"); assert!( - matches + body["matches"] .as_array() .expect("matches") .iter() @@ -695,7 +746,7 @@ mod tests { .expect("execute"); let matches: Value = serde_json::from_str(&result.content).expect("search json"); - let paths: Vec<&str> = matches + let paths: Vec<&str> = matches["matches"] .as_array() .expect("matches") .iter() @@ -731,13 +782,17 @@ mod tests { #[tokio::test] async fn test_file_search_blocking_wrapper_reports_timeout() { let (finished, worker_finished) = tokio::sync::oneshot::channel(); - let err = run_blocking_file_search(Duration::from_millis(1), None, move |cancel_token| { - while !cancel_token.is_cancelled() { - std::thread::sleep(Duration::from_millis(1)); - } - let _ = finished.send(cancel_token.is_cancelled()); - Err(file_search_cancelled()) - }) + let err = run_blocking_file_search::<_, Vec>( + Duration::from_millis(1), + None, + move |cancel_token| { + while !cancel_token.is_cancelled() { + std::thread::sleep(Duration::from_millis(1)); + } + let _ = finished.send(cancel_token.is_cancelled()); + Err(file_search_cancelled()) + }, + ) .await .expect_err("slow file_search worker should time out"); diff --git a/crates/tui/src/tools/file_tool.rs b/crates/tui/src/tools/file_tool.rs index 51916206e1..272f696dfd 100644 --- a/crates/tui/src/tools/file_tool.rs +++ b/crates/tui/src/tools/file_tool.rs @@ -82,6 +82,10 @@ impl ToolSpec for ReadTool { "max_bytes": { "type": "number", "description": "Output budget in bytes for this one call. Defaults to 100000; values above the 500000 maximum are clamped down rather than rejected, and a value below the active default leaves the default in place." + }, + "expected_revision": { + "type": "string", + "description": "Optional snapshot guard from a code navigation reference. Pass the reference's path, offset, limit and expected_revision together; changed source is refused instead of silently reading stale offsets. Omit for an ordinary read." } }, "required": ["path"], diff --git a/crates/tui/src/tools/file_tool/tests.rs b/crates/tui/src/tools/file_tool/tests.rs index 311d71fd26..3159c933f1 100644 --- a/crates/tui/src/tools/file_tool/tests.rs +++ b/crates/tui/src/tools/file_tool/tests.rs @@ -555,7 +555,7 @@ fn primitive_schemas_are_separate_and_small_contract_shaped() { .cloned() .collect::>(), // `max_bytes` is the model-requested per-call output budget (C05). - ["limit", "max_bytes", "offset", "path"] + ["expected_revision", "limit", "max_bytes", "offset", "path"] .into_iter() .map(str::to_string) .collect() diff --git a/crates/tui/src/tools/handle.rs b/crates/tui/src/tools/handle.rs index 3478992499..f5183bdd7e 100644 --- a/crates/tui/src/tools/handle.rs +++ b/crates/tui/src/tools/handle.rs @@ -155,6 +155,18 @@ impl HandleStore { self.records.get(&handle.key()) } + /// Host-owned named JSON, without minting a model-facing var_handle. + pub(crate) fn get_json_named(&self, namespace: &str, name: &str) -> Option<&Value> { + let record = self.records.get(&HandleKey { + session_id: namespace.to_string(), + name: name.to_string(), + })?; + match &record.value { + HandleValue::Json(value) => Some(value), + HandleValue::Text(_) => None, + } + } + /// Remove all handles for `session_id`. Called when an agent's records /// are retired so resident transcript payloads are freed without waiting /// for a full session reset (#3885). diff --git a/crates/tui/src/tools/mod.rs b/crates/tui/src/tools/mod.rs index 9bcaa9bcbe..e33b6443f2 100644 --- a/crates/tui/src/tools/mod.rs +++ b/crates/tui/src/tools/mod.rs @@ -26,6 +26,7 @@ pub mod file; pub mod file_search; pub mod finance; +pub mod echolocation; pub mod fetch_url; pub mod file_tool; pub mod fim; diff --git a/crates/tui/src/tools/project.rs b/crates/tui/src/tools/project.rs index d29328742e..0972827d9c 100644 --- a/crates/tui/src/tools/project.rs +++ b/crates/tui/src/tools/project.rs @@ -1,6 +1,6 @@ //! Project mapping tool for understanding codebase structure. -use crate::utils::{is_key_file, project_tree, summarize_project}; +use crate::utils::{is_key_file, project_tree, summarize_key_files, summarize_project}; use anyhow::Result; use async_trait::async_trait; use serde::Serialize; @@ -17,6 +17,8 @@ struct ProjectMap { tree: String, summary: String, key_files: Vec, + key_file_soundings: std::collections::BTreeMap, + key_files_omitted: usize, } #[async_trait] @@ -51,7 +53,15 @@ impl ToolSpec for ProjectMapTool { async fn execute(&self, input: Value, context: &ToolContext) -> Result { let max_depth = optional_u64(&input, "max_depth", 3)? as usize; - let map = generate_project_map(&context.workspace, max_depth, context.follow_symlinks)?; + let workspace = context.workspace.clone(); + let follow_symlinks = context.follow_symlinks; + let map = tokio::task::spawn_blocking(move || { + generate_project_map(&workspace, max_depth, follow_symlinks) + }) + .await + .map_err(|error| { + ToolError::execution_failed(format!("project_map worker failed: {error}")) + })??; ToolResult::json(&map).map_err(|e| ToolError::execution_failed(e.to_string())) } } @@ -62,9 +72,9 @@ fn generate_project_map( follow_symlinks: bool, ) -> Result { let tree = project_tree(root, max_depth, follow_symlinks); - let summary = summarize_project(root); - // For key_files, we can just do a quick scan since summarize_project doesn't return them directly anymore + // Reuse this observation for the summary only when both walks have the + // same symlink policy. let mut key_files = Vec::new(); let mut builder = ignore::WalkBuilder::new(root); builder @@ -84,9 +94,49 @@ fn generate_project_map( } } + // `follow_symlinks == true` keeps the original non-follow summary walk so + // symlinked key files never leak into the summary. + let summary = if follow_symlinks { + summarize_project(root) + } else { + summarize_key_files(key_files.clone()) + }; + + // Echolocation sounding: one line of symbols per key file (sorted, + // capped), so the map says what lives where, not just what exists. + let (key_file_soundings, key_files_omitted) = + super::echolocation::sound_key_files(root, &key_files); + Ok(ProjectMap { tree, summary, key_files, + key_file_soundings, + key_files_omitted, }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn project_map_sounds_key_file_symbols() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("main.rs"), "fn main() {}\nfn serve() {}\n") + .expect("fixture"); + std::fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("fixture"); + let context = ToolContext::new(dir.path().to_path_buf()); + let result = ProjectMapTool + .execute(json!({}), &context) + .await + .expect("execute"); + assert!(result.success); + let body: Value = serde_json::from_str(&result.content).expect("json result"); + assert_eq!( + body["key_file_soundings"]["main.rs"], + "2 symbols: fn main:1, fn serve:2" + ); + assert!(body["key_file_soundings"].get("Cargo.toml").is_none()); + } +} diff --git a/crates/tui/src/tools/search.rs b/crates/tui/src/tools/search.rs index 82ca86b5b0..4282484c59 100644 --- a/crates/tui/src/tools/search.rs +++ b/crates/tui/src/tools/search.rs @@ -32,13 +32,17 @@ const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024; // 10MB /// tree. Mirrors the file_search tool so both blocking searches behave the same. const GREP_FILES_TIMEOUT: Duration = Duration::from_secs(30); -/// Result of a grep match +/// Result of a grep match. Empty context arrays are omitted on the +/// wire (models request context in ~0% of calls); `default` keeps old +/// payloads parsing. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GrepMatch { pub file: String, pub line_number: usize, pub line: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub context_before: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub context_after: Vec, } @@ -91,7 +95,7 @@ impl ToolSpec for GrepFilesTool { }, "max_results": { "type": "integer", - "description": "Maximum number of results to return (default: 100)" + "description": "Maximum number of results to return (default: 100, max: 1000)" } }, "required": ["pattern"] @@ -118,8 +122,12 @@ impl ToolSpec for GrepFilesTool { .unwrap_or(usize::MAX) .min(1000); let case_insensitive = optional_bool(&input, "case_insensitive", false)?; + // Bounded like file_search's limit: an explicit value is the + // model's choice, but a runaway (or typo) must not mint an + // unbounded JSON array. let max_results = usize::try_from(optional_u64(&input, "max_results", MAX_RESULTS as u64)?) - .unwrap_or(MAX_RESULTS); + .unwrap_or(MAX_RESULTS) + .clamp(1, 1000); // Parse include patterns let include_patterns: Vec = input @@ -229,18 +237,37 @@ impl ToolSpec for GrepFilesTool { return Ok(WalkControl::Continue); } - // Get relative path from workspace + // Relative path from workspace. The walk root comes + // back canonical while the workspace may not be + // (macOS /var, Windows \\?\ verbatim paths), so + // canonicalize both sides before falling back to + // the absolute path. let relative_path = file_path .strip_prefix(&workspace) .ok() + .map(|rel| rel.to_string_lossy().to_string()) .or_else(|| { workspace_canonical .as_deref() .and_then(|canonical| file_path.strip_prefix(canonical).ok()) + .map(|rel| rel.to_string_lossy().to_string()) }) - .unwrap_or(file_path) - .to_string_lossy() - .to_string(); + .or_else(|| { + // Windows verbatim roots (\\?\C:\ws) canonicalize + // the workspace but not the walked file path, so + // the strip above can still miss. Canonicalize the + // file too before giving up. + let ws = workspace_canonical + .clone() + .or_else(|| workspace.canonicalize().ok()); + let fp = file_path.canonicalize().ok(); + ws.zip(fp).and_then(|(ws, fp)| { + fp.strip_prefix(&ws) + .ok() + .map(|rel| rel.to_string_lossy().to_string()) + }) + }) + .unwrap_or_else(|| file_path.to_string_lossy().to_string()); let budget = max_results.saturating_sub(results.len()); let (file_matches, overflowed) = match search_file_streaming( @@ -278,15 +305,45 @@ impl ToolSpec for GrepFilesTool { .map(|item| grep_match_to_json(item, context_lines)) .collect(); + // Echolocation sounding: chart the matched dirs (sorted, capped) + // so the model sees each match's podmates without a follow-up + // list. Computed here, inside the blocking worker, with the + // workspace already at hand. + let matched_files: Vec = results.iter().map(|item| item.file.clone()).collect(); + let vis = crate::tools::echolocation::PodVisibility { + include: &include_patterns, + exclude: &exclude_patterns, + extensions: &[], + glob_root: &search_path, + gitignore: false, + }; + let (pods, pods_omitted) = + crate::tools::echolocation::sound_match_pods(&workspace, &matched_files, &vis); + let pods_json: Vec = pods + .iter() + .map(|pod| { + json!({ + "dir": pod.dir, + "mates": pod.mates, + "mate_total": pod.mate_total, + }) + }) + .collect(); + // Build result. When context_lines == 1, return the single context // line as a string instead of a one-item array. That keeps the common // "show just the adjacent line" case easy for model callers to read. - let mut output = json!({ - "matches": matches_json, - "total_matches": total_matches, - "files_searched": files_searched, - "truncated": truncated, - }); + // json! would to_value() these already-built arrays and deep-copy + // every match and pod. Insert them by move; key order matches the + // previous object because serde_json preserves insertion order. + let mut built = serde_json::Map::new(); + built.insert("matches".to_owned(), Value::Array(matches_json)); + built.insert("total_matches".to_owned(), Value::from(total_matches)); + built.insert("files_searched".to_owned(), Value::from(files_searched)); + built.insert("truncated".to_owned(), Value::from(truncated)); + built.insert("pods".to_owned(), Value::Array(pods_json)); + built.insert("pods_omitted".to_owned(), Value::from(pods_omitted)); + let mut output = Value::Object(built); if truncated { output["max_results"] = json!(max_results); } @@ -392,6 +449,8 @@ fn search_file_streaming( // lines still needed). Entries complete in FIFO order. let mut pending: VecDeque<(usize, usize)> = VecDeque::new(); let mut line_idx = 0usize; + // True when a match exists past the collected budget, so the caller + // can report truncation honestly instead of inferring it. let mut overflowed = false; loop { @@ -429,9 +488,11 @@ fn search_file_streaming( pending.pop_front(); } - if matches.len() >= budget { - overflowed = overflowed || regex.is_match(line); - } else if regex.is_match(line) { + // Past the budget we keep testing but stop collecting; the + // first surplus match proves the cut. With nothing pending (no + // after-context owed) we can stop scanning immediately. + let is_match = regex.is_match(line); + if matches.len() < budget && is_match { matches.push(GrepMatch { file: relative_path.to_string(), line_number: line_idx + 1, @@ -442,6 +503,11 @@ fn search_file_streaming( if context_lines > 0 { pending.push_back((matches.len() - 1, context_lines)); } + } else if is_match { + overflowed = true; + if pending.is_empty() { + break; + } } if context_lines > 0 { diff --git a/crates/tui/src/tools/search/tests.rs b/crates/tui/src/tools/search/tests.rs index b192f91604..32af35f566 100644 --- a/crates/tui/src/tools/search/tests.rs +++ b/crates/tui/src/tools/search/tests.rs @@ -8,6 +8,55 @@ use crate::tools::spec::{ApprovalRequirement, ToolContext, ToolSpec}; use super::{GrepFilesTool, matches_glob}; +#[tokio::test] +async fn grep_max_results_is_clamped_not_trusted() { + let tmp = tempdir().expect("tempdir"); + let body = "needle here\n".repeat(1_200); + fs::write(tmp.path().join("hay.txt"), &body).expect("write"); + let ctx = ToolContext::new(tmp.path()); + let result = GrepFilesTool + .execute(json!({"pattern": "needle", "max_results": 1_000_000}), &ctx) + .await + .expect("grep runs"); + let payload: Value = serde_json::from_str(&result.content).expect("json"); + assert_eq!(payload["matches"].as_array().expect("matches").len(), 1000); + assert_eq!(payload["truncated"], true); + assert_eq!(payload["total_matches"], 1000); +} + +#[tokio::test] +async fn grep_truncated_fires_only_on_a_proven_cut() { + let tmp = tempdir().expect("tempdir"); + // Exactly max: the walk completes, nothing cut, flag stays false. + fs::write(tmp.path().join("exact.txt"), "needle\n".repeat(3)).expect("write"); + // Over max across files: the second file proves the cut. + fs::write(tmp.path().join("a.txt"), "needle\n".repeat(2)).expect("write"); + fs::write(tmp.path().join("b.txt"), "needle\n".repeat(5)).expect("write"); + let ctx = ToolContext::new(tmp.path()); + + let result = GrepFilesTool + .execute( + json!({"pattern": "needle", "path": "exact.txt", "max_results": 3}), + &ctx, + ) + .await + .expect("grep runs"); + let payload: Value = serde_json::from_str(&result.content).expect("json"); + assert_eq!(payload["matches"].as_array().expect("matches").len(), 3); + assert_eq!(payload["truncated"], false); + + let result = GrepFilesTool + .execute( + json!({"pattern": "needle", "path": "a.txt", "max_results": 1}), + &ctx, + ) + .await + .expect("grep runs"); + let payload: Value = serde_json::from_str(&result.content).expect("json"); + assert_eq!(payload["matches"].as_array().expect("matches").len(), 1); + assert_eq!(payload["truncated"], true); +} + #[test] fn grep_description_matches_default_exclusion_behavior() { let description = GrepFilesTool.description(); @@ -114,6 +163,35 @@ fn test_matches_glob_unicode_filename() { assert!(!matches_glob(filename, "nonexistent*")); } +#[tokio::test] +async fn grep_pods_chart_matched_dirs_with_marks() { + let tmp = tempdir().expect("tempdir"); + let ctx = ToolContext::new(tmp.path().to_path_buf()); + fs::create_dir(tmp.path().join("net")).expect("subdir"); + fs::write(tmp.path().join("net/server.rs"), "fn serve() {}\n").expect("write"); + fs::write(tmp.path().join("net/client.rs"), "fn connect() {}\n").expect("write"); + + let result = GrepFilesTool + .execute(json!({"pattern": "serve"}), &ctx) + .await + .expect("execute"); + assert!(result.success); + let body: Value = serde_json::from_str(&result.content).expect("json result"); + assert_eq!(body["total_matches"], 1); + let pods = body["pods"].as_array().expect("pods array"); + assert_eq!(pods.len(), 1); + assert_eq!(pods[0]["dir"], "net"); + let mates: Vec<&str> = pods[0]["mates"] + .as_array() + .expect("mates") + .iter() + .filter_map(Value::as_str) + .collect(); + assert!(mates.contains(&"client.rs"), "{mates:?}"); + assert!(mates.contains(&"server.rs (match)"), "{mates:?}"); + assert_eq!(body["pods_omitted"], 0); +} + #[tokio::test] async fn test_grep_files_basic() { let tmp = tempdir().expect("tempdir"); @@ -415,12 +493,13 @@ async fn test_grep_files_ring_buffer_context_matches_full_read() { let parsed: Value = serde_json::from_str(&result.content).unwrap(); let matches = parsed["matches"].as_array().unwrap(); assert_eq!(matches.len(), 3); - assert_eq!(matches[0]["context_before"], json!([])); + // Token diet: empty context arrays are omitted on the wire. + assert!(matches[0].get("context_before").is_none()); assert_eq!(matches[0]["context_after"], json!(["b1", "b2"])); assert_eq!(matches[1]["context_before"], json!(["b2", "b3"])); assert_eq!(matches[1]["context_after"], json!(["a1", "a2"])); assert_eq!(matches[2]["context_before"], json!(["a2", "a3"])); - assert_eq!(matches[2]["context_after"], json!([])); + assert!(matches[2].get("context_after").is_none()); assert_eq!(matches[2]["line_number"].as_u64().unwrap(), 9); } diff --git a/crates/tui/src/tools/skill.rs b/crates/tui/src/tools/skill.rs index 50fce5ce56..ed74f69230 100644 --- a/crates/tui/src/tools/skill.rs +++ b/crates/tui/src/tools/skill.rs @@ -18,7 +18,8 @@ use async_trait::async_trait; use serde_json::{Value, json}; use crate::skills::{ - Skill, SkillDiscoveryMode, SkillSource, discover_for_workspace_and_dir_with_mode_and_plugins, + MAX_SKILL_DESCRIPTION_CHARS, Skill, SkillDiscoveryMode, SkillSource, + discover_for_workspace_and_dir_with_mode_and_plugins, discover_in_workspace_with_mode_and_plugins, skill_directories_for_workspace_and_dir, skills_directories_for_mode, }; @@ -104,9 +105,9 @@ impl ToolSpec for LoadSkillTool { .into_enabled(); // Listing mode: empty name, "*", or "list" returns the full registry (#4651). - // A `query` filters that listing over the same routing metadata the - // ambient index carries, so a truncated index does not force the model - // to pull every skill to find one. + // Cap descriptions on the first 100 rows; retain all names so the model + // can discover every installed skill without guessing. A `query` filters + // over the same routing metadata the ambient index carries. let query = input .get("query") .and_then(Value::as_str) @@ -136,11 +137,25 @@ impl ToolSpec for LoadSkillTool { } else { format!("Skills matching {:?} ({}):\n", query, skills.len()) }; - for skill in skills { - if skill.description.trim().is_empty() { + for (index, skill) in skills.iter().enumerate() { + let description = if index < 100 { + skill.description.trim() + } else { + "" + }; + if description.is_empty() { listing.push_str(&format!(" - {}\n", skill.name)); } else { - listing.push_str(&format!(" - {} — {}\n", skill.name, skill.description)); + let capped: String = description + .chars() + .take(MAX_SKILL_DESCRIPTION_CHARS) + .collect(); + let marker = if capped.len() < description.len() { + "…" + } else { + "" + }; + listing.push_str(&format!(" - {} — {capped}{marker}\n", skill.name)); } } return Ok(ToolResult::success(listing)); @@ -832,7 +847,11 @@ mod tests { } #[tokio::test] async fn model_load_and_listing_obey_independent_invocation_gates() { + let _lock = crate::test_support::lock_test_env(); let tmp = tempfile::tempdir().unwrap(); + let _home = crate::test_support::EnvVarGuard::set("HOME", tmp.path().join("home")); + let _cw_home = + crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path().join("cw-home")); let workspace = tmp.path().join("workspace"); crate::test_support::trust_workspace(&workspace); let root = workspace.join(".codewhale/skills"); @@ -883,4 +902,53 @@ mod tests { .contains("secret steps for model") ); } + #[tokio::test] + async fn execute_listing_caps_rows_and_descriptions_honestly() { + let _lock = crate::test_support::lock_test_env(); + let tmp = tempdir().unwrap(); + let _home = crate::test_support::EnvVarGuard::set("HOME", tmp.path().join("home")); + let _cw_home = + crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path().join("cw-home")); + let workspace = tmp.path().to_path_buf(); + crate::test_support::trust_workspace(&workspace); + let skills_dir = workspace.join(".codewhale").join("skills"); + let long = "d".repeat(500); + for index in 0..105 { + let description = if index == 0 { long.as_str() } else { "Short." }; + write_skill( + &skills_dir, + &format!("skill-{index:03}"), + description, + "Body.", + ); + } + + let context = ToolContext::new(workspace); + let result = LoadSkillTool + .execute(json!({"name": "list"}), &context) + .await + .expect("listing should succeed"); + assert!( + result.content.contains("Available skills (105)"), + "{}", + result.content + ); + for index in 0..105 { + assert!( + result + .content + .lines() + .any(|line| { line.starts_with(&format!(" - skill-{index:03}")) }), + "missing skill-{index:03}: {}", + result.content + ); + } + let capped: String = "d".repeat(400); + assert!( + result.content.contains(&format!("skill-000 — {capped}…")), + "{}", + result.content + ); + assert!(!result.content.contains(&long), "description must shorten"); + } } diff --git a/crates/tui/src/tools/spec.rs b/crates/tui/src/tools/spec.rs index 9541c47bf5..d4fad23678 100644 --- a/crates/tui/src/tools/spec.rs +++ b/crates/tui/src/tools/spec.rs @@ -116,6 +116,9 @@ pub struct RuntimeToolServices { /// Per-session backing store for `var_handle` payloads. Cloned tool /// contexts share this Arc so handles survive across turns. pub handle_store: SharedHandleStore, + /// Bounded host-computed source observations, isolated from writable + /// var_handles. Children clone the Arc, not delivery state or authority. + pub code_observations: SharedHandleStore, /// Per-session persistent RLM kernels, keyed by caller-chosen context name. pub rlm_sessions: SharedRlmSessionStore, /// Directory for `read_media`'s content-addressed store of @@ -139,6 +142,7 @@ impl Default for RuntimeToolServices { work: None, hook_executor: None, handle_store: new_shared_handle_store(), + code_observations: new_shared_handle_store(), rlm_sessions: new_shared_rlm_session_store(), media_originals_dir: None, } @@ -162,6 +166,7 @@ impl std::fmt::Debug for RuntimeToolServices { .field("work", &self.work.is_some()) .field("hook_executor", &self.hook_executor.is_some()) .field("handle_store", &true) + .field("code_observations", &true) .field("rlm_sessions", &true) .field("media_originals_dir", &self.media_originals_dir) .finish() @@ -1043,6 +1048,23 @@ impl ToolContext { tracker.reads.insert(path.to_path_buf(), snapshot); } + /// Distinct files read under `dir` so far this session. Echolocation's + /// hunt-phase sensor: 2+ tracked reads means the model is working this + /// pod and the next read there enters the terminal buzz. Lock + /// contention degrades to zero (sparse sounding), never an error. + pub(crate) fn pod_visit_count(&self, dir: &Path) -> usize { + self.file_read_tracker + .lock() + .map(|tracker| { + tracker + .reads + .keys() + .filter(|path| path.parent() == Some(dir)) + .count() + }) + .unwrap_or(0) + } + /// Require a successful, still-fresh `read_file` snapshot before a narrow /// in-place edit. This catches model edits made against guessed or stale /// content while leaving transactional patch preflight separate. diff --git a/crates/tui/src/tools/subagent/budget_handback_tests.rs b/crates/tui/src/tools/subagent/budget_handback_tests.rs index e702ce187b..2bc0bcf7d1 100644 --- a/crates/tui/src/tools/subagent/budget_handback_tests.rs +++ b/crates/tui/src/tools/subagent/budget_handback_tests.rs @@ -174,9 +174,7 @@ async fn fixture(mode: &'static str, first_tokens: u64, max_steps: u32) -> Fixtu runtime.spawn_depth = spec.spawn_depth; runtime.allow_shell = false; runtime.accept_edits = false; - runtime.step_api_timeout = if mode == "timeout" { - Duration::from_millis(100) - } else if mode == "work-timeout" { + runtime.step_api_timeout = if mode == "work-timeout" { // Past the 6s work deadline, and bounding the held hand-back call // no tighter than the 9s hard deadline already does. Duration::from_secs(10) @@ -418,8 +416,15 @@ async fn budget_handback_turn_failure_and_timeout_do_not_add_worker_retries() { ("timeout", "report deadline expired"), ] { let mut fixture = fixture(mode, 15, 1).await; + tokio::time::timeout(Duration::from_secs(30), fixture.report_started.notified()) + .await + .expect("report request must reach the fixture"); let result = fixture.finish().await; - assert_eq!(fixture.requests.lock().unwrap().len(), 2); + assert_eq!( + fixture.requests.lock().unwrap().len(), + 2, + "{mode}: {result:?}" + ); assert_eq!(result.status, SubAgentStatus::BudgetExhausted); assert!( result.result.as_deref().unwrap().contains(reason), diff --git a/crates/tui/src/tools/subagent/engine.rs b/crates/tui/src/tools/subagent/engine.rs index 77813e8db6..9f138e8775 100644 --- a/crates/tui/src/tools/subagent/engine.rs +++ b/crates/tui/src/tools/subagent/engine.rs @@ -1089,9 +1089,30 @@ impl ChildJob { .swap(true, std::sync::atomic::Ordering::Relaxed)) .then_some(notice) } - pub(crate) async fn project(&self, messages: &[Message], steps: u32) -> Result<()> { + pub(crate) async fn project( + &self, + messages: &[Message], + steps: u32, + system_prompt: Option<&SystemPrompt>, + ) -> Result<()> { let mut artifact = self.artifact.lock().await; if let Some(writer) = artifact.as_mut() { + if let Some(system) = + system_prompt.filter(|system| writer.last_system_prompt.as_ref() != Some(*system)) + { + let encoded = json_line(&json!({ + "kind": "system_prompt", "at_message_index": writer.persisted_messages, + "system_prompt": system, + }))?; + let state_root = writer.state_root.clone(); + let path = writer.path.clone(); + tokio::task::spawn_blocking(move || { + append_private_subagent_transcript(&state_root, &path, &encoded, true) + }) + .await + .map_err(|error| anyhow!("child prompt receipt worker: {error}"))??; + writer.last_system_prompt = Some(system.clone()); + } writer.sync_messages(messages, true)?; } let checkpoint = checkpoint_subagent_progress( @@ -1195,7 +1216,7 @@ impl ChildJob { text: Option, reason: Option<&str>, ) -> Result { - self.project(messages, steps).await?; + self.project(messages, steps, None).await?; let mut artifact = self.artifact.lock().await; if self.authority.runtime.cancel_token.is_cancelled() { // `project` has durably synced this exact Core Session and updated @@ -1540,7 +1561,7 @@ pub(super) async fn run_child_agent( &assignment, &agent_type, &system, - fork.as_ref(), + fork, ); let initial = seed .pop() @@ -1567,7 +1588,7 @@ pub(super) async fn run_child_agent( // No tool/model is admitted. Preserve the same full seed and the // existing Park-vs-Cancel projection, including a step-zero checkpoint. seed.push(initial); - job.project(&seed, 0).await?; + job.project(&seed, 0, None).await?; let checkpoint = runtime .manager .read() diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 7f04ecca0d..adfb98a204 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -553,7 +553,7 @@ const SUBAGENT_CHECKPOINT_MESSAGE_BUDGET_BYTES: usize = 256 * 1024; /// keeps a bounded tail plus the true `message_count` so inspection stays /// useful without pinning a whole unbounded transcript in RAM. const SUBAGENT_TRANSCRIPT_MESSAGE_BUDGET_BYTES: usize = 1024 * 1024; -const SUBAGENT_TRANSCRIPT_ARTIFACT_SCHEMA_VERSION: u32 = 1; +const SUBAGENT_TRANSCRIPT_ARTIFACT_SCHEMA_VERSION: u32 = 2; const SUBAGENT_TRANSCRIPT_ARTIFACT_DIR: &str = "subagent-transcripts"; const SUBAGENT_STATE_SCHEMA_VERSION: u32 = 1; const SUBAGENT_STATE_FILE: &str = "subagents.v1.json"; @@ -2636,13 +2636,15 @@ fn terminal_mailbox_message(result: &SubAgentResult, report_ref: Option<&str>) - } } -/// Parent transcript snapshot available to sub-agents that opt into context -/// forking. Leading messages may be inherited as context, but every child -/// keeps its own resolved system prompt so parent-specific model identity or -/// role text cannot override the worker's actual route and instructions. +/// Parent transcript snapshot for opted-in context forks. Cold forks keep +/// their resolved top-level prompt; guarded same-route inheritance may reuse +/// the parent header. Resolved role, task and skill instructions remain in +/// the later system-role fork context. Client routing and child authority are +/// enforced independently of prompt text. #[derive(Clone, Debug)] pub struct SubAgentForkContext { pub messages: Vec, + pub live_header: crate::prompt_zones::SharedLiveHeader, /// Stable, To-do-free parent state captured once at turn start. History /// semantics stay exactly as they were: this text is not re-derived per /// spawn. @@ -2682,6 +2684,7 @@ impl SubAgentForkContext { Self { messages: self.messages.clone(), + live_header: Arc::clone(&self.live_header), structured_state_block, work_source: self.work_source.clone(), } @@ -3493,7 +3496,16 @@ impl SubAgentRuntime { runtime_usage_lease: self.runtime_usage_lease.clone(), accounting_origin: self.accounting_origin.clone(), parent_completion_tx: self.parent_completion_tx.clone(), - fork_context: self.fork_context.clone(), + fork_context: self.fork_context.as_ref().map(|context| { + let mut context = context.clone(); + let snapshot = context + .live_header + .lock() + .ok() + .and_then(|cell| cell.clone()); + context.live_header = Arc::new(std::sync::Mutex::new(snapshot)); + context + }), mcp_pool: self.mcp_pool.clone(), step_api_timeout: self.step_api_timeout, api_timeout_retry_base_backoff: self.api_timeout_retry_base_backoff, @@ -10504,6 +10516,7 @@ struct SubAgentTranscriptArtifactWriter { persisted_messages: usize, /// Prefix of the current live history already in the artifact. synced_live_messages: usize, + last_system_prompt: Option, } impl SubAgentTranscriptArtifactWriter { @@ -10528,6 +10541,7 @@ impl SubAgentTranscriptArtifactWriter { relative_path, persisted_messages: 0, synced_live_messages: 0, + last_system_prompt: None, }) } @@ -10638,8 +10652,10 @@ pub fn load_subagent_transcript_artifact( .ok_or_else(|| anyhow!("sub-agent transcript artifact is empty"))?; let header: Value = serde_json::from_str(header_line)?; if header.get("kind").and_then(Value::as_str) != Some("subagent_transcript_header") - || header.get("schema_version").and_then(Value::as_u64) - != Some(u64::from(SUBAGENT_TRANSCRIPT_ARTIFACT_SCHEMA_VERSION)) + || !matches!( + header.get("schema_version").and_then(Value::as_u64), + Some(1 | 2) + ) || header.get("agent_id").and_then(Value::as_str) != Some(agent_id) { return Err(anyhow!( @@ -10650,6 +10666,24 @@ pub fn load_subagent_transcript_artifact( let mut messages = Vec::new(); for line in lines.filter(|line| !line.trim().is_empty()) { let record: Value = serde_json::from_str(line)?; + if record.get("kind").and_then(Value::as_str) == Some("system_prompt") { + anyhow::ensure!( + header.get("schema_version").and_then(Value::as_u64) == Some(2), + "system prompt record requires transcript schema 2" + ); + anyhow::ensure!( + record.get("at_message_index").and_then(Value::as_u64) + == Some(messages.len() as u64), + "system prompt record has an invalid message boundary" + ); + let _: SystemPrompt = serde_json::from_value( + record + .get("system_prompt") + .cloned() + .ok_or_else(|| anyhow!("system prompt record has no prompt"))?, + )?; + continue; + } if record.get("kind").and_then(Value::as_str) != Some("message") { return Err(anyhow!("unknown sub-agent transcript artifact record")); } @@ -12916,6 +12950,9 @@ async fn spawn_subagent_from_input( .unwrap_or(checkpoint_messages); let resume_ctx = SubAgentForkContext { messages, + // Resume forks a settled agent: nothing is live in the + // provider cache, so inheritance stays off by construction. + live_header: crate::prompt_zones::new_live_header_cell(), structured_state_block: None, work_source: None, }; @@ -13524,9 +13561,9 @@ fn subagent_skill_catalog(context: &ToolContext) -> String { } fn subagent_request_system_prompt(subagent_system_prompt: &str) -> SystemPrompt { - // Forking inherits conversation context, not the parent's identity. A - // child can have a different provider/model/profile, so its own resolved - // role prompt must stay at system precedence. + // Cold or different-route forks keep their own resolved role prompt. + // Exact-route cache inheritance is admitted later by the common Engine; + // the role/task framing remains recorded in the fork history. SystemPrompt::Text(subagent_system_prompt.to_string()) } @@ -13543,7 +13580,7 @@ fn build_initial_subagent_messages( assignment, agent_type, &system_prompt, - fork_context, + fork_context.cloned(), ) } @@ -13554,13 +13591,13 @@ fn build_initial_subagent_messages_with_system( assignment: &SubAgentAssignment, agent_type: &FleetRole, subagent_system_prompt: &str, - fork_context: Option<&SubAgentForkContext>, + fork_context: Option, ) -> Vec { - let mut messages = fork_context - .map(|context| context.messages.clone()) - .unwrap_or_default(); + let mut messages = Vec::new(); if let Some(context) = fork_context { + // Resolution already made this child's isolated copy of the parent. + messages = context.messages; if let Some(state) = context .structured_state_block .as_deref() diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index e5667e0cf9..1e521af989 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -5751,6 +5751,7 @@ fn forked_subagent_messages_preserve_parent_prefix_then_append_task() { }; let fork_context = SubAgentForkContext { messages: vec![parent_message.clone()], + live_header: crate::prompt_zones::new_live_header_cell(), structured_state_block: Some("## Fork State\n- Mode: `AGENT`".to_string()), work_source: None, }; @@ -8301,6 +8302,78 @@ fn enabled_agent_surface_options() -> AgentToolSurfaceOptions { options } +#[test] +fn deferred_agent_and_workflow_are_discoverable_by_name() { + // Diet regression: deferral must never strand the tools. The real + // registry (not fixtures) carries them deferred, and both match + // algorithms surface them for the obvious queries. + use crate::core::engine::tool_catalog::{ + TOOL_SEARCH_NAME, apply_native_tool_deferral, execute_tool_search_with_cache, + }; + use crate::core::session::ToolActivationCache; + use crate::tools::registry::ToolRegistryBuilder; + use crate::tools::{ToolContext, todo}; + use std::collections::HashSet; + + let tmp = tempdir().expect("tempdir"); + let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 4); + let registry = ToolRegistryBuilder::new() + .with_agent_runtime_surface( + None, + "test-model".to_string(), + enabled_agent_surface_options(), + todo::new_shared_todo_list(), + crate::tools::plan::new_shared_plan_state(), + ) + .with_subagent_tools(manager, stub_runtime()) + .build(ToolContext::new(tmp.path().to_path_buf())); + let mut catalog = registry.to_api_tools(); + let always_load = HashSet::new(); + apply_native_tool_deferral(&mut catalog, &always_load); + for name in ["agent", "workflow"] { + assert_eq!( + catalog + .iter() + .find(|tool| tool.name == name) + .and_then(|tool| tool.defer_loading), + Some(true), + "{name} is registered and deferred" + ); + } + + for (query, match_kind, wanted) in [ + ("agent", "regex", "agent"), + ("workflow", "regex", "workflow"), + ("spawn subagent worker", "bm25", "agent"), + // The bounded toolbox need not admit both delegation schemas for + // one broad query. Each capability must load for its own intent. + ("workflow ordered phases", "bm25", "workflow"), + ] { + let mut active = HashSet::new(); + let mut cache = ToolActivationCache::default(); + let found = execute_tool_search_with_cache( + TOOL_SEARCH_NAME, + &json!({"query": query, "match": match_kind}), + &catalog, + &mut active, + &mut cache, + ) + .expect("tool_search runs") + .metadata + .expect("metadata")["tool_references"] + .as_array() + .expect("refs") + .iter() + .filter_map(|v| v.as_str()) + .map(str::to_string) + .collect::>(); + assert!( + found.contains(&wanted.to_string()), + "{match_kind} {query:?} must surface {wanted}; got {found:?}" + ); + } +} + /// Return the exact model-visible catalog built for a default General child. /// /// Request-boundary tests use this fixture so Moonshot compatibility coverage @@ -8433,31 +8506,50 @@ async fn execute_surface_tool( } #[test] -fn small_surface_starts_with_core_tools_and_read_only_goal_control() { +fn small_surface_starts_with_core_tools_and_discoverable_goal_control() { let registry = small_surface_registry(FleetRole::Builder); let catalog = registry.deferred_catalog_for_model(&FleetRole::Builder); let mut surface = ChildSurfaceProbe::new(catalog, &[]); + // Token diet: agent+workflow+get_goal ride discovery, not the head. assert_eq!( model_tool_names(model_request_tools(&mut surface)), [ - "agent", "bash", "edit", "file_search", - "get_goal", "load_skill", "read", "task_shell_wait", "todo_write", "tool_search", - "workflow", "write", ] .into_iter() .map(str::to_string) .collect() ); + // Delegation and goal inspection remain discoverable. Root goal + // mutation belongs to the parent, not to this child. + for deferred_name in ["agent", "workflow", "get_goal"] { + assert_eq!( + surface + .catalog() + .iter() + .find(|tool| tool.name == deferred_name) + .and_then(|tool| tool.defer_loading), + Some(true), + "{deferred_name} must stay discoverable" + ); + } + for root_mutator in ["create_goal", "update_goal"] { + assert!( + !surface + .catalog() + .iter() + .any(|tool| tool.name == root_mutator) + ); + } let strict = surface.request_tools(surface.catalog().to_vec(), true); assert_eq!( strict @@ -8615,6 +8707,7 @@ fn small_surface_warms_explicitly_allowed_deferred_tools() { async fn small_surface_fork_context_survives_fresh_child_discovery() { let registry = small_surface_registry(FleetRole::Builder); let context = SubAgentForkContext { + live_header: crate::prompt_zones::new_live_header_cell(), messages: vec![ Message { role: Role::Assistant, @@ -8715,6 +8808,46 @@ async fn small_surface_denied_warm_tool_is_not_resurrected() { assert!(surface.hydrate("Web", &json!({})).is_err()); } +fn synthetic_eager_tool(name: &str) -> Tool { + Tool { + tool_type: Some("function".to_string()), + name: name.to_string(), + description: "eager".to_string(), + input_schema: json!({"type": "object", "properties": {}}), + allowed_callers: None, + defer_loading: Some(false), + input_examples: None, + strict: None, + cache_control: None, + } +} + +#[test] +fn warmed_surfaces_rebuild_byte_identical_wire_tools() { + // Same catalog plus same warm names must serialize identically, + // and warming must change the wire block. + let catalog = vec![ + synthetic_eager_tool("read"), + synthetic_deferred_tool("agent", 8), + synthetic_eager_tool(TOOL_SEARCH_NAME), + ]; + let warm = ["agent".to_string()]; + let mut first = ChildSurfaceProbe::new(catalog.clone(), &warm); + let mut second = ChildSurfaceProbe::new(catalog.clone(), &warm); + let first_wire = model_request_tools(&mut first); + let second_wire = model_request_tools(&mut second); + let first_json = crate::prompt_zones::wire_tool_catalog_json(&first_wire).expect("serializes"); + let second_json = + crate::prompt_zones::wire_tool_catalog_json(&second_wire).expect("serializes"); + assert_eq!(first_json, second_json, "same catalog plus same names"); + assert!(first_wire.iter().any(|tool| tool.name == "agent")); + let mut cold = ChildSurfaceProbe::new(catalog, &[]); + let cold_wire = model_request_tools(&mut cold); + let cold_json = crate::prompt_zones::wire_tool_catalog_json(&cold_wire).expect("serializes"); + assert_ne!(cold_json, first_json, "warming must change the wire block"); + assert!(!cold_wire.iter().any(|tool| tool.name == "agent")); +} + fn synthetic_deferred_tool(name: &str, description_bytes: usize) -> Tool { Tool { tool_type: Some("function".to_string()), @@ -8758,6 +8891,7 @@ fn small_surface_caches_are_independent_bounded_and_revalidated() { .push(synthetic_deferred_tool("oversized", 17 * 1024)); assert!(first.hydrate("oversized", &json!({})).is_err()); + // Two schemas fit the 16 KiB budget; the third must be held. let mut byte_catalog = (0..3) .map(|index| synthetic_deferred_tool(&format!("bytes_{index}"), 6 * 1024)) .collect::>(); @@ -8780,14 +8914,8 @@ fn small_surface_caches_are_independent_bounded_and_revalidated() { #[tokio::test] async fn small_surface_successful_cached_use_touches_lru() { let registry = small_surface_registry(FleetRole::Builder); - let mut catalog = registry.deferred_catalog_for_model(&FleetRole::Builder); - // Exercise the LRU with a deliberately deferred read-only fixture tool; - // production goal controls are eager and must not consume cache slots. - catalog - .iter_mut() - .find(|tool| tool.name == "get_goal") - .expect("goal read fixture") - .defer_loading = Some(true); + let catalog = registry.deferred_catalog_for_model(&FleetRole::Builder); + // Exercise the LRU with deferred goal inspection, not root mutation. let mut others = catalog .iter() .filter(|tool| tool.defer_loading == Some(true) && tool.name != "get_goal") @@ -8808,6 +8936,11 @@ async fn small_surface_successful_cached_use_touches_lru() { #[test] fn small_surface_depth_cap_removes_only_agent() { + let baseline = small_surface_registry(FleetRole::Builder); + let mut expected = model_tool_names(baseline.deferred_catalog_for_model(&FleetRole::Builder)); + assert!(expected.remove("agent")); + assert!(expected.contains("get_goal") && expected.contains("workflow")); + let mut runtime = stub_runtime().with_agent_tool_surface_options(enabled_agent_surface_options()); runtime.worker_profile = WorkerRuntimeProfile::for_role(FleetRole::Builder); @@ -8819,28 +8952,11 @@ fn small_surface_depth_cap_removes_only_agent() { crate::tools::todo::new_shared_todo_list(), crate::tools::plan::new_shared_plan_state(), ); - let mut surface = ChildSurfaceProbe::new( - registry.deferred_catalog_for_model(&FleetRole::Builder), - &[], - ); + // Compare the entire catalog, not just its eager head: deferral must + // not hide an unrelated tool disappearing at the depth boundary. assert_eq!( - model_tool_names(model_request_tools(&mut surface)), - [ - "bash", - "edit", - "file_search", - "get_goal", - "load_skill", - "read", - "task_shell_wait", - "todo_write", - "tool_search", - "workflow", - "write" - ] - .into_iter() - .map(str::to_string) - .collect() + model_tool_names(registry.deferred_catalog_for_model(&FleetRole::Builder)), + expected ); } @@ -12571,6 +12687,7 @@ fn fresh_forked_and_nested_subagents_share_authority_bound_skill_catalogs() { ); let fork_context = SubAgentForkContext { + live_header: crate::prompt_zones::new_live_header_cell(), messages: vec![Message { role: Role::User, content: vec![ContentBlock::Text { @@ -12586,7 +12703,7 @@ fn fresh_forked_and_nested_subagents_share_authority_bound_skill_catalogs() { &assignment, &FleetRole::Reviewer, &system, - Some(&fork_context), + Some(fork_context), ); assert!( forked @@ -12602,6 +12719,7 @@ fn fresh_forked_and_nested_subagents_share_authority_bound_skill_catalogs() { &direct_child, "agent_parent", SubAgentForkContext { + live_header: crate::prompt_zones::new_live_header_cell(), messages: Vec::new(), structured_state_block: None, work_source: None, @@ -13375,6 +13493,7 @@ async fn parent_todo_state_still_reaches_children_as_immutable_fork_context() { let mut parent = stub_runtime(); write_todos_as(&parent, &["parent step one"]).await; parent.fork_context = Some(SubAgentForkContext { + live_header: crate::prompt_zones::new_live_header_cell(), messages: Vec::new(), structured_state_block: Some( "## Fork State\n\n### Work\n\nTo-do (0% settled)\n- [ ] #1 parent step one\n" @@ -13383,10 +13502,54 @@ async fn parent_todo_state_still_reaches_children_as_immutable_fork_context() { work_source: None, }); + let parent_header = Arc::clone(&parent.fork_context.as_ref().unwrap().live_header); + *parent_header.lock().unwrap() = Some(crate::prompt_zones::LiveHeaderSnapshot { + system: Some(codewhale_models::SystemPrompt::Text( + "captured parent header".to_string(), + )), + tools_json: String::new(), + active_names: Vec::new(), + model: parent.model.clone(), + provider: crate::config::ProviderKind::Deepseek, + provider_identity: "fixture-parent".to_string(), + endpoint_fingerprint: Some("fixture-endpoint".to_string()), + route_receipt: Some(crate::route_receipt::TurnRouteReceipt::new( + crate::config::ProviderKind::Deepseek, + "fixture-parent", + &parent.model, + "https://api.example.test/v1", + "fake-cache-key", + )), + history: Vec::new(), + last_hit_tokens: Some(1), + }); let direct = parent.child_runtime(); let nested = direct.child_runtime(); + parent_header.lock().unwrap().as_mut().unwrap().system = Some( + codewhale_models::SystemPrompt::Text("later parent header".to_string()), + ); for (label, child) in [("direct child", &direct), ("nested child", &nested)] { + let child_header = &child.fork_context.as_ref().unwrap().live_header; + assert!(!Arc::ptr_eq(&parent_header, child_header)); + let header = child_header.lock().unwrap().clone().unwrap(); + assert_eq!( + header.route_receipt.as_ref(), + Some(&crate::route_receipt::TurnRouteReceipt::new( + crate::config::ProviderKind::Deepseek, + "fixture-parent", + &parent.model, + "https://api.example.test/v1", + "fake-cache-key", + )) + ); + assert_eq!( + header.system, + Some(codewhale_models::SystemPrompt::Text( + "captured parent header".to_string() + )), + "{label} must retain the header captured at spawn" + ); let block = child .fork_context .as_ref() @@ -17633,6 +17796,7 @@ fn nested_tool_runtime_routes_child_completions_to_local_inbox() { let direct_child_runtime = runtime_with_depth(1, Some(root_tx)); let fork_context = SubAgentForkContext { messages: Vec::new(), + live_header: crate::prompt_zones::new_live_header_cell(), structured_state_block: None, work_source: None, }; @@ -25993,7 +26157,11 @@ fn tool_search_cannot_return_a_retired_agents_tool() { let tmp = tempdir().expect("tempdir"); let registry = subagent_registry_for_catalog(tmp.path()); let mut catalog = registry.to_api_tools(); - apply_native_tool_deferral(&mut catalog, &HashSet::new()); + // Token diet: agent is searchable by default; this test's subject is + // retired-tool exclusion, so its precondition re-eagers agent through + // the same always-load path discovery uses. + let always_load = HashSet::from(["agent".to_string()]); + apply_native_tool_deferral(&mut catalog, &always_load); assert!( catalog .iter() @@ -27507,3 +27675,77 @@ async fn settled_projection_waiter_releases_origin_lease_and_mailbox_before_meta assert_eq!(usage.total_tokens, Some(20)); assert_eq!(usage.cost_microusd, Some(7)); } + +#[test] +fn eager_agent_and_workflow_stay_offered_without_search_rejection() { + use crate::core::engine::tool_catalog::{ + TOOL_SEARCH_NAME, apply_native_tool_deferral, execute_tool_search_with_cache, + }; + use crate::core::session::ToolActivationCache; + use std::collections::HashSet; + + let registry = small_surface_registry(FleetRole::Builder); + let mut catalog = registry.deferred_catalog_for_model(&FleetRole::Builder); + // Keep the eager-tool contract when a caller explicitly pins delegation; + // the default compact catalog still defers both schemas. + let always_load = ["agent", "workflow"] + .map(str::to_string) + .into_iter() + .collect(); + apply_native_tool_deferral(&mut catalog, &always_load); + let mut surface = ChildSurfaceProbe::new(catalog.clone(), &[]); + let eager_names = model_tool_names(model_request_tools(&mut surface)); + for name in ["agent", "workflow"] { + assert_eq!( + catalog + .iter() + .find(|tool| tool.name == name) + .and_then(|tool| tool.defer_loading), + Some(false), + "{name} is registered and eager" + ); + assert!(eager_names.contains(name), "{name} must already be offered"); + } + + for (query, match_kind, wanted) in [ + ("agent", "regex", "agent"), + ("workflow", "regex", "workflow"), + ("spawn subagent worker", "bm25", "agent"), + ("spawn subagent worker", "bm25", "workflow"), + ] { + let mut active = eager_names.iter().cloned().collect::>(); + let mut cache = ToolActivationCache::default(); + let metadata = execute_tool_search_with_cache( + TOOL_SEARCH_NAME, + &json!({"query": query, "match": match_kind}), + &catalog, + &mut active, + &mut cache, + ) + .expect("tool_search runs") + .metadata + .expect("metadata"); + let found = metadata["tool_references"].as_array().expect("refs"); + let unavailable = metadata["unavailable_tool_references"] + .as_array() + .expect("unavailable refs"); + assert!( + active.contains(wanted), + "{match_kind} {query:?} must keep {wanted} offered" + ); + for name in ["agent", "workflow"] { + assert!(active.contains(name), "{name} must stay active"); + assert!( + !found.iter().any(|value| value.as_str() == Some(name)), + "{match_kind} {query:?} must not re-offer eager {name}: {found:?}" + ); + assert!( + !unavailable + .iter() + .any(|value| value["tool_name"].as_str() == Some(name)), + "{match_kind} {query:?} must not reject eager {name}: {unavailable:?}" + ); + assert!(!cache.names().any(|cached| cached == name)); + } + } +} diff --git a/crates/tui/src/tools/workflow/mod.rs b/crates/tui/src/tools/workflow/mod.rs index 5cf2eecdbd..58034aec79 100644 --- a/crates/tui/src/tools/workflow/mod.rs +++ b/crates/tui/src/tools/workflow/mod.rs @@ -2465,9 +2465,10 @@ impl RunPayloadBounds { } } -/// Build the model-facing view of a run record (#2974). The JSON shape is -/// identical to the full record (panel hydration and history cards keep -/// working unchanged), but the unbounded parts are clipped: +/// Build the model-facing view of a run record (#2974). Panel hydration +/// and history cards keep working unchanged (they read keys this view +/// preserves), but the unbounded parts are clipped and each event is +/// projected to its model-essential keys: /// /// - `events`: newest `WORKFLOW_RESULT_EVENTS_TAIL` entries. /// - `progress`: newest `WORKFLOW_RESULT_PROGRESS_TAIL` lines. @@ -2477,9 +2478,14 @@ impl RunPayloadBounds { /// when the serialized value exceeds `WORKFLOW_RESULT_VALUE_MAX_CHARS`. /// - `execution.leaf_results[*].output`: per-leaf preview capped at /// `WORKFLOW_RESULT_LEAF_OUTPUT_MAX_CHARS`. +/// - event objects: exact top-level duplicates (`owner_session_id`, +/// `workflow_goal`, `workflow_id`, `source_path`, `token_budget`) are +/// removed; per-event `workspace`, routing, isolation, and payload keys stay. /// /// Full detail remains available in `.codewhale/workflow-runs.jsonl`; every -/// clip adds an explicit note/pointer so the model can fetch more on demand. +/// lossy clip adds an explicit note/pointer so the model can fetch more on +/// demand. The key projection is lossless by construction (duplicates live +/// on at top level), so it carries no pointer. fn bounded_run_record_value( record: &WorkflowRunRecord, journal_path: &Path, @@ -2506,6 +2512,31 @@ fn bounded_run_record_value( events.drain(..omitted); bounds.events_omitted = omitted; } + // Token diet: project each event to model-essential keys. Dropped + // keys are exact top-level duplicates (`RunStarted` clones the + // record's goal/id/source/budget verbatim; `owner_session_id` + // repeats on every event). `workspace` stays per event so each + // task retains the directory the model must inspect. + // `git_branch` stays per event — short run context that can vary + // per child. Routing keys (profile, resolved_*, route_source), + // worktree (isolation signal), and message/title/status/error/ + // usage stay: asserted contract (`run_record_events_project_to_ + // model_essential_keys`), and the model reads them. Full rows + // stay in the journal either way. + for event in events.iter_mut() { + let Some(fields) = event.as_object_mut() else { + continue; + }; + for key in [ + "owner_session_id", + "workflow_goal", + "workflow_id", + "source_path", + "token_budget", + ] { + fields.remove(key); + } + } bounds.events_returned = events.len(); } if bounds.events_omitted > 0 { @@ -12031,6 +12062,104 @@ FINAL RECEIPT ); } + #[test] + fn run_record_events_project_to_model_essential_keys() { + fn task_started(task_id: &str, workspace: &str) -> WorkflowUiEvent { + WorkflowUiEvent::at( + 2, + "session-test", + WorkflowUiEventKind::TaskStarted(Box::new(WorkflowTaskStartedEvent { + task_id: task_id.to_string(), + label: Some("dig".to_string()), + role: None, + profile: Some("deep".to_string()), + model: None, + strength: None, + thinking: None, + requested_reasoning: None, + effective_reasoning: None, + resolved_role: None, + resolved_profile: None, + resolved_provider: "zai".to_string(), + resolved_model: "glm-5".to_string(), + route_source: "fleet".to_string(), + child_route: None, + worktree: true, + workspace: Some(PathBuf::from(workspace)), + git_branch: Some("feature/x".to_string()), + parent_task_id: None, + depth: 0, + workflow_run_id: None, + workflow_phase_id: None, + workflow_task_label: None, + workflow_child_index: None, + queue_ticket: None, + fleet_receipt: None, + })), + ) + } + + let tmp = tempfile::tempdir().expect("tempdir"); + let mut record = WorkflowRunRecord::new( + "workflow_projection_run".to_string(), + Some("session-test".to_string()), + Some(PathBuf::from("/ws/flow.json")), + Some(1_000), + None, + ); + record.workflow_id = Some("flow-1".to_string()); + record.workflow_goal = Some("ship it".to_string()); + record.push_event(WorkflowUiEvent::at( + 1, + "session-test", + WorkflowUiEventKind::RunStarted { + workflow_id: Some("flow-1".to_string()), + workflow_goal: Some("ship it".to_string()), + source_path: Some(PathBuf::from("/ws/flow.json")), + token_budget: Some(1_000), + max_concurrent: None, + }, + )); + // Two dispatches keep their distinct workspace associations. + record.push_event(task_started("t1", "/tmp/wt-1")); + record.push_event(task_started("t2", "/tmp/wt-2")); + + let journal = tmp.path().join("workflow-runs.jsonl"); + let (payload, bounds) = bounded_run_record_value(&record, &journal); + assert!(!bounds.truncated()); + let events = payload["events"].as_array().expect("events"); + + // Duplicates are gone from every event … + for event in events { + for key in [ + "owner_session_id", + "workflow_goal", + "workflow_id", + "source_path", + "token_budget", + ] { + assert!(event.get(key).is_none(), "dropped {key}: {event}"); + } + } + // … but live on at top level while workspaces stay with their tasks. + assert_eq!(payload["workflow_goal"], "ship it"); + assert_eq!(payload["token_budget"], 1_000); + assert!(payload.get("event_workspaces").is_none()); + assert_eq!(events[1]["task_id"], "t1"); + assert_eq!(events[1]["workspace"], "/tmp/wt-1"); + assert_eq!(events[2]["task_id"], "t2"); + assert_eq!(events[2]["workspace"], "/tmp/wt-2"); + + // Routing, isolation, and payload keys stay on the dispatch event. + let dispatch = &events[1]; + assert_eq!(dispatch["task_id"], "t1"); + assert_eq!(dispatch["route_source"], "fleet"); + assert_eq!(dispatch["resolved_provider"], "zai"); + assert_eq!(dispatch["resolved_model"], "glm-5"); + assert_eq!(dispatch["worktree"], true); + assert_eq!(dispatch["git_branch"], "feature/x"); + } + #[test] fn workflow_status_payload_keeps_small_records_intact() { let tmp = tempfile::tempdir().expect("tempdir"); diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index b82c008cf6..5a9de77acb 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1582,6 +1582,12 @@ pub struct App { pub paused_goal_objective: Option, pub history: Vec, pub history_version: u64, + /// Monotonic epoch bumped by every mutation of `tool_cells`, + /// `tool_details_by_cell`, or `active_tool_details` (via + /// [`App::bump_details_epoch`]). The work-surface file-activity cache + /// keys on it together with `history_version`; a mutation that skips the + /// bump serves a stale FILES view. + pub(crate) details_epoch: u64, /// Bumped when destructive reindexing could make a cached Space owner name /// a different cell before redraw; ordinary streaming revisions preserve it. pub(crate) transcript_identity_epoch: u64, @@ -2730,10 +2736,15 @@ impl ToolRunCache { /// Refresh [`FilteredProjection`] unless it already matches the current /// projection and `collapsed_cells`. `rows` is committed plus active. pub(crate) fn refresh_filtered(&mut self, rows: usize, collapsed_cells: &HashSet) { - if self.filtered.built_for == Some(self.generation) - && self.filtered.collapsed_cells == *collapsed_cells - { - return; + if self.filtered.built_for == Some(self.generation) { + if self.filtered.collapsed_cells == *collapsed_cells { + return; + } + // A raw collapsed-set change (random-replay harness, future + // writers) rebuilds the projection without the widget's + // generation bump; fold it in so keyed consumers cannot skip + // past the changed rows. + self.generation = self.generation.wrapping_add(1); } self.filtered.original.clear(); self.filtered.summary_slots.clear(); @@ -4829,6 +4840,7 @@ impl App { /// `>= n` becomes `index - n + 1`; an index inside the folded range is /// dropped. fn shift_history_maps_down(&mut self, n: usize) { + self.bump_details_epoch(); let remap = |idx: usize| idx.checked_sub(n).map(|kept| kept + 1); self.completed_assistant_outputs .retain_mut(|receipt| match remap(receipt.history_index) { @@ -5096,6 +5108,12 @@ impl App { rev } + /// Invalidate the work-surface file-activity cache after mutating the + /// tool detail maps. Bump at the mutation site, not the call site. + pub(crate) fn bump_details_epoch(&mut self) { + self.details_epoch = self.details_epoch.wrapping_add(1); + } + /// Bring `history_revisions` back into shape (`history_revisions.len() == /// history.len()`). Pushes fresh revs for newly appended cells, truncates /// for cells that were removed. **Does not** invalidate existing entries. @@ -5336,6 +5354,7 @@ impl App { return; } self.history.truncate(new_len); + self.bump_details_epoch(); if self.history_revisions.len() > new_len { self.history_revisions.truncate(new_len); } @@ -5875,6 +5894,7 @@ impl App { self.exploring_entries.clear(); self.active_tool_details.clear(); self.active_tool_entry_completed_at.clear(); + self.bump_details_epoch(); self.streaming_thinking_active_entry = None; self.bump_active_cell_revision(); return; @@ -5915,6 +5935,7 @@ impl App { .entry(cell_index) .or_insert(detail); } + self.bump_details_epoch(); crate::tui::tool_routing::release_oldest_tool_detail_outputs(self); self.exploring_entries.clear(); diff --git a/crates/tui/src/tui/app/init.rs b/crates/tui/src/tui/app/init.rs index f9e351ff41..7fdb465428 100644 --- a/crates/tui/src/tui/app/init.rs +++ b/crates/tui/src/tui/app/init.rs @@ -802,6 +802,7 @@ impl App { paused_goal_objective: None, history: Vec::new(), history_version: 0, + details_epoch: 0, transcript_identity_epoch: 0, history_revisions: Vec::new(), tool_run_cache: ToolRunCache::default(), diff --git a/crates/tui/src/tui/tool_routing.rs b/crates/tui/src/tui/tool_routing.rs index 0cb647e926..dbb66e0158 100644 --- a/crates/tui/src/tui/tool_routing.rs +++ b/crates/tui/src/tui/tool_routing.rs @@ -390,6 +390,7 @@ fn register_tool_cell( // cell index. app.active_tool_details.insert(tool_id.to_string(), record); } + app.bump_details_epoch(); } /// Per-record ceiling on a retained tool output (#5472 finding 3). @@ -451,6 +452,7 @@ fn store_tool_detail_output( detail.output = Some(payload); } release_oldest_tool_detail_outputs(app); + app.bump_details_epoch(); } /// Hold the retained-output total under [`TOOL_DETAIL_TOTAL_BUDGET_BYTES`] by @@ -811,6 +813,7 @@ pub(super) fn handle_tool_call_complete( // live in the active cell or in finalized history; the path is the same. if let Some((cell_index, entry_index)) = app.exploring_entries.remove(id) { app.tool_cells.remove(id); + app.bump_details_epoch(); store_tool_detail_output(app, id, cell_index, result); if let Some(HistoryCell::Tool(ToolCell::Exploring(cell))) = app.cell_at_virtual_index_mut(cell_index) @@ -849,6 +852,7 @@ pub(super) fn handle_tool_call_complete( push_orphan_tool_completion(app, id, name, result); return; }; + app.bump_details_epoch(); store_tool_detail_output(app, id, cell_index, result); let in_active = cell_index >= app.history.len(); @@ -1601,6 +1605,7 @@ fn push_orphan_tool_completion( }, }, ); + app.bump_details_epoch(); // The virtual-index rebase this path used to do inline now lives in // `App::add_message`, so every mid-turn history insert gets it — not just diff --git a/crates/tui/src/tui/transcript.rs b/crates/tui/src/tui/transcript.rs index 657f422ae7..d9d8d2a6de 100644 --- a/crates/tui/src/tui/transcript.rs +++ b/crates/tui/src/tui/transcript.rs @@ -141,6 +141,14 @@ pub struct TranscriptViewCache { /// first-changed-cell search compare this against the caller's revisions /// without touching a single cached render (#6652). seen_revisions: Vec, + /// Caller-supplied whole-vector freshness key (history_version, history + /// length, active revision, active length hashed together). A match proves + /// the revision vector is identical to the previously accepted frame, so + /// the O(n) unchanged-prefix scan can be skipped (#6652 steady state). + /// `None` until a frame both supplies and accepts a key. + accepted_revision_key: Option, + /// Key staged by `note_revision_key` for the next `ensure_*` call. + pending_revision_key: Option, lines: Vec>, line_links: Vec>, line_meta: Vec, @@ -157,6 +165,11 @@ pub struct TranscriptViewCache { /// renders nothing. #[cfg(test)] cells_rendered: u64, + /// Times the unchanged-prefix revision scan ran. Proves the keyed + /// steady state skips it (#6652): a key match must not scan, while a + /// missing or mismatched key must. + #[cfg(test)] + revision_scan_runs: u64, } impl TranscriptViewCache { @@ -174,6 +187,8 @@ impl TranscriptViewCache { fold_action_rendered_cell: None, per_cell: Vec::new(), seen_revisions: Vec::new(), + accepted_revision_key: None, + pending_revision_key: None, lines: Vec::new(), line_links: Vec::new(), line_meta: Vec::new(), @@ -184,6 +199,8 @@ impl TranscriptViewCache { streaming_meta_rows_scanned: 0, #[cfg(test)] cells_rendered: 0, + #[cfg(test)] + revision_scan_runs: 0, } } @@ -251,6 +268,46 @@ impl TranscriptViewCache { ); } + /// Stage a whole-vector revision freshness key for the next `ensure_*` + /// call. When the key equals the previously accepted frame's key, the + /// unchanged-prefix scan is skipped entirely (O(1) steady state). The + /// contract: every mutation that can change any cell revision supplied to + /// the cache must move at least one key component — `history_version` + /// (bumped by every history mutation helper), history length, the active + /// cell revision, the active entry count, or the projection generation + /// (view-level collapse state). A key that is merely stale degrades to + /// the ordinary full scan; only a key that misses a real mutation would + /// serve stale frames, which is why the composition is audited against + /// every `history_revisions` write site. + pub fn note_revision_key(&mut self, key: u64) { + self.pending_revision_key = Some(key); + } + + /// Whether an `ensure_*` call with these inputs would be a no-op: the + /// staged revision key matches the accepted frame and every non-cell + /// cache input (width, options, folds, row count, action owner) is + /// unchanged. Pins every `ensure_iter` input — cell contents by the key + /// contract, layout by direct comparison, hint target by owner equality — + /// so the caller may skip building per-frame revision/ref vectors and + /// skip the call itself. + #[must_use] + pub fn keyed_skip_available( + &self, + width: u16, + options: TranscriptRenderOptions, + cell_folds: &HashMap, + total_cells: usize, + action_owner: Option, + ) -> bool { + self.pending_revision_key + .is_some_and(|key| self.accepted_revision_key == Some(key)) + && self.width == width + && self.options == options + && self.cell_folds == *cell_folds + && self.seen_revisions.len() == total_cells + && self.transcript_action_owner == action_owner + } + /// Ensure logically concatenated shards without cloning history plus the /// active tail each frame. Explicit cache inputs keep identity/fold/map indices /// attached to original virtual cells even when filtering changes positions. @@ -339,6 +396,15 @@ impl TranscriptViewCache { let layout_changed = self.width != width || self.options != options || identity_changed; let folded_changed = self.cell_folds != *cell_folds; let revisions_match = cell_revisions.len() == total_cells; + // Consume the staged whole-vector key: a match proves the revision + // vector is identical to the previously accepted frame, so the O(n) + // prefix scan below is redundant. A missing or mismatched key falls + // through to the ordinary scan. + let revision_key = self.pending_revision_key.take(); + let revision_key_matches = revision_key.is_some_and(|key| { + self.accepted_revision_key + .is_some_and(|accepted| accepted == key) + }); // Cells `0..unchanged` were rendered at exactly these revisions, so // their cached output (and what they are: kind, supersession) is // current. Layout, fold, or a mismatched revision vector (never @@ -347,13 +413,23 @@ impl TranscriptViewCache { // looking at a cell (#6652). let mut unchanged = if layout_changed || folded_changed || !revisions_match { 0 + } else if revision_key_matches { + self.seen_revisions.len().min(total_cells) } else { + #[cfg(test)] + { + self.revision_scan_runs = self.revision_scan_runs.saturating_add(1); + } self.seen_revisions .iter() .zip(cell_revisions) .position(|(seen, current)| seen != current) .unwrap_or_else(|| self.seen_revisions.len().min(total_cells)) }; + // Accept the staged key for the next frame's comparison. A frame that + // stages none clears the accepted key so a later staged key can never + // match across an unkeyed frame. + self.accepted_revision_key = revision_key; // `todo_write` replaces the whole list on every call, so only the // newest snapshot is worth a full card (#5871). When a new one lands // the previous newest must re-render collapsed; its own revision has diff --git a/crates/tui/src/tui/transcript/tests.rs b/crates/tui/src/tui/transcript/tests.rs index ed404661d6..8fd7446e3b 100644 --- a/crates/tui/src/tui/transcript/tests.rs +++ b/crates/tui/src/tui/transcript/tests.rs @@ -249,6 +249,115 @@ fn spacer_rows_after_cell(cache: &TranscriptViewCache, target_cell: usize) -> us spacer_rows } +#[test] +fn revision_key_short_circuits_the_unchanged_prefix_scan() { + let cells = vec![ + user_cell("first prompt"), + assistant_cell("first answer", false), + user_cell("second prompt"), + ]; + let revisions = vec![1u64, 2, 3]; + let options = TranscriptRenderOptions::default(); + + let mut cache = TranscriptViewCache::new(); + cache.note_revision_key(24); + cache.ensure(&cells, &revisions, 40, options); + let after_first = cache.cells_rendered; + assert!(after_first >= 3); + // The very first frame is layout-cold (width changes), so it renders + // everything through the layout path without running the scan. + assert_eq!(cache.revision_scan_runs, 0); + + // Same key, same revisions, warm layout: a scan here would find nothing, + // but the key match must skip it entirely — the counter stays put. + cache.note_revision_key(24); + cache.ensure(&cells, &revisions, 40, options); + assert_eq!(cache.cells_rendered, after_first); + assert_eq!(cache.revision_scan_runs, 0); + + // A changed key re-runs the authoritative scan. The revisions really are + // identical, so it re-renders nothing — the key accelerates but never + // invalidates. + cache.note_revision_key(25); + cache.ensure(&cells, &revisions, 40, options); + assert_eq!(cache.cells_rendered, after_first); + assert_eq!(cache.revision_scan_runs, 1); + + // An unkeyed frame clears key acceptance, so the next keyed frame scans + // again even though the key value repeats. + cache.ensure(&cells, &revisions, 40, options); + assert_eq!(cache.revision_scan_runs, 2); + cache.note_revision_key(25); + cache.ensure(&cells, &revisions, 40, options); + assert_eq!(cache.revision_scan_runs, 3); + assert_eq!(cache.cells_rendered, after_first); +} + +/// The caller-side whole-frame skip is allowed only when every `ensure_iter` +/// input is pinned equal to the accepted frame; each invalidator below must +/// independently force the full path (#6652). +#[test] +fn keyed_skip_available_requires_every_cache_input_to_match() { + use crate::tui::history::TranscriptFold; + use std::collections::HashMap; + + let cells = vec![user_cell("prompt"), assistant_cell("answer", false)]; + let revisions = vec![1u64, 2]; + let options = TranscriptRenderOptions::default(); + let mut cache = TranscriptViewCache::new(); + cache.note_revision_key(7); + cache.ensure(&cells, &revisions, 40, options); + + // Cold until a key is staged against the accepted frame. + assert!(!cache.keyed_skip_available(40, options, &HashMap::new(), 2, None)); + cache.note_revision_key(7); + assert!(cache.keyed_skip_available(40, options, &HashMap::new(), 2, None)); + + cache.note_revision_key(7); + assert!( + !cache.keyed_skip_available(41, options, &HashMap::new(), 2, None), + "width" + ); + cache.note_revision_key(7); + let mut other_options = options; + other_options.show_thinking = !other_options.show_thinking; + assert!( + !cache.keyed_skip_available(40, other_options, &HashMap::new(), 2, None), + "render options" + ); + cache.note_revision_key(7); + let mut folds = HashMap::new(); + folds.insert(0, TranscriptFold::Collapsed); + assert!( + !cache.keyed_skip_available(40, options, &folds, 2, None), + "cell folds" + ); + cache.note_revision_key(7); + assert!( + !cache.keyed_skip_available(40, options, &HashMap::new(), 3, None), + "row count" + ); + cache.note_revision_key(7); + assert!( + !cache.keyed_skip_available( + 40, + options, + &HashMap::new(), + 2, + Some(TranscriptActionOwner { + cell_index: 1, + identity_epoch: 0 + }) + ), + "action owner" + ); + cache.note_revision_key(8); + assert!( + !cache.keyed_skip_available(40, options, &HashMap::new(), 2, None), + "staged key must equal the accepted key" + ); +} + #[test] fn cache_highlights_only_the_newest_user_turn() { let cells = vec![ diff --git a/crates/tui/src/tui/ui/apply.rs b/crates/tui/src/tui/ui/apply.rs index b567d8723d..e305b05744 100644 --- a/crates/tui/src/tui/ui/apply.rs +++ b/crates/tui/src/tui/ui/apply.rs @@ -1442,6 +1442,7 @@ async fn apply_conversation_undo( app.tool_details_by_cell.clear(); app.exploring_entries.clear(); app.ignored_tool_calls.clear(); + app.bump_details_epoch(); app.mark_history_updated(); let manager = tokio::task::spawn_blocking(SessionManager::default_location).await??; let session = build_session_snapshot(app, &manager).map_err(anyhow::Error::msg)?; @@ -4435,6 +4436,7 @@ pub(crate) fn apply_loaded_session_with_goal( app.active_cell = None; app.active_tool_details.clear(); app.active_tool_entry_completed_at.clear(); + app.bump_details_epoch(); app.active_cell_revision = app.active_cell_revision.wrapping_add(1); app.exploring_entries.clear(); app.ignored_tool_calls.clear(); diff --git a/crates/tui/src/tui/ui/event_loop.rs b/crates/tui/src/tui/ui/event_loop.rs index 3577f344bf..e531adecf7 100644 --- a/crates/tui/src/tui/ui/event_loop.rs +++ b/crates/tui/src/tui/ui/event_loop.rs @@ -1200,6 +1200,7 @@ pub async fn run_tui( // the configured `shell_env` hooks. Clone the shared Arc. hook_executor: app.runtime_services.hook_executor.clone(), handle_store: app.runtime_services.handle_store.clone(), + code_observations: app.runtime_services.code_observations.clone(), rlm_sessions: app.runtime_services.rlm_sessions.clone(), media_originals_dir: crate::media_originals::default_store_dir(), }; diff --git a/crates/tui/src/tui/ui/frame.rs b/crates/tui/src/tui/ui/frame.rs index d192c0def3..817e78e376 100644 --- a/crates/tui/src/tui/ui/frame.rs +++ b/crates/tui/src/tui/ui/frame.rs @@ -2231,8 +2231,12 @@ pub(crate) fn context_usage_snapshot_for_window(app: &App, max: u32) -> Option<( // A submitted first turn has started the conversation even before the // engine mirrors its messages back, and so has any provider usage; those // keep the real pressure reading. - let conversation_started = - !app.api_messages.is_empty() || app.is_loading || count_user_history_cells(app) > 0; + let conversation_started = !app.api_messages.is_empty() + || app.is_loading + || app + .history + .iter() + .any(|cell| matches!(cell, HistoryCell::User { .. })); if !conversation_started && app.session.last_prompt_tokens.unwrap_or(0) == 0 && app.last_billed_input_tokens.unwrap_or(0) == 0 diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 987581a812..89b4195e12 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -33893,6 +33893,7 @@ fn collapsing_session_history(turns: usize) -> Vec { #[ignore = "timing benchmark, not a correctness gate"] #[allow(clippy::print_stderr)] fn bench_full_frame_scroll_cost_by_history_length() { + let retained_details = std::env::var_os("CODEWHALE_RENDER_DETAILS").is_some(); for collapsed in [false, true] { let shape = if collapsed { "collapsed" } else { "plain" }; for turns in [100usize, 1_000, 5_000] { @@ -33904,6 +33905,21 @@ fn bench_full_frame_scroll_cost_by_history_length() { } else { app.history = long_session_history(turns); } + if retained_details { + for (index, cell) in app.history.iter().enumerate() { + if let HistoryCell::Tool(ToolCell::Generic(cell)) = cell { + app.tool_details_by_cell.insert( + index, + ToolDetailRecord { + tool_id: format!("bench-{index}"), + tool_name: cell.name.clone(), + input: serde_json::json!({"path": format!("{}.txt", cell.name)}), + output: cell.output.clone(), + }, + ); + } + } + } app.resync_history_revisions(); let config = Config::default(); let mut terminal = Terminal::new(TestBackend::new(140, 40)).unwrap(); @@ -33948,10 +33964,57 @@ fn bench_full_frame_scroll_cost_by_history_length() { app.history.len(), started.elapsed() / frames ); + eprintln!( + "FRAME_PARITY {shape} {turns} details={retained_details} {}", + crate::hashing::sha256_hex(format!("{:?}", terminal.backend().buffer())) + ); } } } +/// The keyed steady-state short-circuit must not survive a projection change +/// that never bumps `history_version`: the random-replay harness (and any +/// future raw writer) can toggle `collapsed_cells` directly, and the frame +/// must still reflect it (#6652). +#[test] +fn keyed_steady_frames_rerender_after_a_raw_projection_toggle() { + use crate::tui::app::ToolCollapseMode; + + let mut app = create_test_app(); + app.tool_collapse_mode = ToolCollapseMode::Compact; + app.tool_collapse_threshold = 3; + app.history = collapsing_session_history(40); + app.resync_history_revisions(); + let config = Config::default(); + let mut terminal = Terminal::new(TestBackend::new(140, 40)).unwrap(); + let render = |app: &mut crate::tui::app::App, terminal: &mut Terminal| { + terminal + .draw(|frame| { + let _ = super::frame::render(frame, app, &config); + }) + .unwrap(); + crate::hashing::sha256_hex(format!("{:?}", terminal.backend().buffer())) + }; + let first = render(&mut app, &mut terminal); + let steady = render(&mut app, &mut terminal); + assert_eq!(first, steady, "keyed steady frame must be byte-identical"); + let hidden = app + .tool_run_cache + .filtered + .original + .iter() + .rev() + .nth(1) + .copied() + .expect("collapsed projection has rendered rows"); + app.collapsed_cells.insert(hidden); + let toggled = render(&mut app, &mut terminal); + assert_ne!( + steady, toggled, + "raw collapsed_cells toggle must change the rendered frame" + ); +} + #[test] fn g3_wheel_and_scrollbar_use_interactive_cadence() { use crate::tui::display_refresh::DrawCadenceTier; diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 95478c9e2d..28bd0c261c 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -431,83 +431,117 @@ impl ChatWidget { } let summary_cells = &app.tool_run_cache.summaries; - // Fast path: no collapsed cells — use original slices directly. - if !has_collapsed { - let mut cell_revisions: Vec = - Vec::with_capacity(app.history.len() + active_entries.len()); - cell_revisions.extend( - app.history_revisions - .iter() - .copied() - .map(history_entry_revision), - ); - if !active_entries.is_empty() { - let active_rev = app.active_cell_revision; - for i in 0..active_entries.len() { - let salt = (i as u64).wrapping_add(1); - cell_revisions.push(active_entry_revision(active_rev, salt)); + // Keyed steady state: a matching key lets the cache skip the O(n) + // unchanged-prefix scan entirely (#6652). The projection generation + // folds in view-level changes (collapse toggles) that never touch + // history revisions. Staging precedes the availability check because + // `ensure_iter` consumes the staged key. + app.viewport + .transcript_cache + .note_revision_key(transcript_revision_key( + app.history_version, + app.history.len(), + app.active_cell_revision, + active_entries.len(), + app.tool_run_cache.generation, + )); + // When every cache input is pinned equal (key, width, options, + // folds, row count, owner), the ensure call itself is dead work: + // skip building the per-frame revision/ref vectors too. + let keyed_skip = app.viewport.transcript_cache.keyed_skip_available( + transcript_width, + render_options, + &app.cell_folds, + if has_collapsed { + app.tool_run_cache.filtered.original.len() + } else { + app.history.len() + active_entries.len() + }, + provisional_action_owner, + ); + + // Keyed skip: the frame is provably identical to the accepted one; + // neither the per-frame vectors nor the ensure call are needed. + if !keyed_skip { + // Fast path: no collapsed cells — use original slices directly. + if !has_collapsed { + let mut cell_revisions: Vec = + Vec::with_capacity(app.history.len() + active_entries.len()); + cell_revisions.extend( + app.history_revisions + .iter() + .copied() + .map(history_entry_revision), + ); + if !active_entries.is_empty() { + let active_rev = app.active_cell_revision; + for i in 0..active_entries.len() { + let salt = (i as u64).wrapping_add(1); + cell_revisions.push(active_entry_revision(active_rev, salt)); + } + } + // Build identity mapping: filtered index == original index. + // Reused across frames; identity maps are rebuilt only when the + // row count changes. + let row_count = app.history.len() + active_entries.len(); + if app.collapsed_cell_map.len() != row_count { + app.collapsed_cell_map = (0..row_count).collect(); } - } - // Build identity mapping: filtered index == original index. - // Reused across frames; identity maps are rebuilt only when the - // row count changes. - let row_count = app.history.len() + active_entries.len(); - if app.collapsed_cell_map.len() != row_count { - app.collapsed_cell_map = (0..row_count).collect(); - } - let shards: [&[HistoryCell]; 2] = [&app.history, active_entries]; - app.viewport.transcript_cache.ensure_split( - &shards, - &cell_revisions, - transcript_width, - render_options, - &app.cell_folds, - None, - provisional_action_owner, - ); - } else { - // Slow path: borrow non-collapsed cells into a filtered ref list - // so collapsed cells are excluded from rendering, and build the - // filtered→original index mapping. Collapsed run starts render a - // synthetic summary cell borrowed from the generation cache. - // No history cells or summary bodies are cloned on scroll frames. - // Which rows survive is cached per projection generation; only - // the revisions are gathered fresh (#6652). - let filtered = &app.tool_run_cache.filtered; - let active_rev = app.active_cell_revision; - let mut filtered_cells: Vec<&HistoryCell> = Vec::with_capacity(filtered.original.len()); - let mut filtered_revs: Vec = Vec::with_capacity(filtered.original.len()); - for &original in &filtered.original { - if original < history_len { - filtered_cells.push(&app.history[original]); - filtered_revs.push(history_entry_revision(app.history_revisions[original])); - } else { - let active_index = original - history_len; - filtered_cells.push(&active_entries[active_index]); - filtered_revs.push(active_entry_revision( - active_rev, - (active_index as u64).wrapping_add(1), - )); + let shards: [&[HistoryCell]; 2] = [&app.history, active_entries]; + app.viewport.transcript_cache.ensure_split( + &shards, + &cell_revisions, + transcript_width, + render_options, + &app.cell_folds, + None, + provisional_action_owner, + ); + } else { + // Slow path: borrow non-collapsed cells into a filtered ref list + // so collapsed cells are excluded from rendering, and build the + // filtered→original index mapping. Collapsed run starts render a + // synthetic summary cell borrowed from the generation cache. + // No history cells or summary bodies are cloned on scroll frames. + // Which rows survive is cached per projection generation; only + // the revisions are gathered fresh (#6652). + let filtered = &app.tool_run_cache.filtered; + let active_rev = app.active_cell_revision; + let mut filtered_cells: Vec<&HistoryCell> = + Vec::with_capacity(filtered.original.len()); + let mut filtered_revs: Vec = Vec::with_capacity(filtered.original.len()); + for &original in &filtered.original { + if original < history_len { + filtered_cells.push(&app.history[original]); + filtered_revs.push(history_entry_revision(app.history_revisions[original])); + } else { + let active_index = original - history_len; + filtered_cells.push(&active_entries[active_index]); + filtered_revs.push(active_entry_revision( + active_rev, + (active_index as u64).wrapping_add(1), + )); + } } - } - for &slot in &filtered.summary_slots { - if let Some((summary, revision)) = summary_cells.get(&filtered.original[slot]) { - filtered_cells[slot] = summary; - filtered_revs[slot] = *revision; + for &slot in &filtered.summary_slots { + if let Some((summary, revision)) = summary_cells.get(&filtered.original[slot]) { + filtered_cells[slot] = summary; + filtered_revs[slot] = *revision; + } } + app.collapsed_cell_map.clone_from(&filtered.original); + + app.viewport.transcript_cache.ensure_filtered( + &filtered_cells, + &filtered_revs, + transcript_width, + render_options, + &app.cell_folds, + Some(&app.collapsed_cell_map), + provisional_action_owner, + ); } - app.collapsed_cell_map.clone_from(&filtered.original); - - app.viewport.transcript_cache.ensure_filtered( - &filtered_cells, - &filtered_revs, - transcript_width, - render_options, - &app.cell_folds, - Some(&app.collapsed_cell_map), - provisional_action_owner, - ); } let (mut total_lines, mut top, mut was_explicit_tail) = @@ -697,6 +731,11 @@ impl ChatWidget { app.viewport .ocean_semantic_surfaces .clone_from(&ocean_protected); + let ambient_life = underwater_atmosphere + && !app.attention_hold_active() + && (browsing_history + || matches!(phase, ShellPhase::Working | ShellPhase::Verifying) + || completion_life_active); Self { ocean_paint_theme: app.ui_theme, ocean_protected, @@ -717,12 +756,9 @@ impl ChatWidget { // Fish also accompany intentional transcript browsing in the // selected underwater scene. They only occupy blank cells and are // collision-checked, so history stays legible while the ocean - // remains playful when scrolling upward. - ambient_life: underwater_atmosphere - && !app.attention_hold_active() - && (browsing_history - || matches!(phase, ShellPhase::Working | ShellPhase::Verifying) - || completion_life_active), + // remains playful when scrolling upward. Hoisted above the + // row-cell borrow so `Self` never re-reads the app. + ambient_life, scroll_track, scroll_thumb, jump_border, @@ -842,6 +878,31 @@ fn history_entry_revision(revision: u64) -> u64 { revision_in_domain(revision, false) } +/// Whole-vector freshness key for the transcript cache's O(1) steady-state +/// short-circuit (`TranscriptViewCache::note_revision_key`). Composition is +/// contractual: every mutation path that can change a cell revision supplied +/// to `ensure_*` moves at least one component — `history_version` (bumped by +/// push/insert/extend/clear/truncate/bump_history_cell/mark_history_updated), +/// history length, the active cell revision, or the active entry count — and +/// every path that can change the collapsed-row projection moves the +/// `ToolRunCache` generation (threshold/mode/calm/expanded-run changes bump +/// it in the widget; raw `collapsed_cells` toggles bump it in +/// `refresh_filtered`), so a keyed frame can never skip past a projection +/// change. +fn transcript_revision_key( + history_version: u64, + history_len: usize, + active_cell_revision: u64, + active_len: usize, + projection_generation: u64, +) -> u64 { + let mut key = history_version; + key ^= (history_len as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15); + key = key.rotate_left(17) ^ active_cell_revision; + key ^ (active_len as u64).wrapping_mul(0xC2B2_AE3D_27D4_EB4F) + ^ projection_generation.wrapping_mul(0x1656_67B1_9E37_79F9) +} + pub(crate) fn active_entry_revision(active_rev: u64, salt: u64) -> u64 { // Active entries and committed history cells can occupy the same // positional cache slot across `flush_active_cell`. Keep their revision @@ -4145,9 +4206,11 @@ mod tests { height: 8, }; let mut buf = Buffer::empty(area); - let widget = ChatWidget::new(&mut app, area); - widget.render(area, &mut buf); - let rendered = buffer_text(&buf, area); + let rendered = { + let widget = ChatWidget::new(&mut app, area); + widget.render(area, &mut buf); + buffer_text(&buf, area) + }; assert_eq!(app.collapsed_cell_map, vec![0]); assert!( @@ -4271,9 +4334,11 @@ mod tests { height: 8, }; let mut buf = Buffer::empty(area); - let widget = ChatWidget::new(&mut app, area); - widget.render(area, &mut buf); - let rendered = buffer_text(&buf, area); + let rendered = { + let widget = ChatWidget::new(&mut app, area); + widget.render(area, &mut buf); + buffer_text(&buf, area) + }; assert_eq!(app.collapsed_cell_map, vec![0]); assert!( @@ -4342,9 +4407,11 @@ mod tests { height: 12, }; let mut buf = Buffer::empty(area); - let widget = ChatWidget::new(&mut app, area); - widget.render(area, &mut buf); - let rendered = buffer_text(&buf, area); + let rendered = { + let widget = ChatWidget::new(&mut app, area); + widget.render(area, &mut buf); + buffer_text(&buf, area) + }; assert_eq!(app.collapsed_cell_map, vec![0, 1, 2]); assert!(rendered.contains("read_file.txt"), "{rendered}"); @@ -6517,10 +6584,13 @@ mod tests { app.view_stack .push(crate::tui::views::HelpView::new_for_locale(app.ui_locale)); - let widget = ChatWidget::new(&mut app, Rect::new(0, 0, 100, 20)); + let (ocean_animated, ambient_life) = { + let widget = ChatWidget::new(&mut app, Rect::new(0, 0, 100, 20)); + (widget.ocean_animated, widget.ambient_life) + }; - assert!(!widget.ocean_animated); - assert!(!widget.ambient_life); + assert!(!ocean_animated); + assert!(!ambient_life); assert!(!should_render_empty_state(&app)); } @@ -6562,15 +6632,17 @@ mod tests { app.fancy_animations = true; app.ocean_completion_started_at = Some(Instant::now() - std::time::Duration::from_millis(900)); - let widget = ChatWidget::new(&mut app, Rect::new(0, 0, 100, 24)); - let age = widget - .ocean_column - .and_then(|column| column.completion_elapsed_ms()); - assert!( - age.is_some_and(|age| (800..1_400).contains(&age)), - "{age:?}" - ); - assert!(widget.life_presence_fixed > 0 && widget.life_presence_fixed < 1_000); + { + let widget = ChatWidget::new(&mut app, Rect::new(0, 0, 100, 24)); + let age = widget + .ocean_column + .and_then(|column| column.completion_elapsed_ms()); + assert!( + age.is_some_and(|age| (800..1_400).contains(&age)), + "{age:?}" + ); + assert!(widget.life_presence_fixed > 0 && widget.life_presence_fixed < 1_000); + } app.low_motion = true; let still = ChatWidget::new(&mut app, Rect::new(0, 0, 100, 24)); @@ -7165,27 +7237,32 @@ mod tests { } let area = Rect::new(2, 5, 48, 5); - let widget = ChatWidget::new_with_ocean_elapsed(&mut app, area, 0); + // The widget borrows the app's row-cell cache; capture its facts and + // drop it before the interleaved viewport asserts below re-read app. + let (widget_transcript_area, last_rendered): (Rect, String) = { + let widget = ChatWidget::new_with_ocean_elapsed(&mut app, area, 0); + let rendered: String = widget + .lines + .last() + .expect("tail line rendered") + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + (widget.transcript_area, rendered) + }; let transcript_area = app .viewport .last_transcript_area .expect("transcript geometry recorded"); assert_eq!(transcript_area, Rect::new(2, 6, 48, 4)); - assert_eq!(widget.transcript_area, transcript_area); + assert_eq!(widget_transcript_area, transcript_area); assert_eq!(app.viewport.last_transcript_visible, 4); assert_eq!( app.viewport.last_transcript_top + app.viewport.last_transcript_visible, app.viewport.last_transcript_total, "reserving the header must still resolve the real transcript to its newest tail" ); - let last_rendered: String = widget - .lines - .last() - .expect("tail line rendered") - .spans - .iter() - .map(|span| span.content.as_ref()) - .collect(); let last_cached: String = app .viewport .transcript_cache @@ -7247,13 +7324,14 @@ mod tests { } let area = Rect::new(2, 5, 48, 5); - let widget = ChatWidget::new_with_ocean_elapsed(&mut app, area, 0); - - assert_eq!( - widget.transcript_area, - Rect::new(2, 6, 48, 4), - "the header takes the first content row" - ); + { + let widget = ChatWidget::new_with_ocean_elapsed(&mut app, area, 0); + assert_eq!( + widget.transcript_area, + Rect::new(2, 6, 48, 4), + "the header takes the first content row" + ); + } assert_eq!( app.viewport.pinned_prompt_area, Some(Rect { diff --git a/crates/tui/src/tui/widgets/transcript_legacy.rs b/crates/tui/src/tui/widgets/transcript_legacy.rs index 61c3674bc2..c3e6ac4c90 100644 --- a/crates/tui/src/tui/widgets/transcript_legacy.rs +++ b/crates/tui/src/tui/widgets/transcript_legacy.rs @@ -369,83 +369,116 @@ impl ChatWidget { } let summary_cells = &app.tool_run_cache.summaries; - // Fast path: no collapsed cells — use original slices directly. - if !has_collapsed { - let mut cell_revisions: Vec = - Vec::with_capacity(app.history.len() + active_entries.len()); - cell_revisions.extend( - app.history_revisions - .iter() - .copied() - .map(history_entry_revision), - ); - if !active_entries.is_empty() { - let active_rev = app.active_cell_revision; - for i in 0..active_entries.len() { - let salt = (i as u64).wrapping_add(1); - cell_revisions.push(active_entry_revision(active_rev, salt)); + // Keyed steady state: a matching key lets the cache skip the O(n) + // unchanged-prefix scan entirely (#6652). The projection generation + // folds in view-level changes (collapse toggles) that never touch + // history revisions. Staging precedes the availability check because + // `ensure_iter` consumes the staged key. + app.viewport + .transcript_cache + .note_revision_key(transcript_revision_key( + app.history_version, + app.history.len(), + app.active_cell_revision, + active_entries.len(), + app.tool_run_cache.generation, + )); + // When every cache input is pinned equal (key, width, options, + // folds, row count, owner), the ensure call itself is dead work: + // skip building the per-frame revision/ref vectors too. + let keyed_skip = app.viewport.transcript_cache.keyed_skip_available( + transcript_width, + render_options, + &app.cell_folds, + if has_collapsed { + app.tool_run_cache.filtered.original.len() + } else { + app.history.len() + active_entries.len() + }, + provisional_action_owner, + ); + // Keyed skip: the frame is provably identical to the accepted one; + // neither the per-frame vectors nor the ensure call are needed. + if !keyed_skip { + // Fast path: no collapsed cells — use original slices directly. + if !has_collapsed { + let mut cell_revisions: Vec = + Vec::with_capacity(app.history.len() + active_entries.len()); + cell_revisions.extend( + app.history_revisions + .iter() + .copied() + .map(history_entry_revision), + ); + if !active_entries.is_empty() { + let active_rev = app.active_cell_revision; + for i in 0..active_entries.len() { + let salt = (i as u64).wrapping_add(1); + cell_revisions.push(active_entry_revision(active_rev, salt)); + } + } + // Build identity mapping: filtered index == original index. + // Reused across frames; identity maps are rebuilt only when the + // row count changes. + let row_count = app.history.len() + active_entries.len(); + if app.collapsed_cell_map.len() != row_count { + app.collapsed_cell_map = (0..row_count).collect(); } - } - // Build identity mapping: filtered index == original index. - // Reused across frames; identity maps are rebuilt only when the - // row count changes. - let row_count = app.history.len() + active_entries.len(); - if app.collapsed_cell_map.len() != row_count { - app.collapsed_cell_map = (0..row_count).collect(); - } - let shards: [&[HistoryCell]; 2] = [&app.history, active_entries]; - app.viewport.transcript_cache.ensure_split( - &shards, - &cell_revisions, - transcript_width, - render_options, - &app.cell_folds, - None, - provisional_action_owner, - ); - } else { - // Slow path: borrow non-collapsed cells into a filtered ref list - // so collapsed cells are excluded from rendering, and build the - // filtered→original index mapping. Collapsed run starts render a - // synthetic summary cell borrowed from the generation cache. - // No history cells or summary bodies are cloned on scroll frames. - // Which rows survive is cached per projection generation; only - // the revisions are gathered fresh (#6652). - let filtered = &app.tool_run_cache.filtered; - let active_rev = app.active_cell_revision; - let mut filtered_cells: Vec<&HistoryCell> = Vec::with_capacity(filtered.original.len()); - let mut filtered_revs: Vec = Vec::with_capacity(filtered.original.len()); - for &original in &filtered.original { - if original < history_len { - filtered_cells.push(&app.history[original]); - filtered_revs.push(history_entry_revision(app.history_revisions[original])); - } else { - let active_index = original - history_len; - filtered_cells.push(&active_entries[active_index]); - filtered_revs.push(active_entry_revision( - active_rev, - (active_index as u64).wrapping_add(1), - )); + let shards: [&[HistoryCell]; 2] = [&app.history, active_entries]; + app.viewport.transcript_cache.ensure_split( + &shards, + &cell_revisions, + transcript_width, + render_options, + &app.cell_folds, + None, + provisional_action_owner, + ); + } else { + // Slow path: borrow non-collapsed cells into a filtered ref list + // so collapsed cells are excluded from rendering, and build the + // filtered→original index mapping. Collapsed run starts render a + // synthetic summary cell borrowed from the generation cache. + // No history cells or summary bodies are cloned on scroll frames. + // Which rows survive is cached per projection generation; only + // the revisions are gathered fresh (#6652). + let filtered = &app.tool_run_cache.filtered; + let active_rev = app.active_cell_revision; + let mut filtered_cells: Vec<&HistoryCell> = + Vec::with_capacity(filtered.original.len()); + let mut filtered_revs: Vec = Vec::with_capacity(filtered.original.len()); + for &original in &filtered.original { + if original < history_len { + filtered_cells.push(&app.history[original]); + filtered_revs.push(history_entry_revision(app.history_revisions[original])); + } else { + let active_index = original - history_len; + filtered_cells.push(&active_entries[active_index]); + filtered_revs.push(active_entry_revision( + active_rev, + (active_index as u64).wrapping_add(1), + )); + } } - } - for &slot in &filtered.summary_slots { - if let Some((summary, revision)) = summary_cells.get(&filtered.original[slot]) { - filtered_cells[slot] = summary; - filtered_revs[slot] = *revision; + for &slot in &filtered.summary_slots { + if let Some((summary, revision)) = summary_cells.get(&filtered.original[slot]) { + filtered_cells[slot] = summary; + filtered_revs[slot] = *revision; + } } + app.collapsed_cell_map.clone_from(&filtered.original); + + app.viewport.transcript_cache.ensure_filtered( + &filtered_cells, + &filtered_revs, + transcript_width, + render_options, + &app.cell_folds, + Some(&app.collapsed_cell_map), + provisional_action_owner, + ); } - app.collapsed_cell_map.clone_from(&filtered.original); - - app.viewport.transcript_cache.ensure_filtered( - &filtered_cells, - &filtered_revs, - transcript_width, - render_options, - &app.cell_folds, - Some(&app.collapsed_cell_map), - provisional_action_owner, - ); } let (mut total_lines, mut top, mut was_explicit_tail) = @@ -733,6 +766,28 @@ fn history_entry_revision(revision: u64) -> u64 { revision_in_domain(revision, false) } +/// Whole-vector freshness key for the transcript cache's O(1) steady-state +/// short-circuit (`TranscriptViewCache::note_revision_key`). Composition is +/// contractual: every mutation path that can change a cell revision supplied +/// to `ensure_*` moves at least one component — `history_version` (bumped by +/// push/insert/extend/clear/truncate/bump_history_cell/mark_history_updated), +/// history length, the active cell revision, the active entry count — and +/// every path that can change the collapsed-row projection moves the +/// `ToolRunCache` generation. Mirrors the copy in `widgets::mod`. +fn transcript_revision_key( + history_version: u64, + history_len: usize, + active_cell_revision: u64, + active_len: usize, + projection_generation: u64, +) -> u64 { + let mut key = history_version; + key ^= (history_len as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15); + key = key.rotate_left(17) ^ active_cell_revision; + key ^ (active_len as u64).wrapping_mul(0xC2B2_AE3D_27D4_EB4F) + ^ projection_generation.wrapping_mul(0x1656_67B1_9E37_79F9) +} + pub(crate) fn active_entry_revision(active_rev: u64, salt: u64) -> u64 { // Active entries and committed history cells can occupy the same // positional cache slot across `flush_active_cell`. Keep their revision diff --git a/crates/tui/src/tui/work_surface/model.rs b/crates/tui/src/tui/work_surface/model.rs index 39f223fd31..c367d83c9e 100644 --- a/crates/tui/src/tui/work_surface/model.rs +++ b/crates/tui/src/tui/work_surface/model.rs @@ -1,6 +1,6 @@ use std::collections::HashSet; use std::fmt::Write as _; -use std::path::{Component, Path}; +use std::path::Path; use std::time::Instant; use codewhale_localization::{Locale, MessageId, tr}; @@ -350,6 +350,9 @@ pub struct WorkSurfaceState { /// TASKS view. The FILES view and its tab badge read it, so the history /// is scanned once per frame, not once per view (#6565). pub(super) file_activity: SettledFileActivity, + /// Cache key that produced `file_activity`. `None` until the first + /// [`settled_file_activity`] call; kept in lockstep by its epoch checks. + pub(super) file_activity_stamp: Option, pub top_height: u16, pub side_width: u16, pub(super) resizing: bool, @@ -446,6 +449,7 @@ impl WorkSurfaceState { terminal: super::terminal::TerminalDock::default(), explicit_view: false, file_activity: SettledFileActivity::default(), + file_activity_stamp: None, top_height: top_height.clamp(TOP_HEIGHT_MIN, TOP_HEIGHT_MAX), side_width: side_width.clamp(SIDE_WIDTH_MIN, SIDE_WIDTH_MAX), resizing: false, @@ -621,8 +625,7 @@ pub(super) fn project(app: &mut App) -> Vec { freeze_terminal_agent_elapsed(app); let agents = agent_rows(app); let coordination = coordination_row(app); - let activity = settled_file_activity(app); - app.work_surface.file_activity = activity.clone(); + let activity = settled_file_activity(app).clone(); let capture = app.runtime_services.work.as_ref().map(|work| { work.try_capture(app.current_session_id.as_deref()) .map(|snapshot| snapshot.map(|snapshot| snapshot.graph)) @@ -1257,7 +1260,7 @@ struct RankedWorkRow { } /// Complete unique targets; only the aggregate summary presentation is capped. -#[derive(Default, Clone)] +#[derive(Default, Clone, PartialEq, Eq)] pub(super) struct SettledFileActivity { pub(super) summary: FileActivitySummary, pub(super) read: Vec, @@ -2311,22 +2314,63 @@ const fn agent_mark(bucket: WorkBucket) -> &'static str { } } -pub(super) fn settled_file_activity(app: &App) -> SettledFileActivity { +/// Cache key for the settled file-activity aggregates: every input +/// [`compute_settled_file_activity`] reads. History cell and active-entry +/// mutations bump `history_version`; detail-map mutations bump +/// `details_epoch` ([`App::bump_details_epoch`]). A stale FILES view means +/// one of these changed without its bump. +#[derive(PartialEq, Eq, Clone, Debug)] +pub(super) struct FileActivityStamp { + history_version: u64, + details_epoch: u64, + inline_diff_mode: InlineDiffMode, + workspace: std::path::PathBuf, +} + +/// Settled file-activity aggregates for the FILES view and tab badges, +/// computed once per input change instead of once per frame (#6565). +pub(super) fn settled_file_activity(app: &mut App) -> &SettledFileActivity { + let stamp = FileActivityStamp { + history_version: app.history_version, + details_epoch: app.details_epoch, + inline_diff_mode: app.inline_diff_mode, + workspace: app.workspace.clone(), + }; + if app.work_surface.file_activity_stamp.as_ref() != Some(&stamp) { + app.work_surface.file_activity = compute_settled_file_activity(app); + app.work_surface.file_activity_stamp = Some(stamp); + } + &app.work_surface.file_activity +} + +/// Uncached full scan of tool detail records; the correctness reference the +/// cache above must always reproduce. +pub(super) fn compute_settled_file_activity(app: &App) -> SettledFileActivity { let mut activity = SettledFileActivity { inline_diff_mode: app.inline_diff_mode, ..SettledFileActivity::default() }; - let mut seen = HashSet::new(); - for index in 0..app.virtual_cell_count() { + // Visit only cells with details, in transcript order. Committed details win; + // stable sorting preserves the resolver's first active match at each index. + let mut records: Vec<_> = app + .tool_details_by_cell + .iter() + .map(|(&index, detail)| (index, detail)) + .chain(app.active_tool_details.values().filter_map(|detail| { + let &index = app.tool_cells.get(&detail.tool_id)?; + (!app.tool_details_by_cell.contains_key(&index)).then_some((index, detail)) + })) + .collect(); + records.sort_by_key(|(index, _)| *index); + records.dedup_by_key(|(index, _)| *index); + let mut seen = HashSet::with_capacity(records.len()); + for (index, detail) in records { let Some(HistoryCell::Tool(cell)) = app.cell_at_virtual_index(index) else { continue; }; if !cell.is_success() { continue; } - let Some(detail) = app.tool_detail_record_for_cell(index) else { - continue; - }; let activity_tool_name = canonical_action_alias(&detail.tool_name, &detail.input); let kind = if matches!(cell, ToolCell::PatchSummary(_)) { Some(FileActivityKind::Write) @@ -2360,9 +2404,9 @@ pub(super) fn settled_file_activity(app: &App) -> SettledFileActivity { FileActivityKind::Write => &mut activity.write, }; if let Some(target) = target - && !details.contains(&target) + && !details.iter().any(|existing| existing.as_str() == &*target) { - details.push(target); + details.push(target.into_owned()); } } activity @@ -2479,12 +2523,12 @@ pub(super) fn settled_mutation_body( } } -fn activity_target( - workspace: &Path, +fn activity_target<'a>( + workspace: &'a Path, tool_name: &str, - input: &serde_json::Value, + input: &'a serde_json::Value, kind: FileActivityKind, -) -> Option { +) -> Option> { if tool_name == "apply_patch" && let Ok(preflight) = crate::tools::apply_patch::preflight_apply_patch(input) { @@ -2495,7 +2539,7 @@ fn activity_target( .take(4) .collect::>(); if !targets.is_empty() { - return Some(targets.join(", ")); + return Some(std::borrow::Cow::Owned(targets.join(", "))); } } let keys: &[&str] = match kind { @@ -2508,37 +2552,70 @@ fn activity_target( return None; } if kind == FileActivityKind::Search && *key != "path" { - return Some(safe_pattern(value)); + return Some(std::borrow::Cow::Owned(safe_pattern(value))); } privacy_safe_path(workspace, value) }) } -fn privacy_safe_path(workspace: &Path, raw: &str) -> Option { - let path = Path::new(raw); - let normalized_raw = raw.replace('\\', "/"); - let normalized_workspace = workspace.to_string_lossy().replace('\\', "/"); - let relative = if path.is_absolute() || normalized_raw.starts_with('/') { +fn privacy_safe_path<'a>(workspace: &'a Path, raw: &'a str) -> Option> { + // Resolve against the raw spelling (drive/verbatim prefixes) exactly as + // before; normalization only rewrites separators. + let absolute = Path::new(raw).is_absolute() || raw.starts_with('/'); + if raw.contains('\\') { + // Rare: separator normalization allocates, so the target owns bytes. + let normalized = raw.replace('\\', "/"); + let relative = relative_workspace_path(workspace, &normalized, absolute)?; + (!relative.is_empty()).then(|| std::borrow::Cow::Owned(relative.to_string())) + } else { + let relative = relative_workspace_path(workspace, raw, absolute)?; + (!relative.is_empty()).then(|| std::borrow::Cow::Borrowed(relative)) + } +} + +/// Workspace-relative `/`-separated slice of `normalized`, or `None` when the +/// path is absolute outside the workspace or carries root/drive/`..` shapes. +/// `relative` is backslash-free UTF-8, so the component shapes +/// `Path::components` would surface are exactly: a leading root, a Windows +/// drive prefix, and `..` traversal segments. Scanning bytes rejects the same +/// inputs without iterating components or allocating. +fn relative_workspace_path<'a>( + workspace: &'a Path, + normalized: &'a str, + absolute: bool, +) -> Option<&'a str> { + let relative = if absolute { + let lossy = workspace.to_string_lossy(); + let normalized_workspace = if lossy.contains('\\') { + std::borrow::Cow::Owned(lossy.replace('\\', "/")) + } else { + lossy + }; let workspace_prefix = normalized_workspace.trim_end_matches('/'); - if normalized_raw == workspace_prefix { + if normalized == workspace_prefix { "" } else { - normalized_raw.strip_prefix(&format!("{workspace_prefix}/"))? + normalized + .strip_prefix(workspace_prefix) + .and_then(|after| after.strip_prefix('/'))? } } else { - normalized_raw.as_str() + normalized }; - let relative = Path::new(relative); - if relative.components().any(|component| { - matches!( - component, - Component::ParentDir | Component::RootDir | Component::Prefix(_) - ) - }) { + if relative.starts_with('/') { return None; } - let display = relative.to_string_lossy().replace('\\', "/"); - (!display.is_empty()).then_some(display) + if cfg!(windows) + && relative.len() >= 2 + && relative.as_bytes()[1] == b':' + && relative.as_bytes()[0].is_ascii_alphabetic() + { + return None; + } + if relative.split('/').any(|segment| segment == "..") { + return None; + } + Some(relative) } fn safe_pattern(raw: &str) -> String { @@ -3154,6 +3231,7 @@ mod tests { assert!(!names_whole("anything", "")); } use crate::tui::app::TuiOptions; + use crate::tui::history::ToolStatus; use crate::tui::tool_routing::{handle_tool_call_complete, handle_tool_call_started}; use crate::work_graph::{CompatTodoBinding, OperationBinding, WorkNodeId}; @@ -3963,7 +4041,7 @@ mod tests { app.flush_active_cell(); } - let activity = settled_file_activity(&app); + let activity = settled_file_activity(&mut app).clone(); assert_eq!( activity.summary, FileActivitySummary { @@ -3982,6 +4060,201 @@ mod tests { ); } + fn force_tool_status(cell: &mut ToolCell, status: ToolStatus) { + match cell { + ToolCell::Generic(generic) => generic.status = status, + ToolCell::Exec(exec) => exec.status = status, + ToolCell::PatchSummary(patch) => patch.status = status, + // Reads/lists explore: one aggregate cell per turn whose status + // derives from its entries, so flip them all. + ToolCell::Exploring(explore) => { + for entry in explore.entries.iter_mut() { + entry.status = status; + } + } + _ => {} + } + } + + /// Every input class the settled file-activity cache keys on must + /// invalidate it. Each step mutates through the real funnel (routing, + /// completion, flush, in-place cell mutation, truncation, session + /// clear, mode toggle) and then asserts the cached aggregates equal a + /// fresh uncached recompute — a missed epoch bump anywhere in that + /// chain surfaces here as a stale mismatch, never as a stale view. + #[test] + fn settled_file_activity_cache_invalidates_on_every_input_class() { + let mut app = test_app(); + + // Registration + completion + flush: a committed read appears. + handle_tool_call_started( + &mut app, + "cache-read-1", + "File", + &serde_json::json!({"action": "read", "path": "src/one.rs"}), + ); + handle_tool_call_complete( + &mut app, + "cache-read-1", + "File", + &Ok(ToolResult::success("ok")), + ); + app.flush_active_cell(); + assert_eq!(settled_file_activity(&mut app).summary.files_read, 1); + let expect = compute_settled_file_activity(&app); + assert_eq!(*settled_file_activity(&mut app), expect); + + // Second tool through the same funnel: aggregates move 1 -> 2. + handle_tool_call_started( + &mut app, + "cache-read-2", + "File", + &serde_json::json!({"action": "read", "path": "src/two.rs"}), + ); + handle_tool_call_complete( + &mut app, + "cache-read-2", + "File", + &Ok(ToolResult::success("ok")), + ); + app.flush_active_cell(); + assert_eq!(settled_file_activity(&mut app).summary.files_read, 2); + let expect = compute_settled_file_activity(&app); + assert_eq!(*settled_file_activity(&mut app), expect); + + // In-place committed cell mutation (`history_version` class): the + // flipped status drops that record from the aggregates. Locate the + // cell through the record map itself — index 0 assumptions don't + // survive seeded cells in agent mode. + let flip_index = app + .tool_details_by_cell + .iter() + .find(|(_, detail)| { + detail.input.get("path").and_then(serde_json::Value::as_str) == Some("src/one.rs") + }) + .map(|(index, _)| *index) + .expect("read-1 record to be committed"); + if let Some(HistoryCell::Tool(cell)) = app.cell_at_virtual_index_mut(flip_index) { + force_tool_status(cell, ToolStatus::Failed); + } + assert_eq!(settled_file_activity(&mut app).summary.files_read, 1); + let expect = compute_settled_file_activity(&app); + assert_eq!(*settled_file_activity(&mut app), expect); + + // Truncation drops everything. + app.truncate_history_to(0); + assert_eq!(settled_file_activity(&mut app).summary.files_read, 0); + let expect = compute_settled_file_activity(&app); + assert_eq!(*settled_file_activity(&mut app), expect); + + // Session-switch clear (mirrors `ui/apply.rs`: maps drained + bump). + handle_tool_call_started( + &mut app, + "cache-read-3", + "File", + &serde_json::json!({"action": "read", "path": "src/three.rs"}), + ); + handle_tool_call_complete( + &mut app, + "cache-read-3", + "File", + &Ok(ToolResult::success("ok")), + ); + app.flush_active_cell(); + assert_eq!(settled_file_activity(&mut app).summary.files_read, 1); + app.tool_cells.clear(); + app.tool_details_by_cell.clear(); + app.active_tool_details.clear(); + app.bump_details_epoch(); + assert_eq!(settled_file_activity(&mut app).summary.files_read, 0); + let expect = compute_settled_file_activity(&app); + assert_eq!(*settled_file_activity(&mut app), expect); + + // Inline-diff mode ships inside the cached payload itself. + app.inline_diff_mode = InlineDiffMode::Off; + assert_eq!( + settled_file_activity(&mut app).inline_diff_mode, + InlineDiffMode::Off + ); + let expect = compute_settled_file_activity(&app); + assert_eq!(*settled_file_activity(&mut app), expect); + } + + /// Deterministic random walk over the same mutation funnels: after + /// every step the cache must equal a fresh recompute. Catches missed + /// epoch bumps in orderings the per-class test never reaches. + #[test] + fn settled_file_activity_cache_tracks_random_mutation_sequences() { + let mut app = test_app(); + let mut state: u64 = 0x9E37_79B9_7F4A_7C15; + let mut next = move || { + state = state + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + state >> 33 + }; + let mut serial = 0usize; + for step in 0..240 { + let op = next() % 7; + match op { + 0 | 1 => { + let id = format!("rand-{serial}"); + serial += 1; + handle_tool_call_started( + &mut app, + &id, + "File", + &serde_json::json!({"action": "read", "path": format!("src/{id}.rs")}), + ); + if next() % 2 == 0 { + handle_tool_call_complete( + &mut app, + &id, + "File", + &Ok(ToolResult::success("ok")), + ); + } + } + 2 => app.flush_active_cell(), + 3 => { + let len = app.history.len(); + if len > 0 { + app.truncate_history_to((next() as usize) % (len + 1)); + } + } + 4 => { + let len = app.history.len(); + if len > 0 { + let index = (next() as usize) % len; + let target = if next() % 2 == 0 { + ToolStatus::Failed + } else { + ToolStatus::Success + }; + if let Some(HistoryCell::Tool(cell)) = app.cell_at_virtual_index_mut(index) + { + force_tool_status(cell, target); + } + } + } + 5 => { + app.inline_diff_mode = match next() % 3 { + 0 => InlineDiffMode::Full, + 1 => InlineDiffMode::Summary, + _ => InlineDiffMode::Off, + }; + } + _ => {} + } + let expect = compute_settled_file_activity(&app); + assert_eq!( + *settled_file_activity(&mut app), + expect, + "cache diverged from fresh recompute at step {step} (op {op})" + ); + } + } + #[test] fn multifile_receipt_counts_semantic_file_outcomes_in_work_label() { let mut app = test_app(); @@ -4004,7 +4277,7 @@ mod tests { handle_tool_call_complete(&mut app, "file-multi", "File", &Ok(result)); app.flush_active_cell(); - let activity = settled_file_activity(&app); + let activity = settled_file_activity(&mut app).clone(); assert_eq!(activity.summary.files_written, 4); let write_row = activity_rows(activity) .into_iter() @@ -4213,7 +4486,7 @@ mod tests { app.flush_active_cell(); } } - app.work_surface.file_activity = settled_file_activity(&app); + settled_file_activity(&mut app); assert!(app.work_surface.file_activity.mutations.is_empty()); assert_eq!(files_touched_count(&mut app), 26); let rows = files_rows(&mut app); diff --git a/crates/tui/src/tui/work_surface/views.rs b/crates/tui/src/tui/work_surface/views.rs index 2ea94b2262..4e468157e4 100644 --- a/crates/tui/src/tui/work_surface/views.rs +++ b/crates/tui/src/tui/work_surface/views.rs @@ -20,8 +20,7 @@ pub(super) const fn view_always_has_content(panel: RailPanel) -> bool { /// Files this session edited or read, even before TASKS has been projected. pub(super) fn files_touched_count(app: &mut App) -> usize { - app.work_surface.file_activity = super::model::settled_file_activity(app); - let activity = &app.work_surface.file_activity; + let activity = super::model::settled_file_activity(app); edited_files(activity).len() + activity.read.len() } @@ -52,8 +51,7 @@ fn edited_files( /// The FILES view: what this session changed (with each change's size and /// its evidence one Enter away), then what it read (#6565). pub(super) fn files_rows(app: &mut App) -> Vec { - app.work_surface.file_activity = super::model::settled_file_activity(app); - let activity = app.work_surface.file_activity.clone(); + let activity = super::model::settled_file_activity(app).clone(); let mut out = Vec::new(); let edited = edited_files(&activity); if !edited.is_empty() { @@ -228,7 +226,7 @@ pub(super) fn context_rows(app: &mut App) -> Vec { format!("tool output · {}", format_tokens(tool_output)), "", )); - let read = super::model::settled_file_activity(app).read; + let read = super::model::settled_file_activity(app).read.clone(); if !read.is_empty() { out.push(fact( "files", @@ -764,8 +762,10 @@ mod tests { ); } // Keep the old projection, as a session switch can, but replace - // the transcript that owns the next session's activity. - app.history.clear(); + // the transcript that owns the next session's activity. Go + // through `clear_history` — a raw `history.clear()` would skip + // the revision epochs the caches key on. + app.clear_history(); app.current_session_id = Some("session-b".into()); if badge_first { assert_eq!(files_touched_count(&mut app), 0); diff --git a/crates/tui/src/utils.rs b/crates/tui/src/utils.rs index a7fcb3e1ce..663b4d7258 100644 --- a/crates/tui/src/utils.rs +++ b/crates/tui/src/utils.rs @@ -168,6 +168,12 @@ pub fn summarize_project(root: &Path) -> String { } } + summarize_key_files(key_files) +} + +/// Classify admitted key files without another filesystem walk. +#[must_use] +pub(crate) fn summarize_key_files(mut key_files: Vec) -> String { key_files.sort(); if key_files.is_empty() { diff --git a/crates/tui/src/working_set.rs b/crates/tui/src/working_set.rs index a60a9383e6..de5cbdc8f5 100644 --- a/crates/tui/src/working_set.rs +++ b/crates/tui/src/working_set.rs @@ -6,6 +6,9 @@ //! - a compact working-set summary block for the system prompt //! - pinned message indices that compaction should preserve +use crate::tools::echolocation::{ + CodeReadReference, MAX_DELIVERED_REFS, delivered_navigation_refs, +}; use crate::workspace_discovery::{ DISCOVERY_ALWAYS_DIRS, path_is_excluded_from_discovery, should_skip_unignored_discovery_entry, }; @@ -1006,9 +1009,54 @@ pub struct WorkingSet { pub turn: u64, /// Path entries keyed by workspace-relative path. pub entries: HashMap, + /// Latest bounded navigation references delivered in this conversation. + /// Not body readership, current source freshness or read authority. This + /// field is never rendered into the session-pinned prompt prefix. + #[serde(default)] + pub delivered_code_refs: Vec, + #[serde(skip)] + navigation_delivery_initialized: bool, } impl WorkingSet { + /// Only provider acceptance of the projected request establishes delivery; + /// intermediate program reads are absent from these messages. + pub(crate) fn observe_navigation_delivery(&mut self, messages: &[Message]) { + // Inspect inherited context on the first accepted request, then only + // the current result tail. Do not reparse every historical payload on + // every model step just to rediscover the same references. + let start = if self.navigation_delivery_initialized { + messages + .iter() + .rposition(|message| message.role == "assistant") + .map_or(0, |index| index + 1) + } else { + 0 + }; + self.navigation_delivery_initialized = true; + for message in &messages[start..] { + for block in &message.content { + if let ContentBlock::ToolResult { content, .. } = block { + self.record_delivered_navigation(delivered_navigation_refs(content)); + } + } + } + } + + fn record_delivered_navigation(&mut self, refs: Vec) { + for reference in refs { + self.delivered_code_refs.retain(|old| { + old.path != reference.path || old.expected_revision == reference.expected_revision + }); + if !self.delivered_code_refs.contains(&reference) { + self.delivered_code_refs.push(reference); + if self.delivered_code_refs.len() > MAX_DELIVERED_REFS { + self.delivered_code_refs.remove(0); + } + } + } + } + /// Advance to the next turn. pub fn next_turn(&mut self) { self.turn = self.turn.saturating_add(1); @@ -1044,8 +1092,25 @@ impl WorkingSet { pub fn rebuild_from_messages(&mut self, messages: &[Message], workspace: &Path) { self.entries.clear(); self.turn = 0; + self.delivered_code_refs.clear(); + self.navigation_delivery_initialized = true; + let mut offered = Vec::new(); for message in messages { + // A subsequent assistant response confirms the historical request, + // unlike a trailing tool result left at a crash boundary. Fresh + // child seeds do not invoke this resume/compaction rebuild. + if message.role == "assistant" { + self.record_delivered_navigation(std::mem::take(&mut offered)); + } + for block in &message.content { + if let ContentBlock::ToolResult { content, .. } = block { + offered.extend(delivered_navigation_refs(content)); + if offered.len() > MAX_DELIVERED_REFS { + offered.drain(..offered.len() - MAX_DELIVERED_REFS); + } + } + } if message.role == "user" { self.next_turn(); } @@ -1494,7 +1559,10 @@ fn extract_paths_from_text(text: &str) -> Vec { } let re = path_regex(); - re.find_iter(text) + // Matches cannot cross line endings, and every arm needs a dot or separator. + text.lines() + .filter(|line| line.contains('/') || line.contains('\\') || line.contains('.')) + .flat_map(|line| re.find_iter(line)) .map(|m| m.as_str().to_string()) .filter(|s| looks_like_path(s)) .collect() diff --git a/crates/workflow/src/lib.rs b/crates/workflow/src/lib.rs index 63f8312f07..fbfec1d3a8 100644 --- a/crates/workflow/src/lib.rs +++ b/crates/workflow/src/lib.rs @@ -631,7 +631,7 @@ pub struct BranchResult { pub status: WorkflowRunStatus, #[serde(default)] pub usage: WorkflowUsage, - #[serde(default)] + #[serde(default, skip_serializing_if = "WorkflowMemoUsage::is_zero")] pub memo_usage: WorkflowMemoUsage, #[serde(default)] pub artifacts: Vec, @@ -652,7 +652,7 @@ pub struct LeafResult { pub status: WorkflowRunStatus, #[serde(default)] pub usage: WorkflowUsage, - #[serde(default)] + #[serde(default, skip_serializing_if = "WorkflowMemoUsage::is_zero")] pub memo_usage: WorkflowMemoUsage, #[serde(default)] pub output: Option, @@ -710,6 +710,15 @@ pub struct WorkflowMemoUsage { } impl WorkflowMemoUsage { + /// True when every counter is zero: 136 bytes of nothing on the wire. + pub fn is_zero(&self) -> bool { + self.armh_hits == 0 + && self.armh_misses == 0 + && self.armh_saved_estimated_tokens == 0 + && self.provider_prompt_cache_hits == 0 + && self.provider_prompt_cache_misses == 0 + } + pub(crate) fn add_assign(&mut self, other: Self) { self.armh_hits = self.armh_hits.saturating_add(other.armh_hits); self.armh_misses = self.armh_misses.saturating_add(other.armh_misses); @@ -766,7 +775,7 @@ pub struct WorkflowExecution { pub status: WorkflowRunStatus, #[serde(default)] pub usage: WorkflowUsage, - #[serde(default)] + #[serde(default, skip_serializing_if = "WorkflowMemoUsage::is_zero")] pub memo_usage: WorkflowMemoUsage, #[serde(default)] pub leaf_results: Vec, @@ -815,7 +824,7 @@ pub struct MockLeafOutcome { pub status: WorkflowRunStatus, #[serde(default)] pub usage: WorkflowUsage, - #[serde(default)] + #[serde(default, skip_serializing_if = "WorkflowMemoUsage::is_zero")] pub memo_usage: WorkflowMemoUsage, #[serde(default)] pub output: Option, diff --git a/docs/CACHE.md b/docs/CACHE.md index 960d59cb16..3d86ee98d7 100644 --- a/docs/CACHE.md +++ b/docs/CACHE.md @@ -102,6 +102,55 @@ change, a project-file change) into the prefix — append it as a user-role message instead. A later request must be `previous ⊕ suffix` unless a logged header change or a history reset explains the difference. +## Fork-prefix inheritance (subagents) + +A forked subagent (`fork_context=true`) on the same route/model extends the +parent's cached prefix instead of cold-starting: it adopts the parent's +exact system bytes and sends the longest byte-equal head of the parent's +wire tool block on its first request. The rest of its grant stays deferred +and activates on demand, so truncation costs cache bytes, never capability. + +Contract details: + +- **Hot proof required.** The turn loop snapshots each final request's + header plus a hit count carried forward across append-only requests; + the gate (`resolve_fork_inherit`, pure) admits only a provably hot + (`>0` hits), same-route head. Spawns execute before the current + request's usage lands, so without the carry every mid-turn spawn + would read cold. +- **Wire-canonical comparison.** Both sides canonicalize through + `tool_to_api_json` (the chat-API shape); internal-only flags + (`defer_loading`, `allowed_callers`) never reach the wire and cannot + false-negative the match. +- **Graceful tiers.** `inherited_full` (whole head) prices shared + history as hits; `inherited_prefix` (partial head) still prices the + system plus matched tools. Fresh spawns, grandchildren, resumes, + route skew, and cold prefixes stay cold with distinct reasons. +- **KV-cache effect:** frozen-prefix extension. The child's first + request is `parent-head ⊕ suffix` (framing + task); nothing volatile + is spliced into the prefix. + +Measured child first-response (same-binary A/B, DeepSeek, unique +histories; DeepSeek counts hits in 64-token blocks): true cold 0/9421 +hits; primed cold 4736/9389; prefix k=3 5504/9941; full k=6 +8576/13251. Trial readout: `CODEWHALE_FORK_TRIAL=1` mirrors the +decision, prefix span, history-divergence point, and cache split to +stderr as JSON lines; `CODEWHALE_FORK_INHERIT=on` is the experiment +arm and the default is off (see the limitation below). + +Known limitation on this architecture: the snapshot writes, the +carry, and the pure gate (`resolve_fork_inherit`) are wired, but child +admission builds the child's request inside the child Engine, so the +child-side gate (system-byte adoption, prefix-warm surface, step-1 +head) is not yet rebuilt for that path. The numbers above were measured +on the pre-actor run loop where it was; until the port lands, the flag +stays default-off because the per-request snapshot cost would buy +nothing. +Known follow-ups (measured, not implemented): fork transcripts freeze +at turn 1 (the live tail never rides, capping wins at system+tools); +framing blocks sit ahead of where the tail would; v1 is single-level +(grandchildren carry ~40KB all-miss transcripts today). + ## Deferred: full reconstructability (Layer 3) DeepSeek Harness derives every request from an append-only session log via a @@ -110,3 +159,15 @@ managed. Codewhale now pins the header and delivers drift as `` appends; the remaining step is to make the session log the single source of truth with a pure projection (and to persist the context-update baseline with it). That is a follow-up lane, not part of this change. + + +### Fork prefix inheritance + +A first-level fork may reuse a parent header only when the actual captured client declares Chat Completions, its model/provider/route key and endpoint fingerprint match, the actual opaque route receipt (including credential generation and upstream vendor) matches, and a positive cache-hit receipt reports parent cache use. Unknown actual route receipts or other wire protocols, resume forks and grandchildren start cold. Only already-active policy-filtered child tools participate; deferred parent tools are not automatically activated. The child preserves every policy-filtered active tool; inherited tool order never grants a tool or replaces the current eager toolbox. Role/task framing remains in the child history, and the actual selected header is recorded in its protected transcript. The parent header is frozen when the child runtime is captured at spawn. A route replacement retires the inherited header before the next request. Directory soundings and skill bodies stay in tool-result history. This is source behavior; provider cache savings require a live receipt. + + +### Fork trial diagnostics + +`CODEWHALE_FORK_INHERIT=off` disables fork inheritance for the process; inheritance otherwise remains enabled behind the route, protocol and hot-parent guards above. Set `CODEWHALE_FORK_TRIAL=1` before starting Codewhale to emit default-off JSON diagnostics to stderr: a classified first-fork decision and the child's first settled model response. The response reports provider input/cache-hit/cache-miss fields with missing values kept null, whether usage was reported and whether the stream failed. Component sizes measure the provider-neutral request serialization, not final HTTP wire bytes or tokens. The diagnostic contains no prompt, message, tool schema or credential text. Unknown or alternate-model wire clients remain cold and do not produce a classified decision record. These observations do not prove cache savings; compare actual provider receipts under the same binary and route. Both environment settings are process-pinned. + +A matched fork may retain parent-specific header instructions while the resolved child role, task and skill instructions follow in a recorded system-role context message. Concrete routing and permissions remain independently enforced by the client and ChildAuthority. Instruction interaction quality and cache savings need live qualification; the matching gate does not prove them. diff --git a/docs/ECHOLOCATION.md b/docs/ECHOLOCATION.md new file mode 100644 index 0000000000..2bf27528b8 --- /dev/null +++ b/docs/ECHOLOCATION.md @@ -0,0 +1,216 @@ +# Echolocation + +## Product definition and direction + +**Echolocation is Codewhale's native, feedback-driven code navigation: use each +observation to narrow the next action, obtain sufficient source evidence, and +reach a correct result with less searching, rereading, and execution overhead.** +It is not merely a footer, a repository summary, or a cache-hit percentage. + +This section defines the product target. It does not claim that all of the +adaptive behavior below is implemented; the current mechanisms and limits are +recorded separately below. + +### The navigation loop + +Borrow the useful principle of echolocation: broad orientation, a more focused +observation, then precise work near the target. More detail is useful when it +changes the next action, not simply because it is available. + +1. **Orient cheaply.** Use the query, path, requested range, and existing evidence + to identify plausible files or regions without dumping the repository. +2. **Focus.** Make observations actionable: qualified source windows and explicit + relationships, rather than names that require another search to locate. +3. **Inspect enough to act.** Return the relevant body and necessary context, such + as its imports or a directly involved helper. A tiny snippet that forces a + whole-module reread is not a successful reduction. +4. **Follow useful relationships.** Increase detail around the selected target + when a caller, callee, test, or configuration edge answers the task's question. + Label lexical, syntax-derived, and semantic evidence distinctly. Unresolved + relationships stay unresolved; same-directory proximity is not a call edge. +5. **Reobserve changes and stop expanding.** Changed source invalidates affected + references. Once there is sufficient evidence for the requested action, avoid + unrelated exploration. A host observation is not proof a model received it. + +The model still selects actions through the existing Engine and tools. Use +available request and conversation signals before inventing another planning +call or control loop. The current visit-count buzz is a starting heuristic, not +an adequate definition of task relevance. + +### Make both the navigation and its machinery efficient + +- **Avoid unnecessary model round trips.** A search result should lead directly + to useful evidence. Reuse existing tool composition for bounded dependent + steps when the next step is mechanical, not a new reasoning decision. +- **Avoid repeated host work.** Where contracts permit, one admitted byte + snapshot should serve range selection, source analysis, and echoes. Reuse + derived facts for identical source rather than scanning, parsing, or + serializing them again unnecessarily. +- **Pay only for useful observations.** Small output alone does not imply small + execution cost. Bound analysis and relationship traversal, not just rendering; + expensive optional detail must earn its cost in the current operation. +- **Keep reuse honest.** Freshness and current access checks remain mandatory. + Do not replace current-byte validation with an mtime-only shortcut, or confuse + host analysis reuse, provider prefix reuse, and model-visible delivery. +- **Stay native, index-free, and bounded.** Echolocation is the backend for the + existing Codewhale tools. Extend their Engine, read/search/edit and LSP paths + and bounded observations; do not build a repository index, indexing service, + daemon, or parallel navigation system. + +### First implementation direction and acceptance + +Start with the existing Rust read-to-reference-to-focused-read path, not a new +repository-wide navigation framework. Profile cold, repeated, and post-edit +reads; distinguish source I/O and validation, line selection, syntax analysis, +neighbor traversal, serialization, and model wait time. Choose the first change +from measured avoidable work, and keep that change separate from new behavior. + +The first backend optimization passes ready Rust syntax observations into +`sound_echolocation`, avoiding the legacy symbol scan whose results were previously +replaced during rendering. Unavailable syntax observations still use the legacy +scanner. Import roots are now collected during the existing link scan rather +than a separate whole-source traversal; reverse-link callers skip unused import +extraction. Link targets are still resolved against the filesystem on each call. +Repeated whole-source loading, hashing, and line scanning for narrow reads remain +investigation targets, not permission to weaken freshness. + +Judge improvements by correct completed tasks first, then time to useful source +evidence, end-to-end completion time, model round trips, tool latency, source +bytes read, delivered context, and total token cost. Include cold-start, +invalidation, memory, and cancellation costs. Keep unsuccessful runs. A faster +read that causes more searches, wrong answers, or excessive retained state is not +a win. The existing fixed-task benchmark is a regression anchor, not the whole +product definition; each new capability needs a task that genuinely exercises it. + +## Current implementation + +Bounded navigation soundings. File tools echo their surroundings fresh +from admitted source bytes -- no repository-wide index, background work, +or new dependencies. Soundings are deterministic for the same source, +visibility gates, and buzz state: sorted, capped, byte-budgeted, and +`/`-separated on every OS. They ride tool results (append-only history), +never the session-pinned prefix. Repeating a result later in a transcript +does not itself earn a prefix-cache hit: reuse requires the same preceding +request bytes, tool order, and provider route. + +## What each surface emits + +- **read** appends a footer: `[Pod (N): …]` sibling mates (bare + names — the dir is the file just read, so it goes unstated) plus + `[Sound: N symbols: …; imports: …; links: …]` (line-numbered + symbols, import roots, cross-pod file links; same-pod links render + bare and import roots a link already names (file stem or package + path) are dropped). Lowercase Rust `read` also publishes bounded syntax + navigation as described below. Total footer budget remains 1200B. +- **Terminal buzz**: after two distinct files are read in one pod, the + next read there adds `heard by …` — podmates whose resolved links + include this file. Sparse clicks while searching, high-resolution + echoes at close range. +- **read miss** (`NotFound` only — never on denylist/permission refusals) + appends `[Miss echo: ]`: podmates of the missing file's parent, + so a typo still orients the next call. +- **grep / file_search** chart each matched dir: mates with `(match)` + marks, under the calling search's own visibility gates (denylist, + excludes, extensions, gitignore). Pods never enumerate names the + calling search hides. +- **project_map** sounds each key file: one line of `name:line` symbols + per file, so the map says what lives where. +- **write / edit** append `[Edit echo: touched …; heard by …]`: the + symbols the mutation touched (nearest declaration at or above each + changed span, via the same `similar` diff that builds the unified + diff, under a 100ms timeout) and the podmates linking the file. + The model-facing receipt is one line and the diff rides metadata + for the TUI, so the echo is how the model learns what its edit hit. + +## Languages + +Symbol, import, and link sounding covers Rust, Python, JavaScript / +TypeScript, and Go via deliberately small line scanners (see +`crates/tui/src/tools/echolocation.rs` for the documented lookalike +classes). Anything unresolvable is omitted, never guessed; links +require an on-disk target. + +## Revision-qualified Rust navigation + +The lowercase `read` path uses the already-installed `syn` parser to observe +outer Rust functions and impl methods, including inline modules. A bounded +`metadata.navigation` object identifies its producer (`rust_syntax_v1`), the +workspace-relative path, source-byte SHA-256 revision, availability and omission +counts. Each symbol carries a `read` object: `path`, 1-based line `offset`, line +`limit`, and `expected_revision`. Pass that object directly to `read` to inspect +the captured function window. No separately named navigation tool is required. + +Observations come from the same byte snapshot as the read, not another filesystem +read. Syntax is not semantic resolution: macros, active cfg, external modules, +type/name resolution and LSP coordinates are not claimed. Ranges start on the +`fn` keyword's line; preceding attribute/modifier lines and local items are not +included. Invalid UTF-8, parse errors and sources above +256 KiB report `unavailable`, never invented ranges. At most 24 symbols and 4 KiB +of serialized navigation are retained; counts identify omitted observations. + +A `[Code navigation: ...]` footer renders whole reference objects under 600B, +sharing the existing 1200B budget with the pod/sounding sections. Long paths can +leave no room for an inline reference; the bounded metadata remains available to +`execute_tools`. Existing scanners still serve other languages and other echo +surfaces. The successful Rust read's symbol sounding consumes these syntax facts. + +### Shared facts, separate delivery + +`RuntimeToolServices` reuses the existing bounded `HandleStore` implementation in +a **separate, host-owned store**, not the writable program `var_handle` namespace. +Keys bind canonical workspace, relative path and producer; cached values are +reused only after normal path/authority admission and matching the **current byte +revision**. There is no watcher, persistent index, permission bypass or mtime-only +freshness claim. Child runtime clones share the physical store, not a parent's +delivery ledger. Cache eviction simply recomputes the observation on a later read. + +An optional `expected_revision` guard rejects stale source before using old +offsets, including a same-size external edit. Without it, ordinary reads behave +normally. A reference is evidence of a range, never authority to access that path. + +`WorkingSet` retains at most 32 delivered references **only after the final +projected request is accepted by the model client**. It inspects model-facing +tool-result text, not nested calls or the physical cache. A program can read source +internally and return only `{done: true}` without seeding this ledger; returning a +selected structured reference records just that reference. JSON strings inside +logs/argument previews are not decoded as deliveries. Inspection is conservative +and bounded to 2048 JSON nodes and 16 nesting levels per result. + +The session's existing tool-result log retains the same projected reference +objects. Resume/compaction rebuilds use that conversation, requiring a subsequent +assistant message before classifying a historical result as delivered; a trailing +crash-boundary result alone is not proof. This ledger does not claim body readership, +current freshness, complete historical delivery or deduplication of future reads. +Fresh child seeds do not rebuild it from a parent's ledger. + +**KV-cache effect:** navigation is tool-result history only, never a volatile +prefix contributor. The optional `read` schema field is static and session-pinned +with the tool catalog. No new system-prompt text is inserted. + +## Tuning and ablation + +`CODEWHALE_ECHO` gates sections for trials +(`off:`, comma-separated; names: `mates`, `symbols`, `imports`, +`links`, `callers`, `pods`, `soundings`, `navigation`). Unset or malformed means +everything on. The gates are process-pinned and read-only; there is no +per-session or per-call control, by design. The squeeze follows the +same gates, so an `off:links` trial measures links alone. +`off:navigation` suppresses the inline navigation footer only; structured +metadata remains available to programs and still uses the syntax producer. + +## Non-goals + +- No tool description teaches the footer format: the brackets are + self-describing, and every description byte on an eager tool taxes + the session prefix. The diet stops where behavior would degrade. +- No cross-pod callers: reverse links scan same-dir mates only, so the + cost of a sounding is bounded by one directory listing plus a + bounded re-read of small mates. +- No `patch` (multi-file) echoes yet; single-file write/edit only. + + +## Source and scan limits + +Callers are inspected from at most 32 sorted visible language-file candidates. Each accepted caller body is at most 256 KiB; one extra byte detects overflow. Unscanned candidates are reported explicitly. Key-file body sounding accepts at most 8 MiB and detects overflow the same way. Reads use the existing protected regular-file handle and reject unsafe file shapes before parsing. Directory name enumeration and platform/kernel read latency remain outside these body caps. Delete attribution uses the old-side range; replacement attribution uses both old and new ranges. These source rules do not qualify Windows behavior or provider cache savings. + +Search pod enumeration reuses the depth-one ignore walker: parent ignore files, global rules, repository gitignore and info/exclude are honored when requested. Symlinks are not followed. Include/exclude/extension and deny guards remain applied, and the unlistable-directory fallback only repeats already matched non-dotfile names. Directed reads bypass ignore filtering while retaining denial and protected-read guards. diff --git a/docs/MODES.md b/docs/MODES.md index 060681d67b..a0c7c425bb 100644 --- a/docs/MODES.md +++ b/docs/MODES.md @@ -35,7 +35,7 @@ Run `/mode` to open the mode picker, or switch directly with `/mode work`, `/mode plan`, or `/mode operate`. - **Plan**: design-first prompting. The stable primitive names remain familiar, but the runtime centrally refuses file mutation and shell execution. Read-only inspection and policy-allowed research, including deferred Web search/fetch, remain available. When a Plan turn successfully finishes a nonempty response, including a prose-only plan, the TUI asks how to continue: Work (Ask), Work (Auto-Review), or keep planning. Work sends the exact approved response as a normal follow-up and preserves that turn's existing To-dos, or adds one pending plan item without replacing unrelated work. -- **Work** (internally `agent`): ordinary multi-step execution. The first-turn toolbox includes `read`, `write`, `edit`, `file_search`, `bash`, `agent`, `workflow`, `todo_write`, and `load_skill`, plus `create_goal`, `get_goal`, `update_goal`, and `task_shell_wait` so goal and background-shell controls are available without discovery. The agent calls `create_goal` when a request is a durable objective; the host never infers a goal from wording, and the contract must not tell the model otherwise. Approval, sandbox, repository law, and managed policy decide what may execute. +- **Work** (internally `agent`): ordinary multi-step execution. The first-turn toolbox includes `read`, `write`, `edit`, `file_search`, `bash`, `todo_write`, `load_skill`, and `task_shell_wait`, plus `create_goal` and the synthetic `tool_search`; `agent`, `workflow`, `get_goal`, and `update_goal` load through discovery (`get_goal`/`update_goal` ride eager while a goal is active). The agent calls `create_goal` when a request is a durable objective; the host never infers a goal from wording, and the contract must not tell the model otherwise. Approval, sandbox, repository law, and managed policy decide what may execute. - **Operate**: Work at full strength, the "ultra" mode. Same tools and authority as Work; the difference is how they are used. Each substantive request becomes a goal (`create_goal`), the plan stays visible (`todo_write`), multi-part work runs through `workflow` and parallel `agent` workers, non-trivial changes get an independent reviewer before they are called done, long commands keep running in the background, and recurring or watch-type work is offered as an `automation` (created after approval). It keeps going until the goal is verified, blocked on you, or paused. `Act` and `/mode act` remain compatibility aliases for Work. Saved settings diff --git a/docs/TOOL_SURFACE.md b/docs/TOOL_SURFACE.md index 2b0b5e0406..195741c8c2 100644 --- a/docs/TOOL_SURFACE.md +++ b/docs/TOOL_SURFACE.md @@ -19,31 +19,30 @@ Implementation sources: ## Default-active contract -New turns start with thirteen eager native names plus synthetic `tool_search`: +New turns start with nine eager native names plus synthetic `tool_search`: 1. `read` 2. `write` 3. `edit` 4. `file_search` 5. `bash` -6. `agent` -7. `workflow` -8. `todo_write` +6. `todo_write` +7. `task_shell_wait` +8. `load_skill` 9. `create_goal` -10. `get_goal` -11. `update_goal` -12. `task_shell_wait` -13. `load_skill` -14. `tool_search` (synthetic, always active) +10. `tool_search` (synthetic, always active) -The thirteen native names are `DEFAULT_ACTIVE_NATIVE_TOOLS` in +The nine native names are `DEFAULT_ACTIVE_NATIVE_TOOLS` in `crates/tui/src/core/engine/tool_catalog.rs`, pinned by -`default_active_contract_keeps_discovery_and_core_tools_eager`. An authority -boundary may remove `agent` at the maximum child depth, and Plan registers only -the foreground shell surface (so no `task_shell_wait`), but route size alone -must not change this core vocabulary. +`default_active_contract_keeps_discovery_and_core_tools_eager`. `agent`, +`workflow`, `get_goal`, and `update_goal` are deferred by default and load +via `tool_search` (`get_goal`/`update_goal` ride eager only while a goal is +active). An authority boundary may remove `agent` at the maximum child depth, +and Plan registers only the foreground shell surface (so no `task_shell_wait`), +but route size alone must not change this core vocabulary. -The direct schemas deliberately stay small: +The core schemas deliberately stay small (deferred rows load via +`tool_search` rather than riding every turn): | Tool | Input | Purpose | |---|---|---| @@ -52,12 +51,12 @@ The direct schemas deliberately stay small: | `edit` | `path`, `edits` | Apply one or more unambiguous text replacements against one original snapshot. | | `file_search` | `query`, optional `path`, `limit`, `extensions`, `exclude` | Find files by name, path fragment or glob and return bounded, ranked matches. Respects ignore files; `exclude` replaces default exclusions. | | `bash` | `command`, optional `timeout`, optional `description` | Run one cancellable foreground shell command and return a bounded tail. The optional description is display-only. | -| `agent` | delegated task and optional scope/context controls | Start or inspect focused child work. | -| `workflow` | plan/script/source_path plus run controls | Coordinate multi-agent phases with dependencies and completion checks. | +| `agent` (deferred) | delegated task and optional scope/context controls | Start or inspect focused child work. | +| `workflow` (deferred) | plan/script/source_path plus run controls | Coordinate multi-agent phases with dependencies and completion checks. | | `todo_write` | complete replacement list of `{content, status}` items | Keep optional, agent-owned progress notes for genuinely multi-step work. | | `create_goal` | objective plus optional budget | Start the session goal the turn works toward. | -| `get_goal` | none | Read the active goal and its progress. | -| `update_goal` | terminal status | Mark the goal complete or blocked. | +| `get_goal` (deferred unless a goal is active) | none | Read the active goal and its progress. | +| `update_goal` (deferred unless a goal is active) | terminal status | Mark the goal complete or blocked. | | `task_shell_wait` | `task_id`, optional `wait`, `timeout_ms`, `cancel`, `gate` | Poll or stop a background shell task. Completion still arrives as a runtime event, so use it for early output, true barriers, or gate evidence. | | `load_skill` | optional `name`, `query` | Load a named skill's `SKILL.md` body and companion-file list; omit `name` or use `name="list"` to list eligible skills, or use `query` to search names and descriptions. | | `tool_search` | `query`, optional matching controls | Discover policy-allowed deferred tools and add selected schemas to this conversation's toolbox. | @@ -399,7 +398,13 @@ above must report `1 passed` (the ignored metrics test reports `1 passed` only because `--ignored` selects it); `0 passed` means the filter matched nothing and the check did not run. -The provider-free receipt must report the thirteen default-active names listed +The provider-free receipt must report the ten default-active names listed +above. above. A separate repository-wide tool count may include deferred, dynamic, feature-gated, and compatibility-only registrations; it is not the number of tools placed in the first-turn model catalog. + + +### Source navigation receipts + +Source reads may include a bounded pod/symbol/link footer. Edits retain their unified diff in metadata and can name affected declarations and confirmed same-directory callers. File-name search and grep report matched directory pods; project maps can report bounded key-file symbols. Sounding content uses the same denied-read and credential exclusions plus protected regular-file handles. Caller content scans at most32 deterministic candidates at256KiB each; candidates outside that budget are reported as unscanned, never as additional known callers. Key-file content is capped at8MiB. Enumeration itself and platform I/O latency are not wall-clock bounded. diff --git a/docs/features.toml b/docs/features.toml index fe4e5e8f4b..328530b887 100644 --- a/docs/features.toml +++ b/docs/features.toml @@ -759,3 +759,33 @@ surfaces = { tui = "preview" } since = "0.10.2" docs = "docs/PLUGINS.md" owner = "crates/tui/src/plugins/mutation.rs" + +[[feature]] +id = "fork-prefix-inheritance" +name = "Fork prefix inheritance" +summary = "Allows matching first-level Chat Completions forks to reuse a parent request header." +status = "experimental" +surfaces = { tui = "preview", api = "preview" } +since = "0.10.2" +docs = "docs/CACHE.md#fork-prefix-inheritance" +owner = "crates/tui/src/core/engine/turn_loop/preparation.rs" + +[[feature]] +id = "fork-trial-diagnostics" +name = "Fork trial diagnostics" +summary = "Opt-in JSON stderr observations of classified fork decisions and actual first-response cache usage." +status = "flagged" +surfaces = { tui = "preview", api = "preview" } +since = "0.10.2" +docs = "docs/CACHE.md#fork-trial-diagnostics" +owner = "crates/runtime/src/prompt_zones.rs" + +[[feature]] +id = "source-navigation-echoes" +name = "Source navigation echoes" +summary = "File and search results include bounded source symbols, neighboring files and change attribution." +status = "experimental" +surfaces = { tui = "preview", api = "preview" } +since = "0.10.2" +docs = "docs/ECHOLOCATION.md" +owner = "crates/tui/src/tools/echolocation.rs" diff --git a/docs/public-surface-facts.json b/docs/public-surface-facts.json index aa2f63fdec..cec94c5dcf 100644 --- a/docs/public-surface-facts.json +++ b/docs/public-surface-facts.json @@ -105,14 +105,11 @@ "edit", "file_search", "bash", - "agent", - "workflow", "todo_write", "create_goal", - "get_goal", - "update_goal", "task_shell_wait", "load_skill" + ], "schemas": { "read": [ diff --git a/docs/zh_hans/CACHE.md b/docs/zh_hans/CACHE.md index 989836b93b..52e81b74ee 100644 --- a/docs/zh_hans/CACHE.md +++ b/docs/zh_hans/CACHE.md @@ -98,3 +98,14 @@ DeepSeek Harness 通过一个纯函数投影 `deriveMessages()`,从只追加 Codewhale 现在固定头部,遇到漂移就追加一条 `` 送出去; 剩下的一步是让会话日志成为唯一事实来源,并配上纯投影 (同时把上下文更新基线一并持久化)。那是后续的独立工作线,不属于本次变更。 + + +### 分叉前缀继承 + +首层分叉只有在实际客户端声明 Chat Completions、模型/提供商/路由身份及端点指纹一致,且实际不透明路由回执(包含凭据代际和上游供应商)一致,并且父请求有正缓存命中回执时,才可复用父头部。未知实际路由回执或其他协议、恢复分叉和孙级子智能体均冷启动。父头部在捕获子运行时时固定。只有子智能体已激活且通过权限过滤的工具参加匹配,父级延迟工具不会自动激活。子智能体保留权限过滤后的全部激活工具;继承顺序不会授予权限或删除当前预加载工具。任务与角色留在历史中,实际采用的头部记入受保护转录。路由改变会在下一请求前撤销继承;目录回声和技能正文只进入工具结果历史。真实提供商缓存收益仍需实际回执。 + +### 分叉试验诊断 + +启动进程前设置 `CODEWHALE_FORK_INHERIT=off` 可关闭继承;默认开启仍受上述守卫限制。设置 `CODEWHALE_FORK_TRIAL=1` 会向 stderr 输出默认关闭的 JSON:已分类的首层分叉决策,以及子智能体首个已结算模型响应。响应保留提供商输入/命中/未命中字段的缺失值(null),并说明是否报告用量、流是否失败。各部分字节数是提供商中立请求的序列化大小,不是最终 HTTP 线格式或 token 数。不会输出提示、消息、工具 schema 或凭据正文。未知协议或替代模型客户端仍冷启动且无已分类决策记录。这些诊断不能证明节省;需比较相同二进制和路由下的实际提供商回执。两个设置均在进程中固定。 + +匹配的分叉可能保留父级头部指令,已解析的子角色、任务和技能指令则以已记录的后续 system-role 上下文消息提供。实际路由和权限仍由客户端与 ChildAuthority 独立执行。指令之间的质量影响及缓存节省尚需真实调用验证,匹配守卫本身不证明这些结果。 diff --git a/docs/zh_hans/MODES.md b/docs/zh_hans/MODES.md index 0a544919f7..e409eb23bc 100644 --- a/docs/zh_hans/MODES.md +++ b/docs/zh_hans/MODES.md @@ -20,7 +20,7 @@ Workflow 通过同一个子智能体运行时执行命名步骤;Fleet 管理 按 `Tab` 补全输入框(composer)菜单,或在输入框为空时循环切换可见模式:**Plan → Work → Operate → Plan**。`Tab` 从不发送或排队输入框中的文本;用 `Enter` 发送或排队。按 `Shift+Tab` 循环切换权限姿态(Ask → Auto-Review → Full Access)。按 `Ctrl+T` 循环切换思考强度。运行 `/mode` 打开模式选择器,或直接用 `/mode work`、`/mode plan`、`/mode operate` 切换。 - **Plan**:设计优先的提示方式。原语(primitive)名称保持不变,仍是大家熟悉的那些,但运行时会集中拒绝文件修改和 shell 执行。只读检查与策略允许的研究(包括延迟加载的 Web 搜索/抓取)仍然可用。当一个 Plan 回合成功结束并给出非空回复(包括纯文字计划)时,TUI 会询问如何继续:Work(Ask)、Work(Auto-Review),或继续规划。选择 Work 会把已批准的原文作为普通后续消息发送,并保留该回合已有的 To-dos,或追加一个待办的计划项而不替换无关的工作。 -- **Work**(内部为 `agent`):普通的多步执行。第一回合的工具箱包含 `read`、`write`、`edit`、`file_search`、`bash`、`agent`、`workflow`、`todo_write` 和 `load_skill`,以及无需先搜索即可使用的目标与后台 shell 控制工具 `create_goal`、`get_goal`、`update_goal` 和 `task_shell_wait`。当请求属于一个持久的目标时,智能体会调用 `create_goal`;宿主从不根据措辞去推断目标,契约也不得告诉模型相反的内容。审批、沙箱、仓库规则(repository law)和托管策略决定什么可以执行。 +- **Work**(内部为 `agent`):普通的多步执行。第一回合的工具箱包含 `read`、`write`、`edit`、`file_search`、`bash`、`todo_write`、`load_skill` 和 `task_shell_wait`,以及 `create_goal` 与合成工具 `tool_search`;`agent`、`workflow`、`get_goal` 和 `update_goal` 通过搜索发现加载(`get_goal`/`update_goal` 在目标激活时直接可用)。当请求属于一个持久的目标时,智能体会调用 `create_goal`;宿主从不根据措辞去推断目标,契约也不得告诉模型相反的内容。审批、沙箱、仓库规则(repository law)和托管策略决定什么可以执行。 - **Operate**:以全力运行的 Work,即“ultra”模式。工具与权限和 Work 相同,区别在于如何使用它们。每个实质性请求都会成为一个目标(`create_goal`),计划保持可见(`todo_write`),多部分工作通过 `workflow` 和并行的 `agent` 工作者执行,非平凡的改动在宣布完成前先交给独立的审查者,长时间运行的命令继续在后台运行,循环或监视类工作则作为 `automation` 提出(获批后创建)。它会一直进行,直到目标被验证、被你阻塞或被暂停。 `Act` 和 `/mode act` 仍然是 Work 的兼容别名。保存的设置仍然规范化为内部值 `agent`。 diff --git a/docs/zh_hans/TOOL_SURFACE.md b/docs/zh_hans/TOOL_SURFACE.md index f436a3a448..132fab5989 100644 --- a/docs/zh_hans/TOOL_SURFACE.md +++ b/docs/zh_hans/TOOL_SURFACE.md @@ -329,3 +329,8 @@ cargo test --locked -p codewhale-tui --lib core::engine::tests::print_mode_tool_ 不依赖提供商的回执必须报告上面列出的十三个默认激活名称。另一份仓库范围的 工具计数可能包含延迟、动态、受特性开关控制以及仅为兼容而存在的注册; 它不是放进首回合模型目录的工具数量。 + + +## 源码导航回声 + +文件和搜索工具可在工具结果历史中附带同目录伙伴、符号、导入和修改归因,不加入会话固定前缀。调用者扫描最多选择 32 个可见、排序后的候选文件,每个正文最多接受 256 KiB,超额候选明确标为未扫描。关键文件正文最多接受 8 MiB。受保护路径或拒绝结果不会作为“读取不存在”继续探测;读取通过已验证的常规文件句柄完成。删除按旧侧、替换按两侧归因。完整目录名称枚举和内核 I/O 延迟不受正文上限约束。见英文 [ECHOLOCATION.md](../ECHOLOCATION.md) 及 [TOOL_SURFACE.md](../TOOL_SURFACE.md)。 diff --git a/scripts/check-blocking-calls-budget.json b/scripts/check-blocking-calls-budget.json index 70b1a50cec..882d3c45aa 100644 --- a/scripts/check-blocking-calls-budget.json +++ b/scripts/check-blocking-calls-budget.json @@ -354,6 +354,9 @@ "std_fs": 18, "thread_sleep": 1 }, + "crates/tui/src/tools/echolocation.rs": { + "path_canonicalize": 7 + }, "crates/tui/src/tools/git.rs": { "path_canonicalize": 1 }, diff --git a/scripts/check-runtime-contract-budget.py b/scripts/check-runtime-contract-budget.py index 45b04c2e32..26e54d2030 100755 --- a/scripts/check-runtime-contract-budget.py +++ b/scripts/check-runtime-contract-budget.py @@ -693,6 +693,10 @@ def main(argv: Sequence[str] | None = None) -> int: except RuntimeContractError as error: print(f"[runtime-contract-budget] ERROR: {error}", file=sys.stderr) if str(error).startswith("identity changed"): + print( + "[runtime-contract-budget] receipt=" + json.dumps(receipt, sort_keys=True), + file=sys.stderr, + ) print( "If the identity change is intended, land the new budget in this PR:\n" f" {grow_command}", diff --git a/scripts/ratchet-gate.sh b/scripts/ratchet-gate.sh index c6e92143b9..c3ea811537 100755 --- a/scripts/ratchet-gate.sh +++ b/scripts/ratchet-gate.sh @@ -24,8 +24,8 @@ # The base re-check needs a second checkout of an older commit. It uses a # throwaway `git worktree` under $RUNNER_TEMP (or mktemp), removed on exit. # That is a CI mechanism: locally, run the checker or scripts/preflight.sh. -# Cargo-backed checkers share this checkout's target directory so the base -# measurement is an incremental rebuild, not a cold one. +# Cargo-backed base checks use an isolated target directory so an older +# dependency image cannot replace artifacts needed by later PR checks. set -euo pipefail name="" @@ -94,7 +94,7 @@ git worktree add --quiet --detach "${base_tree}" "${base}" base_status=0 ( cd "${base_tree}" - export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-${root}/target}" + export CARGO_TARGET_DIR="${scratch}/target" "$@" ) || base_status=$? diff --git a/scripts/runtime-contract-budget.json b/scripts/runtime-contract-budget.json index 00ca1858b3..b623212c04 100644 --- a/scripts/runtime-contract-budget.json +++ b/scripts/runtime-contract-budget.json @@ -17,14 +17,14 @@ "identity_sha256": "5130806e324482a4b5ec28ae6fc408846b894844db9b7c38f3cfcf48e2ac63d8" }, "goal": { - "bytes": 9415, + "bytes": 9359, "delta_bytes": 81, - "identity_sha256": "7f004aa61453193c3ad328efb86bc70ad57d86ee5f28af5e4cb05d228a18d817" + "identity_sha256": "4822c23821fc096e9eef68ac40684dd7ff8c864e476e7a9efb62b252ab67bfa7" }, "handoff": { - "bytes": 9803, + "bytes": 9747, "delta_bytes": 388, - "identity_sha256": "350a47b56a210bf3cd027204d9db5e791e2e2c26af5ab37ca514415ba1527c65" + "identity_sha256": "3374c784c2bb0551184d258d6411cbfba4b06f64b7b0ffc074a4db0871c26328" }, "instructions": { "bytes": 7605, @@ -32,9 +32,9 @@ "identity_sha256": "85bd54a1ca30028f852c6a531c6b1aa04b6b971f1ed3498c66e942f055b276c1" }, "memory": { - "bytes": 9334, + "bytes": 9278, "delta_bytes": 963, - "identity_sha256": "d21379b0fa308b121649e5d756da4d8b6e31d5a0b10e2d87b5511197a7959bcd" + "identity_sha256": "58b92bceca2ee5f210712464ce2fb17a6f40d8181fbc8e731466d97529cc8487" }, "project": { "bytes": 7474, @@ -42,14 +42,14 @@ "identity_sha256": "918a55fd0c6da2a1334cdad93b2da61bcee660723ebc0443cd0a7b045979a0f8" }, "skill": { - "bytes": 8371, - "delta_bytes": 766, - "identity_sha256": "1cb100b5a923dead5fe2486539a37e9ab11ab03c82cb00bfa8eb3de2cb5c4a38" + "bytes": 8315, + "delta_bytes": 710, + "identity_sha256": "5186f569ffc8055f5fbaf3f9beead596072c7f5a376831f301940d633e981e8f" } }, "system_prompt_blocks": 6, - "total_bytes": 9803, - "total_tokens_est": 2451 + "total_bytes": 9747, + "total_tokens_est": 2437 }, "schema_version": 1, "skill_discovery": { diff --git a/web/data/agent-docs.generated.json b/web/data/agent-docs.generated.json index a907aa6d1d..783cf83b5c 100644 --- a/web/data/agent-docs.generated.json +++ b/web/data/agent-docs.generated.json @@ -80,8 +80,8 @@ "sources": [ { "path": "docs/MODES.md", - "sha256": "ea1e371421770a8582195f4c80c70fe9d067b3800ef143c7fea1fdcf444f0ecd", - "content": "# Modes and Permission Postures\n\n> 阅读简体中文版:[zh_hans/MODES.md](zh_hans/MODES.md)\n\nCodewhale has three related concepts:\n\n- **TUI mode**: what kind of visible interaction you're in (Plan/Work/Operate).\n- **Permission posture**: how aggressively the UI asks before executing tools.\n- **Workflow**: named steps that coordinate sub-agents, with dependencies and\n results, available in any TUI mode.\n\nModel selection is separate. `--model auto` and `/model auto` route each turn to\na concrete model and thinking level; they are not TUI modes and are not part of\nthe `Tab` cycle.\n\nWorkflow runs named steps through the same sub-agent runtime; Fleet manages\ntheir saved roles and model assignments. A workflow adds ordering, result\nhandoffs, gates and a progress view. The active mode and permission posture\nstill control what each step may execute.\n\nBefore assigning steps, query `agent(action=\"roster\")` for the saved Fleet\nmodels and roles. A plan child can select a listed model with `model`, or use\nthe saved `role`/`profile` assignment. An Exact Fleet fixes each member's route.\n\n## TUI Modes\n\nPress `Tab` to complete composer menus or cycle through the visible modes\nwhen the composer is empty: **Plan → Work → Operate → Plan**. `Tab` never sends\nor queues composer text; use `Enter` to send or queue it.\nPress `Shift+Tab` to cycle permission posture (Ask → Auto-Review → Full Access).\nEvery change of effective permission writes a `Permissions changed to …` line\nin the transcript.\nPress `Ctrl+T` to cycle reasoning effort.\nRun `/mode` to open the mode picker, or switch directly with `/mode work`,\n`/mode plan`, or `/mode operate`.\n\n- **Plan**: design-first prompting. The stable primitive names remain familiar, but the runtime centrally refuses file mutation and shell execution. Read-only inspection and policy-allowed research, including deferred Web search/fetch, remain available. When a Plan turn successfully finishes a nonempty response, including a prose-only plan, the TUI asks how to continue: Work (Ask), Work (Auto-Review), or keep planning. Work sends the exact approved response as a normal follow-up and preserves that turn's existing To-dos, or adds one pending plan item without replacing unrelated work.\n- **Work** (internally `agent`): ordinary multi-step execution. The first-turn toolbox includes `read`, `write`, `edit`, `file_search`, `bash`, `agent`, `workflow`, `todo_write`, and `load_skill`, plus `create_goal`, `get_goal`, `update_goal`, and `task_shell_wait` so goal and background-shell controls are available without discovery. The agent calls `create_goal` when a request is a durable objective; the host never infers a goal from wording, and the contract must not tell the model otherwise. Approval, sandbox, repository law, and managed policy decide what may execute.\n- **Operate**: Work at full strength, the \"ultra\" mode. Same tools and authority as Work; the difference is how they are used. Each substantive request becomes a goal (`create_goal`), the plan stays visible (`todo_write`), multi-part work runs through `workflow` and parallel `agent` workers, non-trivial changes get an independent reviewer before they are called done, long commands keep running in the background, and recurring or watch-type work is offered as an `automation` (created after approval). It keeps going until the goal is verified, blocked on you, or paused.\n\n`Act` and `/mode act` remain compatibility aliases for Work. Saved settings\nstill normalize to the internal value `agent`.\n\n### Tool availability by mode\n\n| Tool family | Plan | Work | Operate |\n|:---|:---:|:---:|:---:|\n| `read` and policy-allowed deferred research tools | yes | yes | yes |\n| `write` and `edit` | visible names; execution denied | approval- and policy-gated | same as Work |\n| `bash` | visible name; execution denied | approval- and policy-gated | same as Work; delegation is preferred when parallelism or isolation helps |\n| `agent` | yes, subject to child-depth authority | yes, subject to child-depth authority | yes, subject to child-depth authority |\n| Deferred native, MCP, and plugin tools | discoverable through `tool_search` when policy permits | same | same |\n| Paid or external-service tools | follows permission posture | follows permission posture | follows permission posture |\n| Access outside the workspace root | explicit trusted paths only | only through trusted paths or trust mode | same trusted-path/trust policy as Work; fleet profiles never widen it |\n\nOperate changes scheduling emphasis, not authority. It neither adds a\nmode-specific tool denial nor bypasses the active approval, sandbox, shell,\nask-rule, repository-law, or managed-policy boundary. Plan remains the\nmode-specific execution boundary for shell and write-capable tools; that\nauthority difference does not require a different primitive vocabulary.\n\n### Operate loop (one screen)\n\n```text\nUser message\n → small / chat / one-file? → parent does it (Work-equivalent tools)\n → multi-step work? → goal → named steps + dependencies + completion checks\n → Workflow phases → independent sub-agents in parallel\n → collect results → check evidence → hand off to the next phase\n → missing required result? → stop dependent work and repair the step\n → one independent task? → one direct sub-agent\n → parent integrates results and reports completed, blocked and next steps\n```\n\nLifecycle claims stay exact: dispatched ≠ settled ≠ verified.\n\n`allow_shell` controls whether `bash` can execute; it does not rename the tool\nor make mode the approval authority. Durable tasks and automation keep\nconservative omitted-field defaults and receive shell authority only when their\nsettings explicitly grant it. Stateful terminal/background controls are\nspecialized deferred tools rather than fields on the small foreground `bash`\nschema. Full Access changes the permission posture: the deterministic\nAuto-Review floor applies in the reviewing postures and is skipped under Full\nAccess, while repository-policy holds remain authoritative.\n\nAction-capable modes can discover the deferred `rlm` family through\n`tool_search`; its `open`, `eval`, `configure`, and `close` actions own persistent\nRLM sessions. The legacy split `rlm_*` spellings remain replay-only aliases.\nInside an RLM Python REPL, `sub_query_batch` fans out 1-16 cheap parallel child\ncalls pinned to `deepseek-v4-flash`.\n\nThe fast `deepseek-v4-flash` / thinking-off path is called Fin in the product\nlanguage. Fin is a seam for routing, summaries, cheap child calls, and\ncoordination work; it does not change approval behavior.\n\nThe orchestration controls remain available without taking over the starting\nscreen: `/auto` turns on Auto-Review so the agent just works, `/goal` keeps one\nobjective across turns, and `/workflow` prepares a repeatable ordered or\nfan-out workflow. They are directly callable and searchable through the full\ncommand palette, but are not pinned to the starter slash menu, idle welcome,\nfooter, or default Hotbar. A bare `/` instead opens the small task-oriented\nstarter set; use `/help` or the command palette for the complete inventory.\n\n`/goal ` sets a session objective with an optional token budget and\nkeeps active objectives visible as Work context. The agent may also create the\ngoal itself when a direct request describes a verifiable end state that will\ntake more than one turn (\"until the tests pass\", \"make X work end to end\"); it\nthen shows one receipt line and you can `/goal pause` or `/goal clear` it. Bare\n`/goal` shows progress (state, elapsed, continuations, and how to continue when\nno turn is running); with no goal and no conversation yet it prints usage.\n`/goal pause` stops goal continuation without changing the objective, `/goal\nresume` resumes and sends the objective back into the turn, `/goal complete`\nmarks it done, `/goal blocked` marks it blocked, and `/goal clear` removes it. Goal state does not change the active TUI mode,\npermission posture, or model route. This remains distinct from `--model auto`, which\nonly controls model and thinking selection.\n\nWorkflow builds on the same separation: a goal can ask the agent to keep\nworking, while Workflow supplies the repeatable workflow/progress surface for\nlarge fanout. In the UI, a Workflow run should be shown as an overlay on the\nmain screen, not as another mode beside Plan, Work, and Operate.\n\nApp-server clients can persist a thread-scoped goal with `thread/goal/set`, read\nit with `thread/goal/get`, and clear it with `thread/goal/clear`. That persisted\nrecord carries `active`, `paused`, `blocked`, `usage_limited`, `budget_limited`,\nor `complete` status plus token/time accounting fields for clients that need\nthread resume semantics.\n\n## Mode Persistence\n\nChoosing a mode interactively also sets the mode a fresh session starts in.\nTab/Shift+Tab cycling, the `Alt+A` / `Alt+P` / `Alt+Y` shortcuts, the hotbar's\nPlan/Work/Operate actions, and `/mode` all write `default_mode` to\n`~/.codewhale/settings.toml`, so switching to Operate survives a restart. The\nwrite happens off the event loop; if it fails, the TUI says so in a warning\ntoast rather than reverting silently on the next launch.\n\nMode, thinking level, and the model picker share one serialized writer, so the\nselection you made last is the one on disk — a burst of Tab presses cannot end\nup persisting whichever write happened to finish last — and a mode write never\nrolls back an unrelated key such as `default_model`.\n\nTwo paths deliberately do **not** rewrite the startup default: restoring a saved\nsession (which re-installs the mode that session was in) and a mode change\nrefused because a turn is in flight. The legacy `yolo` entry point installs Work\nplus Full Access, and `agent` is what it persists — `yolo` is a permission\nalias, never a startup mode.\n\nRe-selecting the mode you are already in is not a no-op. After a restored\nsession the live mode and `default_mode` routinely disagree, so choosing the\nlive mode again is how you make it durable; Codewhale confirms with a\n\"saved as startup default\" receipt rather than reporting \"already in that mode\".\n\nWhile a turn is running, every change to the live route is refused — mode,\nmodel, thinking level, and provider — no matter which surface you use. That\nnow includes the slash surfaces (`/mode`, `/model`, `/provider`,\n`/config `, `/config preset`), which are reachable mid-turn. Press\nEsc to interrupt first. The restart-only `default_mode` key is exempt, because\nit does not touch the running turn.\n\nCodewhale writes `settings.toml` under a lock that spans processes, and replaces\nthe file atomically, so a second Codewhale instance on the same home directory\ncannot lose your selection or read a half-written file. At exit, queued writes\nare flushed before the terminal is restored; anything that failed is printed on\nthe way out instead of disappearing with the alternate screen.\n\n## Compatibility Notes\n\n- Older settings files with `default_mode = \"normal\"` still load as `agent`; saving rewrites the normalized value.\n\n## Escape Key Behavior\n\n`Esc` is a cancel stack, not a mode switch.\n\n- Close slash menus or transient UI first.\n- Cancel the active request if a turn is running.\n- Discard a queued draft if the composer is empty.\n- Clear the current input if text is present.\n- Otherwise it is a no-op.\n\n## Permission Posture\n\nPermission posture controls tool approval and whether a turn may pause for a\nmissing user decision. It is one layer of the full\n[authorization order](AUTHORIZATION_ORDER.md), not a bypass for tool admission,\nrepository law, or sandbox enforcement. Cycle it with `Shift+Tab`, or edit it\nat runtime:\n\n```text\n/config\n# edit the approval_mode row to: suggest | auto | never\n```\n\nLegacy note: `/set approval_mode ...` was retired in favor of `/config`.\n\n- `suggest` (**Ask**, default): tool approvals may interrupt, and Codewhale asks\n when an unresolved user choice materially changes authority, cost, scope, or\n outcome.\n- `auto` (**Auto-Review**): reviews tool calls automatically. Deliberate user\n questions remain available in interactive sessions through `request_user_input`;\n a question parks the turn until answered, canceled, or its configured timeout\n expires. Headless `exec` withholds that tool because it has no responder.\n Tool safety holds remain separate from user questions. Two layers decide approvals. The\n **deterministic floor** (configured block rules plus the built-in safety\n floor) allows proven-safe calls and hard-blocks publish-like actions and\n destructive background/headless work; it is never model-reviewed. Fallback\n holds — calls the deterministic engine could not prove safe — escalate to a\n one-shot **model guardian** (v0.9.8) that returns risk, allow/deny, and a\n rationale. The guardian sees the exact held call and deterministic\n observations in separate JSON fields; conversation history, skill\n instructions, attachments, and expanded model context are excluded. It does\n not infer user intent or compute a generic user-intent score.\n High or critical risk cannot auto-run even if the model says allow. It has no\n tools, remembers no rules, and denies rather than truncates an oversized\n exact call. Exactly one reviewer request is made; incomplete or malformed\n output, timeout, cancellation, or provider failure fails closed. Repeated\n denials stop the turn: after three guardian denials in a row, or ten among\n the turn's last fifty reviews, the turn ends as interrupted with a status\n line, and calls still in that batch are refused without another review.\n Only explicit denials count (not timeouts or failures), an allowed review\n resets the run of three, the count starts fresh with each turn, and a child\n agent counts its own turns. Headless\n adapters use the deterministic-only tier. Repo-law\n holds that explicitly require a person block in Auto-Review rather than\n opening a hidden approval modal.\n\nThe LLM reviewer is closest to OpenAI Codex's experimental Auto-Review at\ncommit [`6fc6b9d6d2580d62622fc9884b5f5707f6505a5e`](https://github.com/openai/codex/tree/6fc6b9d6d2580d62622fc9884b5f5707f6505a5e).\nCodex's [guardian entry point](https://github.com/openai/codex/blob/6fc6b9d6d2580d62622fc9884b5f5707f6505a5e/codex-rs/core/src/guardian/mod.rs)\nreconstructs conversation context and runs a dedicated review session.\nCodewhale deliberately adopts only the exact-action structured decision,\n90-second deadline, fail-closed result, and per-turn denial circuit breaker. It\ndoes not copy Codex's transcript reconstruction, user-authorization score,\nreviewer tools, retries, persistent review session, or denial ledger.\n\nKimi Code at commit\n[`1414d4602898f406e540b23342cb18db23ff9efc`](https://github.com/MoonshotAI/kimi-code/tree/1414d4602898f406e540b23342cb18db23ff9efc)\nalso has no LLM reviewer. Its ordered\n[permission policy](https://github.com/MoonshotAI/kimi-code/blob/1414d4602898f406e540b23342cb18db23ff9efc/packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicyService.ts)\napplies explicit deny rules and then its\n[Auto policy](https://github.com/MoonshotAI/kimi-code/blob/1414d4602898f406e540b23342cb18db23ff9efc/packages/agent-core-v2/src/agent/permissionPolicy/policies/auto-mode-approve.ts)\nreturns `approve` directly. Codewhale uses the deterministic floor and guardian\ndescribed above, while keeping deliberate user questions available.\n\nThe sandbox and escalation baseline is grounded in DeepSeek Harness\n`0.1.0-rc.5` at\ncommit [`47f943859bef60e4160492346772ded9b24f765a`](https://github.com/deepseek-ai/deepseek-harness/tree/47f943859bef60e4160492346772ded9b24f765a):\nits [sandbox contract](https://github.com/deepseek-ai/deepseek-harness/blob/47f943859bef60e4160492346772ded9b24f765a/docs/subsystems/sandbox.md)\ndefines per-call `read-only`, `workspace-write`, and `danger-full-access`\nboundaries and forbids silent unconfined fallback; its\n[approval contract](https://github.com/deepseek-ai/deepseek-harness/blob/47f943859bef60e4160492346772ded9b24f765a/docs/subsystems/approval.md)\ngrants only `allowed-once` and fails closed on rejection, cancellation, or an\nunavailable answerer; and its\n[sandbox result contract](https://github.com/deepseek-ai/deepseek-harness/blob/47f943859bef60e4160492346772ded9b24f765a/packages/shell/bash-sandbox/README.md)\ntells the model to retry a denied command exactly once with the narrowest wider\nmode plus a justification. DeepSeek Harness does not add an LLM reviewer to\nthat path. Codewhale's autonomous posture adds only the single stateless\nguardian request described above; deterministic hard blocks remain\nnon-bypassable in the reviewing postures.\n- `bypass` (**Full Access**): ordinary tool calls do not show approval prompts,\n while deliberate user questions remain available. The deterministic\n Auto-Review floor is skipped — publish-like commands, destructive\n background/headless work, and the Windows npm-launcher platform hold do not\n strand a call the person already granted. Non-bypassable registered holds\n auto-approve instead of opening a contradictory modal. Repository-law and\n managed-policy holds fail closed as hard blocks instead of contradicting\n Full Access with an approval modal.\n- `never`: blocks any tool that is not considered safe/read-only; deliberate\n user questions remain available.\n\nThe effective posture and its question discipline are projected into every\nturn from the same runtime authority that gates tools. A mode/posture change is\ntherefore visible to the next turn. Untrusted runtime-generated input is\nnarrowed before metadata is built and cannot invent approval authority. An\nexplicit Full Access sub-agent handoff preserves the parent's standing posture\nso ordinary child work does not begin prompting again.\n\n### Children (sub-agents and fleet workers)\n\nChildren inherit the session posture faithfully rather than a bare\nauto-approve bit:\n\n- **Auto-Review**: a worker's held call goes through the same deterministic\n policy (proven-safe calls run; publish-like and destructive background work\n is hard-blocked) and, for holds it cannot prove safe, the same one-shot\n model guardian using the child's own session client. No prompt is ever\n opened for a child; an unavailable guardian denies, fail closed.\n- **Ask**: a call the role may delegate runs. A held call is raised as an\n approval prompt in the parent's UI (`agent::approval:`) when the\n host is an interactive TUI; the worker waits visibly (`waiting for user`)\n and the person's answer is routed back to it, whether the parent turn is\n idle or itself awaiting an approval. Hosts that cannot prompt deny with the\n reason.\n- **Full Access**: calls run, including destructive detached work — the\n Auto-Review floor is skipped exactly as it is for the parent turn. The\n execution envelope still applies and never widens.\n\nRole posture and the execution envelope are checked before and after this\ngate and never widen. Every decision a person did not make at a prompt is\nwritten to the audit log and to the child's transcript as a one-line note\n(`Auto-Review allowed 'bash' (low risk, model guardian): …`), visible when\nthe worker is focused.\n\n## Small-Screen Status Behavior\n\nWhen terminal height is constrained, the status area compacts first so header/chat/composer/footer remain visible:\n\n- Loading and queued status rows are budgeted by available height.\n- Queued previews collapse to compact summaries when full previews do not fit.\n- `/queue` workflows remain available; compact status only affects rendering density.\n\n## Workspace Boundary and Trust Mode\n\nBy default, file tools are restricted to the `--workspace` directory. Enable trust mode to allow file access outside the workspace:\n\n```text\n/trust on\n```\n\nBare `/trust` (like `/trust status`) only *reports* the current setting — it\ndoes not enable anything. Use `/trust off` to restrict access again.\n\nFull Access enables trust mode automatically.\n\n## MCP Behavior\n\nMCP tools are exposed as `mcp__` and use the same approval flow as\nbuilt-in tools. Read-only MCP helpers may auto-run in Ask and Auto-Review when\npolicy permits; MCP tools with possible side effects require approval. Full\nAccess does not bypass hard policy holds.\n\nA tool's own MCP annotations count only as far as their source is trusted. A\ntool from a reviewed, enabled plugin that declares `readOnlyHint: true` runs\nlike the built-in read helpers, with no prompt; the same claim from any other\nserver is ignored. A tool that declares `destructiveHint: true` never gets\nthat relaxation, even from a reviewed plugin, and its approval card says the\nserver marked it destructive. Full Access still runs it without a prompt, like\nany other tool that would ask.\n\nSee `MCP.md`.\n\n## Related CLI Flags\n\nRun `codewhale --help` for the canonical list. Common flags:\n\n- `-p, --prompt `: one-shot prompt mode (prints and exits)\n- `codewhale exec --auto --output-format stream-json `: run the tool-backed non-interactive agent and emit one JSON object per line for harnesses and backend wrappers. `--auto` enables Full Access by default (no Codewhale shell sandbox, trust mode on); add `--sandbox workspace-write` to keep the shell sandbox and file-tool workspace restrictions, with trust mode off. Any `--sandbox` other than `danger-full-access` (or the equivalent `sandbox_mode` in config) keeps trust mode off; `external-sandbox` relies on your own isolation. Exec runs that offer tools are saved in every output format. Exit codes: `0` on success, `1` for genuine task/agent failures, `75` (`EX_TEMPFAIL`) when the turn ended on a retryable infrastructure failure (provider/transport `network`/`timeout` after all in-session retries) so harnesses can tell a retryable infra exit apart from a task failure; the terminal stream `metadata` event's `error_category` carries the same classification\n- Put exec flags before the prompt. A prompt starting with `-` needs `--` first, for example `codewhale exec -- \"- explain this option\"`. A lone `-` is literal text. Known exec long flags (including `--flag=value`) after the prompt are rejected unless they follow an explicit `--`, which marks them as literal prompt text.\n- `codewhale exec --prompt-file ` / `cat prompt.txt | codewhale exec --prompt-file -`: read the prompt from a file or stdin instead of argv, for prompts past the OS per-argument limit (~128 KiB on Linux). Conflicts with a positional prompt, and a positional `-` is literal prompt text; `--prompt-file -` is refused with `--parent-death-watch`, which owns stdin\n- `codewhale exec --resume ` / `--session-id `: continue a saved session non-interactively\n- `codewhale exec --continue `: continue the most recent saved session for this workspace non-interactively\n- `codewhale fork ` / `codewhale fork --last`: copy a saved session into a new sibling session; forked sessions retain additive parent-session metadata and show that lineage in session listings\n- `--model `: when using the `codewhale` facade, forward a DeepSeek model override to the TUI\n- `--workspace `: workspace root for file tools\n- `-r, --resume `: resume a saved session\n- `-c, --continue`: resume the most recent session in this workspace\n- `--max-subagents `: clamp to `1..=128`\n- `--mouse-capture` / `--no-mouse-capture`: opt in or out of internal mouse scrolling, transcript selection, right-click context actions, and transcript scrollbar dragging. Mouse capture is enabled by default on non-Windows terminals and on Windows Terminal/ConEmu/Cmder so drag selection copies only transcript text, removes visual wrap-column line breaks from paragraphs, and stays scoped to the transcript pane; hold Shift while dragging or use `--no-mouse-capture` for raw terminal selection. It defaults off on legacy Windows console (CMD without `WT_SESSION` / `ConEmuPID`) and inside JetBrains JediTerm — PyCharm/IDEA/CLion/etc. — where the terminal advertises mouse support but forwards SGR mouse events as raw text (#878, #898). Use `--mouse-capture` to opt in anywhere it's defaulted off. Raw terminal selection may cross the right workbar and include visual wraps because the terminal, not the TUI, owns the selection.\n- `--profile `: select config profile\n- `--config `: config file path\n- `-v, --verbose`: verbose logging\n\n## Branching and Rollback\n\nCodewhale has three related but intentionally separate recovery paths:\n\n- `codewhale fork ` creates a new saved session from an existing saved\n conversation and records the source session id. This is the safe way to\n explore a different answer path without overwriting the original session.\n- Esc-Esc backtrack rewinds the live transcript to a previous user prompt and\n restores that prompt into the composer for editing. It does not change\n files; `/restore` rolls the folder back to a chosen restore point and needs\n `/trust on` or Full Access.\n- `/restore` and the `revert_turn` tool restore workspace files from side-git\n snapshots. `/restore list [N]` lists more snapshot options before choosing a\n rollback point. They do not rewrite conversation history.\n\nA Pi-style in-file tree browser is a larger UI/data-model project. v0.8.40\nships the bounded fork/backtrack primitives and explicit lineage metadata.\n" + "sha256": "02c1b42ecbf4030bdeca7c6dad252c5a34fa21d26349e034766d43379222d11c", + "content": "# Modes and Permission Postures\n\n> 阅读简体中文版:[zh_hans/MODES.md](zh_hans/MODES.md)\n\nCodewhale has three related concepts:\n\n- **TUI mode**: what kind of visible interaction you're in (Plan/Work/Operate).\n- **Permission posture**: how aggressively the UI asks before executing tools.\n- **Workflow**: named steps that coordinate sub-agents, with dependencies and\n results, available in any TUI mode.\n\nModel selection is separate. `--model auto` and `/model auto` route each turn to\na concrete model and thinking level; they are not TUI modes and are not part of\nthe `Tab` cycle.\n\nWorkflow runs named steps through the same sub-agent runtime; Fleet manages\ntheir saved roles and model assignments. A workflow adds ordering, result\nhandoffs, gates and a progress view. The active mode and permission posture\nstill control what each step may execute.\n\nBefore assigning steps, query `agent(action=\"roster\")` for the saved Fleet\nmodels and roles. A plan child can select a listed model with `model`, or use\nthe saved `role`/`profile` assignment. An Exact Fleet fixes each member's route.\n\n## TUI Modes\n\nPress `Tab` to complete composer menus or cycle through the visible modes\nwhen the composer is empty: **Plan → Work → Operate → Plan**. `Tab` never sends\nor queues composer text; use `Enter` to send or queue it.\nPress `Shift+Tab` to cycle permission posture (Ask → Auto-Review → Full Access).\nEvery change of effective permission writes a `Permissions changed to …` line\nin the transcript.\nPress `Ctrl+T` to cycle reasoning effort.\nRun `/mode` to open the mode picker, or switch directly with `/mode work`,\n`/mode plan`, or `/mode operate`.\n\n- **Plan**: design-first prompting. The stable primitive names remain familiar, but the runtime centrally refuses file mutation and shell execution. Read-only inspection and policy-allowed research, including deferred Web search/fetch, remain available. When a Plan turn successfully finishes a nonempty response, including a prose-only plan, the TUI asks how to continue: Work (Ask), Work (Auto-Review), or keep planning. Work sends the exact approved response as a normal follow-up and preserves that turn's existing To-dos, or adds one pending plan item without replacing unrelated work.\n- **Work** (internally `agent`): ordinary multi-step execution. The first-turn toolbox includes `read`, `write`, `edit`, `file_search`, `bash`, `todo_write`, `load_skill`, and `task_shell_wait`, plus `create_goal` and the synthetic `tool_search`; `agent`, `workflow`, `get_goal`, and `update_goal` load through discovery (`get_goal`/`update_goal` ride eager while a goal is active). The agent calls `create_goal` when a request is a durable objective; the host never infers a goal from wording, and the contract must not tell the model otherwise. Approval, sandbox, repository law, and managed policy decide what may execute.\n- **Operate**: Work at full strength, the \"ultra\" mode. Same tools and authority as Work; the difference is how they are used. Each substantive request becomes a goal (`create_goal`), the plan stays visible (`todo_write`), multi-part work runs through `workflow` and parallel `agent` workers, non-trivial changes get an independent reviewer before they are called done, long commands keep running in the background, and recurring or watch-type work is offered as an `automation` (created after approval). It keeps going until the goal is verified, blocked on you, or paused.\n\n`Act` and `/mode act` remain compatibility aliases for Work. Saved settings\nstill normalize to the internal value `agent`.\n\n### Tool availability by mode\n\n| Tool family | Plan | Work | Operate |\n|:---|:---:|:---:|:---:|\n| `read` and policy-allowed deferred research tools | yes | yes | yes |\n| `write` and `edit` | visible names; execution denied | approval- and policy-gated | same as Work |\n| `bash` | visible name; execution denied | approval- and policy-gated | same as Work; delegation is preferred when parallelism or isolation helps |\n| `agent` | yes, subject to child-depth authority | yes, subject to child-depth authority | yes, subject to child-depth authority |\n| Deferred native, MCP, and plugin tools | discoverable through `tool_search` when policy permits | same | same |\n| Paid or external-service tools | follows permission posture | follows permission posture | follows permission posture |\n| Access outside the workspace root | explicit trusted paths only | only through trusted paths or trust mode | same trusted-path/trust policy as Work; fleet profiles never widen it |\n\nOperate changes scheduling emphasis, not authority. It neither adds a\nmode-specific tool denial nor bypasses the active approval, sandbox, shell,\nask-rule, repository-law, or managed-policy boundary. Plan remains the\nmode-specific execution boundary for shell and write-capable tools; that\nauthority difference does not require a different primitive vocabulary.\n\n### Operate loop (one screen)\n\n```text\nUser message\n → small / chat / one-file? → parent does it (Work-equivalent tools)\n → multi-step work? → goal → named steps + dependencies + completion checks\n → Workflow phases → independent sub-agents in parallel\n → collect results → check evidence → hand off to the next phase\n → missing required result? → stop dependent work and repair the step\n → one independent task? → one direct sub-agent\n → parent integrates results and reports completed, blocked and next steps\n```\n\nLifecycle claims stay exact: dispatched ≠ settled ≠ verified.\n\n`allow_shell` controls whether `bash` can execute; it does not rename the tool\nor make mode the approval authority. Durable tasks and automation keep\nconservative omitted-field defaults and receive shell authority only when their\nsettings explicitly grant it. Stateful terminal/background controls are\nspecialized deferred tools rather than fields on the small foreground `bash`\nschema. Full Access changes the permission posture: the deterministic\nAuto-Review floor applies in the reviewing postures and is skipped under Full\nAccess, while repository-policy holds remain authoritative.\n\nAction-capable modes can discover the deferred `rlm` family through\n`tool_search`; its `open`, `eval`, `configure`, and `close` actions own persistent\nRLM sessions. The legacy split `rlm_*` spellings remain replay-only aliases.\nInside an RLM Python REPL, `sub_query_batch` fans out 1-16 cheap parallel child\ncalls pinned to `deepseek-v4-flash`.\n\nThe fast `deepseek-v4-flash` / thinking-off path is called Fin in the product\nlanguage. Fin is a seam for routing, summaries, cheap child calls, and\ncoordination work; it does not change approval behavior.\n\nThe orchestration controls remain available without taking over the starting\nscreen: `/auto` turns on Auto-Review so the agent just works, `/goal` keeps one\nobjective across turns, and `/workflow` prepares a repeatable ordered or\nfan-out workflow. They are directly callable and searchable through the full\ncommand palette, but are not pinned to the starter slash menu, idle welcome,\nfooter, or default Hotbar. A bare `/` instead opens the small task-oriented\nstarter set; use `/help` or the command palette for the complete inventory.\n\n`/goal ` sets a session objective with an optional token budget and\nkeeps active objectives visible as Work context. The agent may also create the\ngoal itself when a direct request describes a verifiable end state that will\ntake more than one turn (\"until the tests pass\", \"make X work end to end\"); it\nthen shows one receipt line and you can `/goal pause` or `/goal clear` it. Bare\n`/goal` shows progress (state, elapsed, continuations, and how to continue when\nno turn is running); with no goal and no conversation yet it prints usage.\n`/goal pause` stops goal continuation without changing the objective, `/goal\nresume` resumes and sends the objective back into the turn, `/goal complete`\nmarks it done, `/goal blocked` marks it blocked, and `/goal clear` removes it. Goal state does not change the active TUI mode,\npermission posture, or model route. This remains distinct from `--model auto`, which\nonly controls model and thinking selection.\n\nWorkflow builds on the same separation: a goal can ask the agent to keep\nworking, while Workflow supplies the repeatable workflow/progress surface for\nlarge fanout. In the UI, a Workflow run should be shown as an overlay on the\nmain screen, not as another mode beside Plan, Work, and Operate.\n\nApp-server clients can persist a thread-scoped goal with `thread/goal/set`, read\nit with `thread/goal/get`, and clear it with `thread/goal/clear`. That persisted\nrecord carries `active`, `paused`, `blocked`, `usage_limited`, `budget_limited`,\nor `complete` status plus token/time accounting fields for clients that need\nthread resume semantics.\n\n## Mode Persistence\n\nChoosing a mode interactively also sets the mode a fresh session starts in.\nTab/Shift+Tab cycling, the `Alt+A` / `Alt+P` / `Alt+Y` shortcuts, the hotbar's\nPlan/Work/Operate actions, and `/mode` all write `default_mode` to\n`~/.codewhale/settings.toml`, so switching to Operate survives a restart. The\nwrite happens off the event loop; if it fails, the TUI says so in a warning\ntoast rather than reverting silently on the next launch.\n\nMode, thinking level, and the model picker share one serialized writer, so the\nselection you made last is the one on disk — a burst of Tab presses cannot end\nup persisting whichever write happened to finish last — and a mode write never\nrolls back an unrelated key such as `default_model`.\n\nTwo paths deliberately do **not** rewrite the startup default: restoring a saved\nsession (which re-installs the mode that session was in) and a mode change\nrefused because a turn is in flight. The legacy `yolo` entry point installs Work\nplus Full Access, and `agent` is what it persists — `yolo` is a permission\nalias, never a startup mode.\n\nRe-selecting the mode you are already in is not a no-op. After a restored\nsession the live mode and `default_mode` routinely disagree, so choosing the\nlive mode again is how you make it durable; Codewhale confirms with a\n\"saved as startup default\" receipt rather than reporting \"already in that mode\".\n\nWhile a turn is running, every change to the live route is refused — mode,\nmodel, thinking level, and provider — no matter which surface you use. That\nnow includes the slash surfaces (`/mode`, `/model`, `/provider`,\n`/config `, `/config preset`), which are reachable mid-turn. Press\nEsc to interrupt first. The restart-only `default_mode` key is exempt, because\nit does not touch the running turn.\n\nCodewhale writes `settings.toml` under a lock that spans processes, and replaces\nthe file atomically, so a second Codewhale instance on the same home directory\ncannot lose your selection or read a half-written file. At exit, queued writes\nare flushed before the terminal is restored; anything that failed is printed on\nthe way out instead of disappearing with the alternate screen.\n\n## Compatibility Notes\n\n- Older settings files with `default_mode = \"normal\"` still load as `agent`; saving rewrites the normalized value.\n\n## Escape Key Behavior\n\n`Esc` is a cancel stack, not a mode switch.\n\n- Close slash menus or transient UI first.\n- Cancel the active request if a turn is running.\n- Discard a queued draft if the composer is empty.\n- Clear the current input if text is present.\n- Otherwise it is a no-op.\n\n## Permission Posture\n\nPermission posture controls tool approval and whether a turn may pause for a\nmissing user decision. It is one layer of the full\n[authorization order](AUTHORIZATION_ORDER.md), not a bypass for tool admission,\nrepository law, or sandbox enforcement. Cycle it with `Shift+Tab`, or edit it\nat runtime:\n\n```text\n/config\n# edit the approval_mode row to: suggest | auto | never\n```\n\nLegacy note: `/set approval_mode ...` was retired in favor of `/config`.\n\n- `suggest` (**Ask**, default): tool approvals may interrupt, and Codewhale asks\n when an unresolved user choice materially changes authority, cost, scope, or\n outcome.\n- `auto` (**Auto-Review**): reviews tool calls automatically. Deliberate user\n questions remain available in interactive sessions through `request_user_input`;\n a question parks the turn until answered, canceled, or its configured timeout\n expires. Headless `exec` withholds that tool because it has no responder.\n Tool safety holds remain separate from user questions. Two layers decide approvals. The\n **deterministic floor** (configured block rules plus the built-in safety\n floor) allows proven-safe calls and hard-blocks publish-like actions and\n destructive background/headless work; it is never model-reviewed. Fallback\n holds — calls the deterministic engine could not prove safe — escalate to a\n one-shot **model guardian** (v0.9.8) that returns risk, allow/deny, and a\n rationale. The guardian sees the exact held call and deterministic\n observations in separate JSON fields; conversation history, skill\n instructions, attachments, and expanded model context are excluded. It does\n not infer user intent or compute a generic user-intent score.\n High or critical risk cannot auto-run even if the model says allow. It has no\n tools, remembers no rules, and denies rather than truncates an oversized\n exact call. Exactly one reviewer request is made; incomplete or malformed\n output, timeout, cancellation, or provider failure fails closed. Repeated\n denials stop the turn: after three guardian denials in a row, or ten among\n the turn's last fifty reviews, the turn ends as interrupted with a status\n line, and calls still in that batch are refused without another review.\n Only explicit denials count (not timeouts or failures), an allowed review\n resets the run of three, the count starts fresh with each turn, and a child\n agent counts its own turns. Headless\n adapters use the deterministic-only tier. Repo-law\n holds that explicitly require a person block in Auto-Review rather than\n opening a hidden approval modal.\n\nThe LLM reviewer is closest to OpenAI Codex's experimental Auto-Review at\ncommit [`6fc6b9d6d2580d62622fc9884b5f5707f6505a5e`](https://github.com/openai/codex/tree/6fc6b9d6d2580d62622fc9884b5f5707f6505a5e).\nCodex's [guardian entry point](https://github.com/openai/codex/blob/6fc6b9d6d2580d62622fc9884b5f5707f6505a5e/codex-rs/core/src/guardian/mod.rs)\nreconstructs conversation context and runs a dedicated review session.\nCodewhale deliberately adopts only the exact-action structured decision,\n90-second deadline, fail-closed result, and per-turn denial circuit breaker. It\ndoes not copy Codex's transcript reconstruction, user-authorization score,\nreviewer tools, retries, persistent review session, or denial ledger.\n\nKimi Code at commit\n[`1414d4602898f406e540b23342cb18db23ff9efc`](https://github.com/MoonshotAI/kimi-code/tree/1414d4602898f406e540b23342cb18db23ff9efc)\nalso has no LLM reviewer. Its ordered\n[permission policy](https://github.com/MoonshotAI/kimi-code/blob/1414d4602898f406e540b23342cb18db23ff9efc/packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicyService.ts)\napplies explicit deny rules and then its\n[Auto policy](https://github.com/MoonshotAI/kimi-code/blob/1414d4602898f406e540b23342cb18db23ff9efc/packages/agent-core-v2/src/agent/permissionPolicy/policies/auto-mode-approve.ts)\nreturns `approve` directly. Codewhale uses the deterministic floor and guardian\ndescribed above, while keeping deliberate user questions available.\n\nThe sandbox and escalation baseline is grounded in DeepSeek Harness\n`0.1.0-rc.5` at\ncommit [`47f943859bef60e4160492346772ded9b24f765a`](https://github.com/deepseek-ai/deepseek-harness/tree/47f943859bef60e4160492346772ded9b24f765a):\nits [sandbox contract](https://github.com/deepseek-ai/deepseek-harness/blob/47f943859bef60e4160492346772ded9b24f765a/docs/subsystems/sandbox.md)\ndefines per-call `read-only`, `workspace-write`, and `danger-full-access`\nboundaries and forbids silent unconfined fallback; its\n[approval contract](https://github.com/deepseek-ai/deepseek-harness/blob/47f943859bef60e4160492346772ded9b24f765a/docs/subsystems/approval.md)\ngrants only `allowed-once` and fails closed on rejection, cancellation, or an\nunavailable answerer; and its\n[sandbox result contract](https://github.com/deepseek-ai/deepseek-harness/blob/47f943859bef60e4160492346772ded9b24f765a/packages/shell/bash-sandbox/README.md)\ntells the model to retry a denied command exactly once with the narrowest wider\nmode plus a justification. DeepSeek Harness does not add an LLM reviewer to\nthat path. Codewhale's autonomous posture adds only the single stateless\nguardian request described above; deterministic hard blocks remain\nnon-bypassable in the reviewing postures.\n- `bypass` (**Full Access**): ordinary tool calls do not show approval prompts,\n while deliberate user questions remain available. The deterministic\n Auto-Review floor is skipped — publish-like commands, destructive\n background/headless work, and the Windows npm-launcher platform hold do not\n strand a call the person already granted. Non-bypassable registered holds\n auto-approve instead of opening a contradictory modal. Repository-law and\n managed-policy holds fail closed as hard blocks instead of contradicting\n Full Access with an approval modal.\n- `never`: blocks any tool that is not considered safe/read-only; deliberate\n user questions remain available.\n\nThe effective posture and its question discipline are projected into every\nturn from the same runtime authority that gates tools. A mode/posture change is\ntherefore visible to the next turn. Untrusted runtime-generated input is\nnarrowed before metadata is built and cannot invent approval authority. An\nexplicit Full Access sub-agent handoff preserves the parent's standing posture\nso ordinary child work does not begin prompting again.\n\n### Children (sub-agents and fleet workers)\n\nChildren inherit the session posture faithfully rather than a bare\nauto-approve bit:\n\n- **Auto-Review**: a worker's held call goes through the same deterministic\n policy (proven-safe calls run; publish-like and destructive background work\n is hard-blocked) and, for holds it cannot prove safe, the same one-shot\n model guardian using the child's own session client. No prompt is ever\n opened for a child; an unavailable guardian denies, fail closed.\n- **Ask**: a call the role may delegate runs. A held call is raised as an\n approval prompt in the parent's UI (`agent::approval:`) when the\n host is an interactive TUI; the worker waits visibly (`waiting for user`)\n and the person's answer is routed back to it, whether the parent turn is\n idle or itself awaiting an approval. Hosts that cannot prompt deny with the\n reason.\n- **Full Access**: calls run, including destructive detached work — the\n Auto-Review floor is skipped exactly as it is for the parent turn. The\n execution envelope still applies and never widens.\n\nRole posture and the execution envelope are checked before and after this\ngate and never widen. Every decision a person did not make at a prompt is\nwritten to the audit log and to the child's transcript as a one-line note\n(`Auto-Review allowed 'bash' (low risk, model guardian): …`), visible when\nthe worker is focused.\n\n## Small-Screen Status Behavior\n\nWhen terminal height is constrained, the status area compacts first so header/chat/composer/footer remain visible:\n\n- Loading and queued status rows are budgeted by available height.\n- Queued previews collapse to compact summaries when full previews do not fit.\n- `/queue` workflows remain available; compact status only affects rendering density.\n\n## Workspace Boundary and Trust Mode\n\nBy default, file tools are restricted to the `--workspace` directory. Enable trust mode to allow file access outside the workspace:\n\n```text\n/trust on\n```\n\nBare `/trust` (like `/trust status`) only *reports* the current setting — it\ndoes not enable anything. Use `/trust off` to restrict access again.\n\nFull Access enables trust mode automatically.\n\n## MCP Behavior\n\nMCP tools are exposed as `mcp__` and use the same approval flow as\nbuilt-in tools. Read-only MCP helpers may auto-run in Ask and Auto-Review when\npolicy permits; MCP tools with possible side effects require approval. Full\nAccess does not bypass hard policy holds.\n\nA tool's own MCP annotations count only as far as their source is trusted. A\ntool from a reviewed, enabled plugin that declares `readOnlyHint: true` runs\nlike the built-in read helpers, with no prompt; the same claim from any other\nserver is ignored. A tool that declares `destructiveHint: true` never gets\nthat relaxation, even from a reviewed plugin, and its approval card says the\nserver marked it destructive. Full Access still runs it without a prompt, like\nany other tool that would ask.\n\nSee `MCP.md`.\n\n## Related CLI Flags\n\nRun `codewhale --help` for the canonical list. Common flags:\n\n- `-p, --prompt `: one-shot prompt mode (prints and exits)\n- `codewhale exec --auto --output-format stream-json `: run the tool-backed non-interactive agent and emit one JSON object per line for harnesses and backend wrappers. `--auto` enables Full Access by default (no Codewhale shell sandbox, trust mode on); add `--sandbox workspace-write` to keep the shell sandbox and file-tool workspace restrictions, with trust mode off. Any `--sandbox` other than `danger-full-access` (or the equivalent `sandbox_mode` in config) keeps trust mode off; `external-sandbox` relies on your own isolation. Exec runs that offer tools are saved in every output format. Exit codes: `0` on success, `1` for genuine task/agent failures, `75` (`EX_TEMPFAIL`) when the turn ended on a retryable infrastructure failure (provider/transport `network`/`timeout` after all in-session retries) so harnesses can tell a retryable infra exit apart from a task failure; the terminal stream `metadata` event's `error_category` carries the same classification\n- Put exec flags before the prompt. A prompt starting with `-` needs `--` first, for example `codewhale exec -- \"- explain this option\"`. A lone `-` is literal text. Known exec long flags (including `--flag=value`) after the prompt are rejected unless they follow an explicit `--`, which marks them as literal prompt text.\n- `codewhale exec --prompt-file ` / `cat prompt.txt | codewhale exec --prompt-file -`: read the prompt from a file or stdin instead of argv, for prompts past the OS per-argument limit (~128 KiB on Linux). Conflicts with a positional prompt, and a positional `-` is literal prompt text; `--prompt-file -` is refused with `--parent-death-watch`, which owns stdin\n- `codewhale exec --resume ` / `--session-id `: continue a saved session non-interactively\n- `codewhale exec --continue `: continue the most recent saved session for this workspace non-interactively\n- `codewhale fork ` / `codewhale fork --last`: copy a saved session into a new sibling session; forked sessions retain additive parent-session metadata and show that lineage in session listings\n- `--model `: when using the `codewhale` facade, forward a DeepSeek model override to the TUI\n- `--workspace `: workspace root for file tools\n- `-r, --resume `: resume a saved session\n- `-c, --continue`: resume the most recent session in this workspace\n- `--max-subagents `: clamp to `1..=128`\n- `--mouse-capture` / `--no-mouse-capture`: opt in or out of internal mouse scrolling, transcript selection, right-click context actions, and transcript scrollbar dragging. Mouse capture is enabled by default on non-Windows terminals and on Windows Terminal/ConEmu/Cmder so drag selection copies only transcript text, removes visual wrap-column line breaks from paragraphs, and stays scoped to the transcript pane; hold Shift while dragging or use `--no-mouse-capture` for raw terminal selection. It defaults off on legacy Windows console (CMD without `WT_SESSION` / `ConEmuPID`) and inside JetBrains JediTerm — PyCharm/IDEA/CLion/etc. — where the terminal advertises mouse support but forwards SGR mouse events as raw text (#878, #898). Use `--mouse-capture` to opt in anywhere it's defaulted off. Raw terminal selection may cross the right workbar and include visual wraps because the terminal, not the TUI, owns the selection.\n- `--profile `: select config profile\n- `--config `: config file path\n- `-v, --verbose`: verbose logging\n\n## Branching and Rollback\n\nCodewhale has three related but intentionally separate recovery paths:\n\n- `codewhale fork ` creates a new saved session from an existing saved\n conversation and records the source session id. This is the safe way to\n explore a different answer path without overwriting the original session.\n- Esc-Esc backtrack rewinds the live transcript to a previous user prompt and\n restores that prompt into the composer for editing. It does not change\n files; `/restore` rolls the folder back to a chosen restore point and needs\n `/trust on` or Full Access.\n- `/restore` and the `revert_turn` tool restore workspace files from side-git\n snapshots. `/restore list [N]` lists more snapshot options before choosing a\n rollback point. They do not rewrite conversation history.\n\nA Pi-style in-file tree browser is a larger UI/data-model project. v0.8.40\nships the bounded fork/backtrack primitives and explicit lineage metadata.\n" } ] }, @@ -107,8 +107,8 @@ "sources": [ { "path": "docs/TOOL_SURFACE.md", - "sha256": "89f7a70003d94c9b7e9139355e9c68c57c02e15a5513ce03afcfe67f3e34d16a", - "content": "# Tool surface\n\n> 阅读简体中文版:[zh_hans/TOOL_SURFACE.md](zh_hans/TOOL_SURFACE.md)。\n\nThis document describes the current model-facing tool contract. The v0.9.1\ncutover that produced it is recorded in `docs/RUNTIME_SIMPLIFICATION_DESIGN.md`;\nread the workspace version from `Cargo.toml`, not from this line. The registry\nremains larger than the first-turn catalog so\nsaved transcripts can replay and uncommon capabilities can be loaded on demand.\nThe model should learn one canonical name for each common operation.\n\nImplementation sources:\n\n- `crates/tui/src/core/engine/tool_catalog.rs` owns the eager/deferred catalog.\n- `crates/tui/src/tools/registry.rs` registers canonical tools and hidden aliases.\n- `crates/tui/src/tools/{file,file_tool,shell}.rs` own the small foreground\n primitive behavior and schemas; the other native tools remain searchable.\n- `docs/RUNTIME_SIMPLIFICATION_DESIGN.md` records the v0.9.1 cutover and receipt.\n\n## Default-active contract\n\nNew turns start with thirteen eager native names plus synthetic `tool_search`:\n\n1. `read`\n2. `write`\n3. `edit`\n4. `file_search`\n5. `bash`\n6. `agent`\n7. `workflow`\n8. `todo_write`\n9. `create_goal`\n10. `get_goal`\n11. `update_goal`\n12. `task_shell_wait`\n13. `load_skill`\n14. `tool_search` (synthetic, always active)\n\nThe thirteen native names are `DEFAULT_ACTIVE_NATIVE_TOOLS` in\n`crates/tui/src/core/engine/tool_catalog.rs`, pinned by\n`default_active_contract_keeps_discovery_and_core_tools_eager`. An authority\nboundary may remove `agent` at the maximum child depth, and Plan registers only\nthe foreground shell surface (so no `task_shell_wait`), but route size alone\nmust not change this core vocabulary.\n\nThe direct schemas deliberately stay small:\n\n| Tool | Input | Purpose |\n|---|---|---|\n| `read` | `path`, optional `offset`, optional `limit` | Read a bounded file window with explicit continuation or truncation notices. |\n| `write` | `path`, `content` | Create or replace a file. |\n| `edit` | `path`, `edits` | Apply one or more unambiguous text replacements against one original snapshot. |\n| `file_search` | `query`, optional `path`, `limit`, `extensions`, `exclude` | Find files by name, path fragment or glob and return bounded, ranked matches. Respects ignore files; `exclude` replaces default exclusions. |\n| `bash` | `command`, optional `timeout`, optional `description` | Run one cancellable foreground shell command and return a bounded tail. The optional description is display-only. |\n| `agent` | delegated task and optional scope/context controls | Start or inspect focused child work. |\n| `workflow` | plan/script/source_path plus run controls | Coordinate multi-agent phases with dependencies and completion checks. |\n| `todo_write` | complete replacement list of `{content, status}` items | Keep optional, agent-owned progress notes for genuinely multi-step work. |\n| `create_goal` | objective plus optional budget | Start the session goal the turn works toward. |\n| `get_goal` | none | Read the active goal and its progress. |\n| `update_goal` | terminal status | Mark the goal complete or blocked. |\n| `task_shell_wait` | `task_id`, optional `wait`, `timeout_ms`, `cancel`, `gate` | Poll or stop a background shell task. Completion still arrives as a runtime event, so use it for early output, true barriers, or gate evidence. |\n| `load_skill` | optional `name`, `query` | Load a named skill's `SKILL.md` body and companion-file list; omit `name` or use `name=\"list\"` to list eligible skills, or use `query` to search names and descriptions. |\n| `tool_search` | `query`, optional matching controls | Discover policy-allowed deferred tools and add selected schemas to this conversation's toolbox. |\n\nMode is an authority decision, not a synonym system. Plan, Work, and Operate\nuse the same primitive identities. Plan centrally refuses `write`, `edit`, and\n`bash`; Work and Operate still pass those calls through approval, sandbox,\ntrusted-path, repository-law, and managed-policy gates. Full Access changes\nordinary approval behavior but does not bypass hard safety or repository law.\n\n`update_plan` remains registered only for saved-artifact compatibility and is\nnot model-visible. `tasks`, `Git`, `Run`, `Web`, `remember`, and other\nspecialized capabilities are searchable rather than first-turn ceremony.\n\nOn the TUI's default (calm) shell card the Bash description is the headline,\nshown as written apart from stripped terminal escapes, collapsed whitespace and\na length bound, and the command sits on its own `$` row directly under it in\nevery state and at every width, including automatic runs. A card without a\ndescription leads with the `$` command instead. The labeled card\n(`show_tool_details = true` with `calm_mode = false`) keeps the command in its\nheader and the description as a muted `note:` row. The description never\nreplaces the command and is excluded from execution, exact approval keys and\nAuto-Review input. The original description remains in the recorded tool input\nand exports. Approval prompts, `/jobs`, the activity inspector, ACP and other\nRuntime clients keep showing the command; this change adds no description row\nto those surfaces.\n\n## Local code execution\n\n`code_execution` (Python) and `js_execution` (Node.js) use the same\npermission-aware local launcher as workspace task gates and test runs. Ordinary\napproval retains the effective session policy. To retry a denied exact call\nwith wider permissions, supply `sandbox_permissions` (`workspace-write` or\n`danger-full-access`) and a nonempty `justification`; Ask mode requires explicit\none-shot user approval. The approved policy affects only that call.\n\nBoth tools read source from stdin, return stdout/stderr/return_code, and keep\nthe existing timeout and process-tree cleanup. They are not persistent REPLs;\ntracebacks refer to stdin rather than a temporary script path. The separate\nRLM/REPL kernel is not changed by this launcher integration.\n\nPlatform/backend limits of the shared launcher still apply: no available local\nwrapper means workspace-write remains unenforced, while read-only is refused.\nAn external sandbox session cannot use these local interpreter tools and must\nuse its shell execution path. See [sandbox limits](SANDBOX.md).\n\n## Deferred and dynamic tools\n\n`Web` is conditional and deferred. It is discoverable through `tool_search`\nonly when the active policy and runtime backend permit it. Read-only\nchildren retain its read-only search/fetch evidence path; read-only authority\ndoes not mean \"unable to research.\"\n\nThe durable `github`, `automation`, and `rlm` action families are also deferred\nby default. `rlm` owns `open`, `eval`, `configure`, and `close` actions for a\npersistent local Python session (a subprocess with a scrubbed environment, not\nan OS sandbox). Inline ```` ```repl ```` fences in a reply run in the same kind of\nkernel only when `code_execution` is on the turn's surface (never in Plan mode),\nonly when the fence opens its own line, and only after `code_execution`'s\napproval under the session posture. Feature-gated native tools may be added to\nthe active or deferred catalog only when their implementation and host\ndependencies are available.\n\nMCP tools are dynamic. Successfully connected servers register names such as\n`mcp__` from `~/.codewhale/mcp.json`; a failed or disabled server\nmust not be presented as available. MCP and plugin tools are deferred unless a\nuser explicitly names them in `[tools].always_load`.\n\nFor an unstarted configured server, an MCP-focused `tool_search` first\nperforms bounded discovery under the current turn's server/tool ceiling.\nIt searches real server-provided schemas after connection, rather than\ninventing `mcp_*` definitions. A search inside `execute_tools` describes\nthose schemas without activating them; a direct search uses the existing\nbounded activation cache. Ordinary unrelated searches leave optional servers\nunstarted. The CLI's standalone MCP inspection commands own a separate pool.\n\n### Code mode (`execute_tools`)\n\n`execute_tools` is engine-injected, alongside the synthetic interpreter tools.\nIt runs a JavaScript program whose only host surface is\n`await tools.call(name, args)`, and it is the default way to compose several\ntool calls — MCP and plugin tools included — without round-tripping every\nintermediate result through the conversation. It is hidden from Plan mode and\nrefused under a worker authority envelope.\n\n- **One gate.** In a session turn every nested call is sent back to the turn\n loop and planned exactly like a direct call: deny/allow lists, preparation\n (MCP `readOnlyHint`/`destructiveHint`), `tool_call_before` hooks, ask-rules,\n Auto-Review, repository law, and the Computer Use consent refusal. MCP calls\n run through the session MCP pool. Approving the program grants nothing, so\n `execute_tools` itself is auto-approved. If the permission posture changes\n while a program runs, its remaining nested calls are refused (an approved\n call survives only an equal or broader posture, as for a direct call) and\n the model retries them under the new posture.\n- **Approvals suspend the program.** A nested call that needs approval raises\n the normal approval card (named `execute_tools program call: ...`) and the\n program waits; allow resumes it, deny fails only that nested call as an\n exception the program can catch. Time spent waiting on a decision does not\n count against the program's run deadline, which is the turn's remaining\n wall clock.\n- **Receipts.** The result lists every nested call with its decision (`auto`,\n `approved`, `denied`, `refused`) and status (`ok`, `failed`, `refused`,\n `in_flight`). A program that hits its deadline still returns the receipt;\n `in_flight` calls were cancelled and may have partially run. Each nested\n result is `{content, metadata, truncated}`; an oversized result keeps that\n shape, and `truncated` names the original size and the spillover file with\n the full output.\n- **Discovery without re-pinning.** Inside a program,\n `tools.call('tool_search', {query})` returns matching deferred tools with\n their input schemas and does not activate them, so the request's tool array\n and the session-pinned prefix do not change.\n- **Stays direct:** `agent`, `workflow`, `request_user_input`, nested\n `execute_tools`, interactive shells, sandbox escalation, Computer Use\n consent and scripts, and MCP sign-in (`mcp__authenticate`).\n\nCode mode is on by default (`[features] code_mode = true`), which makes\n`execute_tools` eager from the first request; direct tools and `tool_search`\nstay available either way. Set `code_mode = false` (or run with\n`--disable code_mode`) to go back to deferring `execute_tools` behind\n`tool_search`. The flag is session configuration, so the prompt prefix stays\nstable within a session.\n\nSub-agents get `execute_tools` on the same terms, unless their role is limited\nto the read-only evidence tools or an explicit tool list leaves it out. A\nchild's nested call is planned by the child's own gate, so its grant and its\napproval path apply as for its direct calls, and it is admitted at execution\nagainst the child's write-scope claim and live peers. A child's program also\nstops at the child's tool timeout (time waiting on approvals is not counted)\nor its work deadline, and still returns the receipt. A program in which no\nnested call ran does not count as progress for the child's permission-denial\nguard, but the guard does not yet count a refused nested call as a denial\neither; only the child's step and time budgets end a loop of denied calls\nretried inside programs. Without an engine turn\n(a direct unit call), a program keeps the conservative profile: read-only,\nauto-approved native calls only, no MCP.\n\n### Conversation toolbox cache\n\nA successful search activation is remembered by name for the current\nconversation. The cache is bounded at eight deferred names and 16 KiB of\nserialized schemas. To make room it evicts tools that were never called first\n(oldest activation first), then the least recently called; a tool called in the\ncurrent or previous turn is never evicted. When such tools already fill the\ncache, a search still activates its best match over the bound and holds the\nrest; a later activation restores the bound once those tools go unused. Every\nentry is revalidated against the current catalog and policy before it is\nadvertised again. A session sync clears it. The cache cannot resurrect a\nremoved, denied, or newly-eager tool.\n\nWhen a search's activation pushes a previously activated tool out of the\ncache, that search's result lists it under `evicted_tool_references`, so the\nmodel is told the tool is no longer callable rather than finding it missing\nfrom the next request. A held match is listed under\n`unavailable_tool_references`. Both carry `tool_search_input`, an exact regex\nquery for that one name that loads it again. Only tools that actually left the\nactive set are listed; a tool that became eager stays callable and is not\nreported. Known limit: a tool dropped by the per-turn revalidation (removed,\ndenied, or no longer deferred), or evicted to make room for a direct first call\nto a deferred tool, is still dropped silently, because there is no search\nresult to carry the notice.\n\nEach subagent gets its own policy-filtered deferred catalog, always-present\n`tool_search`, and bounded activation cache. Forked messages and instructions\nremain in context, but the child cache starts empty and discovers tools locally;\nneither forked context nor a cache can become a discovery allowlist. A child can\nstill search every tool its own authority permits, including Web search/fetch\nfor read-only research roles.\n\n## Inspect the model-client request tool payload\n\nRun `/tools` after a model turn to inspect a bounded projection of the exact\ntool field in the latest prepared model-client request. `/tools json` emits the\nsame evidence as bounded machine-readable JSON. Both formats open in a pager;\nthey are not copied into transcript history. `/tool-studio` remains a human-\ncommand compatibility alias; it is not a model tool.\n\nThe snapshot distinguishes an absent tool field from a present empty array. It\nreports the exact model-client tool JSON byte count and SHA-256 digest only when\nmeasurement fits the one-MiB inspection bound; larger payloads stay unavailable.\nProvider adapters may transform, sanitize, or omit those fields while building\na provider-specific wire body, so `/tools` marks provider delivery and the wire\npayload unavailable. Capture and rendering are bounded: retained schemas,\ndescriptions, caller lists, catalog rows, turn IDs, and payload measurement all\ncarry explicit truncation, omission, or unavailable receipts. The snapshot stays\nin memory only for the current session and is replaced on each prepared request.\n\nProvider, model, approval, registry provenance, and runtime capability metadata\nare not fields in the request tool schema. `/tools` therefore reports them as\nunavailable instead of joining against mutable state or inferring values. Use\nthe separate route and permission receipts for those facts.\n\n## Modes and permission postures\n\nModes and permission postures are separate controls:\n\n- **Plan** keeps the stable primitive vocabulary but centrally refuses shell\n execution and file mutation.\n- **Work** is ordinary interactive execution.\n- **Operate** uses the same direct-tool authority as Work. Small work stays\n direct; multi-step delegation uses a compact Workflow plan with dependencies,\n bounded scopes, and completion evidence. Fleet manages the same sub-agents\n and roles. One bounded, independent task can use a direct agent; `followup`\n reuses that agent for continued work.\n- **Ask**, **Auto-Review**, and **Full Access** control approval behavior within\n an action-capable mode. They never widen Plan into write or shell access.\n\nSee `docs/MODES.md` for the full mode and posture contract.\n\n## Compatibility names\n\nThe model-facing contract is the lowercase core above. Saved v0.9.x\ntranscripts and protocol clients may still call exact hidden compatibility\nnames such as `File`, `Bash`, and the older single-operation file names. Those\nnames never enter a new model catalog or `tool_search` result.\n\nCompatibility is execution compatibility, not fuzzy aliasing: an exact legacy\ncall must reach the handler for its legacy schema. It must not be rewritten\ninto a small lowercase primitive whose input shape is different. Unknown or\nretired names still fail closed instead of guessing a destination.\n\nSpecialized native families such as `Git`, `Run`, and `Web` are not aliases for\nthe lowercase core. They remain real, policy-filtered deferred tools and are\nloaded through `tool_search` when needed.\n\n## Long-running work\n\n`bash` runs one cancellable foreground command. It does not carry background,\nTTY, wait, interact, or cancel action fields. Stateful process and terminal\ncontrol stays out of the shell schema: `task_shell_wait` is eager for the\nbackground jobs `bash` starts, while interactive terminal control remains\ndeferred behind `tool_search`; neither enlarges the first-turn shell schema.\n\nUse `tasks` when the work itself needs a durable lifecycle, structured gates,\nartifacts, replayable timelines, or a stable task id. Large tool results should\nremain behind bounded handles or artifacts instead of being copied wholesale\ninto the parent transcript.\n\n## Parallel fan-out\n\nThe sub-agent capacity source of truth is\n`crates/tui/src/config/subagent_limits.rs`:\n\n- default configured concurrency: **64**;\n- maximum configured concurrency: **128**;\n- maximum admitted running-plus-queued work: **1024**.\n\nThese are capacity ceilings, not advice to dispatch every available slot. A\nmanager should use the smallest useful fan-out, preserve a single owner for\nfan-in, and verify worker receipts before reporting combined completion.\n\nRLM child-query batching is a different, cheaper cost class. Its\n`sub_query_batch` helper accepts 1–16 one-shot children inside a live `rlm`\nsession; it is not a substitute for tool-carrying `agent` workers.\n\n## Human inspection: `/tools` (`/tool-studio`)\n\n`/tools` renders a **read-only, bounded human projection** of the tool field of\nthe request that was prepared for one `(turn, step)`. It is not a second\nregistry and not an execution surface.\n\n**The seam.** The snapshot is built in `crates/tui/src/core/engine/turn_loop.rs`\nimmediately after `MessageRequest` is constructed, from `request.tools` — the\nsame value the model client is handed. The engine resolves the surrounding\nper-turn data once in `engine.rs` (`ToolSurfaceContext`: flattened registry\nfacts, the MCP pool's own server attribution, the engine-injected catalog names,\nand the resolved model client's receipt) and passes it as plain data, so the\nper-step seam never re-locks the MCP pool or holds a tool object.\n\n**Turn and step identity.** The tool set can differ between steps of a turn, so\neach snapshot is stamped with turn id and step and each seam emits its own. The\nTUI keeps only the latest (`SessionState.last_tool_request_snapshot`). Before\nthe first seam there is no snapshot and `/tools` says so rather than rebuilding\na registry in the UI.\n\nTwo kinds of fact are kept apart:\n\n- **Wire facts** come from the prepared request: name, description, schema,\n `defer_loading` / `strict` / `allowed_callers` / `cache_control`, byte\n accounting, and the catalog digest.\n- **Surface facts** come from the `ToolSurfaceContext`: provenance\n (`builtin` / `plugin` / `mcp` / `synthetic` / `unknown`), MCP server identity,\n declared capabilities, declared approval requirement, and model visibility.\n\nContract:\n\n- **One digest.** `active_tool_catalog_sha256`\n (`crates/tui/src/core/engine/preview.rs`) is the single definition of the\n active-tool-catalog hash. The request manifest publishes it as\n `ToolSurfaceFacts::active_tool_catalog_sha256` and `/tools` reports the same\n value for the same prepared request; neither surface keeps a hash of its own.\n- **Nothing is guessed.** MCP server identity is shown only when the real pool\n attributed that exact model tool name. `McpPool::mcp_model_tool_name` is the\n single definition shared by the model catalog and the human attribution, and\n an ambiguous name (two servers colliding on one model name) resolves to no\n server. Synthetic provenance comes from\n `default_synthetic_catalog_tool_names`, which is asserted against the engine's\n own `is_synthetic_catalog_tool` predicate. A transmitted tool with no registry\n entry reports `capabilities: unknown`, never \"none\".\n- **Provider availability follows the resolved client.** It comes from\n `Engine::tool_surface_provider_receipt`, never from \"a tool registry exists\".\n With no client the receipt is `unavailable` even when the registry is full.\n- **Unknown shrinks, it does not vanish.** `unavailable_for_this_request` always\n contains `provider_wire_payload`: nothing on this path observes what the\n provider adapter finally transmits. It additionally contains `provider` and\n `model` without a resolved client, and `provenance` / `capabilities` /\n `approval` when no surface context was captured.\n- **Absent stays distinct from empty.** A request with no tools field is not a\n request with an empty tools array; an unresolved field is `unknown` with a\n reason, not a default.\n- **Bounded.** Rendering is capped by tool count (32), name, description, schema\n bytes, allowed-caller count, and a payload measurement bound, each with an\n explicit truncation or omission receipt. Registered tools that this request\n does *not* carry are reported as a bounded name list plus an exact count\n rather than expanding the projection.\n- **Inert.** The snapshot lives beside the transcript, never in\n `session.messages`, so it cannot enter a model request or perturb the\n provider's prefix cache. It never executes a tool, never reads credentials,\n never reorders the catalog, and is never registered as a model-callable tool.\n- **Delivery is never claimed.** The capture happens before connection setup, so\n `delivery_status` stays `unknown`.\n\n## Release verification\n\nDo not infer the public surface from handler function names. Verify the model\ncatalog and alias visibility at the exact candidate SHA:\n\n```bash\npython3 scripts/measure-runtime-contract.py\ncargo test -p codewhale-tui --lib --locked core::engine::tests::default_active_contract_keeps_discovery_and_core_tools_eager -- --exact\ncargo test -p codewhale-tui --lib --locked tools::file_tool::tests::primitive_schemas_are_separate_and_small_contract_shaped -- --exact\ncargo test -p codewhale-tui --lib --locked tools::shell::tests::lowercase_bash_schema_is_small_contract -- --exact\ncargo test --locked -p codewhale-tui --lib core::engine::tests::print_mode_tool_catalog_metrics -- --ignored --exact --nocapture\n```\n\nCheck the test names against the source before trusting a green run: `cargo test`\nexits 0 with \"0 passed; N filtered out\" when a filter matches nothing, so a\nmisspelled filter is indistinguishable from a pass. Each `--exact` command\nabove must report `1 passed` (the ignored metrics test reports `1 passed`\nonly because `--ignored` selects it); `0 passed` means the filter matched\nnothing and the check did not run.\n\nThe provider-free receipt must report the thirteen default-active names listed\nabove. A separate repository-wide tool count may include deferred, dynamic,\nfeature-gated, and compatibility-only registrations; it is not the number of\ntools placed in the first-turn model catalog.\n" + "sha256": "f14fbc4d884fa628afd5c82c501d0cda4fb069afaf1aedbe851f253b90b217cb", + "content": "# Tool surface\n\n> 阅读简体中文版:[zh_hans/TOOL_SURFACE.md](zh_hans/TOOL_SURFACE.md)。\n\nThis document describes the current model-facing tool contract. The v0.9.1\ncutover that produced it is recorded in `docs/RUNTIME_SIMPLIFICATION_DESIGN.md`;\nread the workspace version from `Cargo.toml`, not from this line. The registry\nremains larger than the first-turn catalog so\nsaved transcripts can replay and uncommon capabilities can be loaded on demand.\nThe model should learn one canonical name for each common operation.\n\nImplementation sources:\n\n- `crates/tui/src/core/engine/tool_catalog.rs` owns the eager/deferred catalog.\n- `crates/tui/src/tools/registry.rs` registers canonical tools and hidden aliases.\n- `crates/tui/src/tools/{file,file_tool,shell}.rs` own the small foreground\n primitive behavior and schemas; the other native tools remain searchable.\n- `docs/RUNTIME_SIMPLIFICATION_DESIGN.md` records the v0.9.1 cutover and receipt.\n\n## Default-active contract\n\nNew turns start with nine eager native names plus synthetic `tool_search`:\n\n1. `read`\n2. `write`\n3. `edit`\n4. `file_search`\n5. `bash`\n6. `todo_write`\n7. `task_shell_wait`\n8. `load_skill`\n9. `create_goal`\n10. `tool_search` (synthetic, always active)\n\nThe nine native names are `DEFAULT_ACTIVE_NATIVE_TOOLS` in\n`crates/tui/src/core/engine/tool_catalog.rs`, pinned by\n`default_active_contract_keeps_discovery_and_core_tools_eager`. `agent`,\n`workflow`, `get_goal`, and `update_goal` are deferred by default and load\nvia `tool_search` (`get_goal`/`update_goal` ride eager only while a goal is\nactive). An authority boundary may remove `agent` at the maximum child depth,\nand Plan registers only the foreground shell surface (so no `task_shell_wait`),\nbut route size alone must not change this core vocabulary.\n\nThe core schemas deliberately stay small (deferred rows load via\n`tool_search` rather than riding every turn):\n\n| Tool | Input | Purpose |\n|---|---|---|\n| `read` | `path`, optional `offset`, optional `limit` | Read a bounded file window with explicit continuation or truncation notices. |\n| `write` | `path`, `content` | Create or replace a file. |\n| `edit` | `path`, `edits` | Apply one or more unambiguous text replacements against one original snapshot. |\n| `file_search` | `query`, optional `path`, `limit`, `extensions`, `exclude` | Find files by name, path fragment or glob and return bounded, ranked matches. Respects ignore files; `exclude` replaces default exclusions. |\n| `bash` | `command`, optional `timeout`, optional `description` | Run one cancellable foreground shell command and return a bounded tail. The optional description is display-only. |\n| `agent` (deferred) | delegated task and optional scope/context controls | Start or inspect focused child work. |\n| `workflow` (deferred) | plan/script/source_path plus run controls | Coordinate multi-agent phases with dependencies and completion checks. |\n| `todo_write` | complete replacement list of `{content, status}` items | Keep optional, agent-owned progress notes for genuinely multi-step work. |\n| `create_goal` | objective plus optional budget | Start the session goal the turn works toward. |\n| `get_goal` (deferred unless a goal is active) | none | Read the active goal and its progress. |\n| `update_goal` (deferred unless a goal is active) | terminal status | Mark the goal complete or blocked. |\n| `task_shell_wait` | `task_id`, optional `wait`, `timeout_ms`, `cancel`, `gate` | Poll or stop a background shell task. Completion still arrives as a runtime event, so use it for early output, true barriers, or gate evidence. |\n| `load_skill` | optional `name`, `query` | Load a named skill's `SKILL.md` body and companion-file list; omit `name` or use `name=\"list\"` to list eligible skills, or use `query` to search names and descriptions. |\n| `tool_search` | `query`, optional matching controls | Discover policy-allowed deferred tools and add selected schemas to this conversation's toolbox. |\n\nMode is an authority decision, not a synonym system. Plan, Work, and Operate\nuse the same primitive identities. Plan centrally refuses `write`, `edit`, and\n`bash`; Work and Operate still pass those calls through approval, sandbox,\ntrusted-path, repository-law, and managed-policy gates. Full Access changes\nordinary approval behavior but does not bypass hard safety or repository law.\n\n`update_plan` remains registered only for saved-artifact compatibility and is\nnot model-visible. `tasks`, `Git`, `Run`, `Web`, `remember`, and other\nspecialized capabilities are searchable rather than first-turn ceremony.\n\nOn the TUI's default (calm) shell card the Bash description is the headline,\nshown as written apart from stripped terminal escapes, collapsed whitespace and\na length bound, and the command sits on its own `$` row directly under it in\nevery state and at every width, including automatic runs. A card without a\ndescription leads with the `$` command instead. The labeled card\n(`show_tool_details = true` with `calm_mode = false`) keeps the command in its\nheader and the description as a muted `note:` row. The description never\nreplaces the command and is excluded from execution, exact approval keys and\nAuto-Review input. The original description remains in the recorded tool input\nand exports. Approval prompts, `/jobs`, the activity inspector, ACP and other\nRuntime clients keep showing the command; this change adds no description row\nto those surfaces.\n\n## Local code execution\n\n`code_execution` (Python) and `js_execution` (Node.js) use the same\npermission-aware local launcher as workspace task gates and test runs. Ordinary\napproval retains the effective session policy. To retry a denied exact call\nwith wider permissions, supply `sandbox_permissions` (`workspace-write` or\n`danger-full-access`) and a nonempty `justification`; Ask mode requires explicit\none-shot user approval. The approved policy affects only that call.\n\nBoth tools read source from stdin, return stdout/stderr/return_code, and keep\nthe existing timeout and process-tree cleanup. They are not persistent REPLs;\ntracebacks refer to stdin rather than a temporary script path. The separate\nRLM/REPL kernel is not changed by this launcher integration.\n\nPlatform/backend limits of the shared launcher still apply: no available local\nwrapper means workspace-write remains unenforced, while read-only is refused.\nAn external sandbox session cannot use these local interpreter tools and must\nuse its shell execution path. See [sandbox limits](SANDBOX.md).\n\n## Deferred and dynamic tools\n\n`Web` is conditional and deferred. It is discoverable through `tool_search`\nonly when the active policy and runtime backend permit it. Read-only\nchildren retain its read-only search/fetch evidence path; read-only authority\ndoes not mean \"unable to research.\"\n\nThe durable `github`, `automation`, and `rlm` action families are also deferred\nby default. `rlm` owns `open`, `eval`, `configure`, and `close` actions for a\npersistent local Python session (a subprocess with a scrubbed environment, not\nan OS sandbox). Inline ```` ```repl ```` fences in a reply run in the same kind of\nkernel only when `code_execution` is on the turn's surface (never in Plan mode),\nonly when the fence opens its own line, and only after `code_execution`'s\napproval under the session posture. Feature-gated native tools may be added to\nthe active or deferred catalog only when their implementation and host\ndependencies are available.\n\nMCP tools are dynamic. Successfully connected servers register names such as\n`mcp__` from `~/.codewhale/mcp.json`; a failed or disabled server\nmust not be presented as available. MCP and plugin tools are deferred unless a\nuser explicitly names them in `[tools].always_load`.\n\nFor an unstarted configured server, an MCP-focused `tool_search` first\nperforms bounded discovery under the current turn's server/tool ceiling.\nIt searches real server-provided schemas after connection, rather than\ninventing `mcp_*` definitions. A search inside `execute_tools` describes\nthose schemas without activating them; a direct search uses the existing\nbounded activation cache. Ordinary unrelated searches leave optional servers\nunstarted. The CLI's standalone MCP inspection commands own a separate pool.\n\n### Code mode (`execute_tools`)\n\n`execute_tools` is engine-injected, alongside the synthetic interpreter tools.\nIt runs a JavaScript program whose only host surface is\n`await tools.call(name, args)`, and it is the default way to compose several\ntool calls — MCP and plugin tools included — without round-tripping every\nintermediate result through the conversation. It is hidden from Plan mode and\nrefused under a worker authority envelope.\n\n- **One gate.** In a session turn every nested call is sent back to the turn\n loop and planned exactly like a direct call: deny/allow lists, preparation\n (MCP `readOnlyHint`/`destructiveHint`), `tool_call_before` hooks, ask-rules,\n Auto-Review, repository law, and the Computer Use consent refusal. MCP calls\n run through the session MCP pool. Approving the program grants nothing, so\n `execute_tools` itself is auto-approved. If the permission posture changes\n while a program runs, its remaining nested calls are refused (an approved\n call survives only an equal or broader posture, as for a direct call) and\n the model retries them under the new posture.\n- **Approvals suspend the program.** A nested call that needs approval raises\n the normal approval card (named `execute_tools program call: ...`) and the\n program waits; allow resumes it, deny fails only that nested call as an\n exception the program can catch. Time spent waiting on a decision does not\n count against the program's run deadline, which is the turn's remaining\n wall clock.\n- **Receipts.** The result lists every nested call with its decision (`auto`,\n `approved`, `denied`, `refused`) and status (`ok`, `failed`, `refused`,\n `in_flight`). A program that hits its deadline still returns the receipt;\n `in_flight` calls were cancelled and may have partially run. Each nested\n result is `{content, metadata, truncated}`; an oversized result keeps that\n shape, and `truncated` names the original size and the spillover file with\n the full output.\n- **Discovery without re-pinning.** Inside a program,\n `tools.call('tool_search', {query})` returns matching deferred tools with\n their input schemas and does not activate them, so the request's tool array\n and the session-pinned prefix do not change.\n- **Stays direct:** `agent`, `workflow`, `request_user_input`, nested\n `execute_tools`, interactive shells, sandbox escalation, Computer Use\n consent and scripts, and MCP sign-in (`mcp__authenticate`).\n\nCode mode is on by default (`[features] code_mode = true`), which makes\n`execute_tools` eager from the first request; direct tools and `tool_search`\nstay available either way. Set `code_mode = false` (or run with\n`--disable code_mode`) to go back to deferring `execute_tools` behind\n`tool_search`. The flag is session configuration, so the prompt prefix stays\nstable within a session.\n\nSub-agents get `execute_tools` on the same terms, unless their role is limited\nto the read-only evidence tools or an explicit tool list leaves it out. A\nchild's nested call is planned by the child's own gate, so its grant and its\napproval path apply as for its direct calls, and it is admitted at execution\nagainst the child's write-scope claim and live peers. A child's program also\nstops at the child's tool timeout (time waiting on approvals is not counted)\nor its work deadline, and still returns the receipt. A program in which no\nnested call ran does not count as progress for the child's permission-denial\nguard, but the guard does not yet count a refused nested call as a denial\neither; only the child's step and time budgets end a loop of denied calls\nretried inside programs. Without an engine turn\n(a direct unit call), a program keeps the conservative profile: read-only,\nauto-approved native calls only, no MCP.\n\n### Conversation toolbox cache\n\nA successful search activation is remembered by name for the current\nconversation. The cache is bounded at eight deferred names and 16 KiB of\nserialized schemas. To make room it evicts tools that were never called first\n(oldest activation first), then the least recently called; a tool called in the\ncurrent or previous turn is never evicted. When such tools already fill the\ncache, a search still activates its best match over the bound and holds the\nrest; a later activation restores the bound once those tools go unused. Every\nentry is revalidated against the current catalog and policy before it is\nadvertised again. A session sync clears it. The cache cannot resurrect a\nremoved, denied, or newly-eager tool.\n\nWhen a search's activation pushes a previously activated tool out of the\ncache, that search's result lists it under `evicted_tool_references`, so the\nmodel is told the tool is no longer callable rather than finding it missing\nfrom the next request. A held match is listed under\n`unavailable_tool_references`. Both carry `tool_search_input`, an exact regex\nquery for that one name that loads it again. Only tools that actually left the\nactive set are listed; a tool that became eager stays callable and is not\nreported. Known limit: a tool dropped by the per-turn revalidation (removed,\ndenied, or no longer deferred), or evicted to make room for a direct first call\nto a deferred tool, is still dropped silently, because there is no search\nresult to carry the notice.\n\nEach subagent gets its own policy-filtered deferred catalog, always-present\n`tool_search`, and bounded activation cache. Forked messages and instructions\nremain in context, but the child cache starts empty and discovers tools locally;\nneither forked context nor a cache can become a discovery allowlist. A child can\nstill search every tool its own authority permits, including Web search/fetch\nfor read-only research roles.\n\n## Inspect the model-client request tool payload\n\nRun `/tools` after a model turn to inspect a bounded projection of the exact\ntool field in the latest prepared model-client request. `/tools json` emits the\nsame evidence as bounded machine-readable JSON. Both formats open in a pager;\nthey are not copied into transcript history. `/tool-studio` remains a human-\ncommand compatibility alias; it is not a model tool.\n\nThe snapshot distinguishes an absent tool field from a present empty array. It\nreports the exact model-client tool JSON byte count and SHA-256 digest only when\nmeasurement fits the one-MiB inspection bound; larger payloads stay unavailable.\nProvider adapters may transform, sanitize, or omit those fields while building\na provider-specific wire body, so `/tools` marks provider delivery and the wire\npayload unavailable. Capture and rendering are bounded: retained schemas,\ndescriptions, caller lists, catalog rows, turn IDs, and payload measurement all\ncarry explicit truncation, omission, or unavailable receipts. The snapshot stays\nin memory only for the current session and is replaced on each prepared request.\n\nProvider, model, approval, registry provenance, and runtime capability metadata\nare not fields in the request tool schema. `/tools` therefore reports them as\nunavailable instead of joining against mutable state or inferring values. Use\nthe separate route and permission receipts for those facts.\n\n## Modes and permission postures\n\nModes and permission postures are separate controls:\n\n- **Plan** keeps the stable primitive vocabulary but centrally refuses shell\n execution and file mutation.\n- **Work** is ordinary interactive execution.\n- **Operate** uses the same direct-tool authority as Work. Small work stays\n direct; multi-step delegation uses a compact Workflow plan with dependencies,\n bounded scopes, and completion evidence. Fleet manages the same sub-agents\n and roles. One bounded, independent task can use a direct agent; `followup`\n reuses that agent for continued work.\n- **Ask**, **Auto-Review**, and **Full Access** control approval behavior within\n an action-capable mode. They never widen Plan into write or shell access.\n\nSee `docs/MODES.md` for the full mode and posture contract.\n\n## Compatibility names\n\nThe model-facing contract is the lowercase core above. Saved v0.9.x\ntranscripts and protocol clients may still call exact hidden compatibility\nnames such as `File`, `Bash`, and the older single-operation file names. Those\nnames never enter a new model catalog or `tool_search` result.\n\nCompatibility is execution compatibility, not fuzzy aliasing: an exact legacy\ncall must reach the handler for its legacy schema. It must not be rewritten\ninto a small lowercase primitive whose input shape is different. Unknown or\nretired names still fail closed instead of guessing a destination.\n\nSpecialized native families such as `Git`, `Run`, and `Web` are not aliases for\nthe lowercase core. They remain real, policy-filtered deferred tools and are\nloaded through `tool_search` when needed.\n\n## Long-running work\n\n`bash` runs one cancellable foreground command. It does not carry background,\nTTY, wait, interact, or cancel action fields. Stateful process and terminal\ncontrol stays out of the shell schema: `task_shell_wait` is eager for the\nbackground jobs `bash` starts, while interactive terminal control remains\ndeferred behind `tool_search`; neither enlarges the first-turn shell schema.\n\nUse `tasks` when the work itself needs a durable lifecycle, structured gates,\nartifacts, replayable timelines, or a stable task id. Large tool results should\nremain behind bounded handles or artifacts instead of being copied wholesale\ninto the parent transcript.\n\n## Parallel fan-out\n\nThe sub-agent capacity source of truth is\n`crates/tui/src/config/subagent_limits.rs`:\n\n- default configured concurrency: **64**;\n- maximum configured concurrency: **128**;\n- maximum admitted running-plus-queued work: **1024**.\n\nThese are capacity ceilings, not advice to dispatch every available slot. A\nmanager should use the smallest useful fan-out, preserve a single owner for\nfan-in, and verify worker receipts before reporting combined completion.\n\nRLM child-query batching is a different, cheaper cost class. Its\n`sub_query_batch` helper accepts 1–16 one-shot children inside a live `rlm`\nsession; it is not a substitute for tool-carrying `agent` workers.\n\n## Human inspection: `/tools` (`/tool-studio`)\n\n`/tools` renders a **read-only, bounded human projection** of the tool field of\nthe request that was prepared for one `(turn, step)`. It is not a second\nregistry and not an execution surface.\n\n**The seam.** The snapshot is built in `crates/tui/src/core/engine/turn_loop.rs`\nimmediately after `MessageRequest` is constructed, from `request.tools` — the\nsame value the model client is handed. The engine resolves the surrounding\nper-turn data once in `engine.rs` (`ToolSurfaceContext`: flattened registry\nfacts, the MCP pool's own server attribution, the engine-injected catalog names,\nand the resolved model client's receipt) and passes it as plain data, so the\nper-step seam never re-locks the MCP pool or holds a tool object.\n\n**Turn and step identity.** The tool set can differ between steps of a turn, so\neach snapshot is stamped with turn id and step and each seam emits its own. The\nTUI keeps only the latest (`SessionState.last_tool_request_snapshot`). Before\nthe first seam there is no snapshot and `/tools` says so rather than rebuilding\na registry in the UI.\n\nTwo kinds of fact are kept apart:\n\n- **Wire facts** come from the prepared request: name, description, schema,\n `defer_loading` / `strict` / `allowed_callers` / `cache_control`, byte\n accounting, and the catalog digest.\n- **Surface facts** come from the `ToolSurfaceContext`: provenance\n (`builtin` / `plugin` / `mcp` / `synthetic` / `unknown`), MCP server identity,\n declared capabilities, declared approval requirement, and model visibility.\n\nContract:\n\n- **One digest.** `active_tool_catalog_sha256`\n (`crates/tui/src/core/engine/preview.rs`) is the single definition of the\n active-tool-catalog hash. The request manifest publishes it as\n `ToolSurfaceFacts::active_tool_catalog_sha256` and `/tools` reports the same\n value for the same prepared request; neither surface keeps a hash of its own.\n- **Nothing is guessed.** MCP server identity is shown only when the real pool\n attributed that exact model tool name. `McpPool::mcp_model_tool_name` is the\n single definition shared by the model catalog and the human attribution, and\n an ambiguous name (two servers colliding on one model name) resolves to no\n server. Synthetic provenance comes from\n `default_synthetic_catalog_tool_names`, which is asserted against the engine's\n own `is_synthetic_catalog_tool` predicate. A transmitted tool with no registry\n entry reports `capabilities: unknown`, never \"none\".\n- **Provider availability follows the resolved client.** It comes from\n `Engine::tool_surface_provider_receipt`, never from \"a tool registry exists\".\n With no client the receipt is `unavailable` even when the registry is full.\n- **Unknown shrinks, it does not vanish.** `unavailable_for_this_request` always\n contains `provider_wire_payload`: nothing on this path observes what the\n provider adapter finally transmits. It additionally contains `provider` and\n `model` without a resolved client, and `provenance` / `capabilities` /\n `approval` when no surface context was captured.\n- **Absent stays distinct from empty.** A request with no tools field is not a\n request with an empty tools array; an unresolved field is `unknown` with a\n reason, not a default.\n- **Bounded.** Rendering is capped by tool count (32), name, description, schema\n bytes, allowed-caller count, and a payload measurement bound, each with an\n explicit truncation or omission receipt. Registered tools that this request\n does *not* carry are reported as a bounded name list plus an exact count\n rather than expanding the projection.\n- **Inert.** The snapshot lives beside the transcript, never in\n `session.messages`, so it cannot enter a model request or perturb the\n provider's prefix cache. It never executes a tool, never reads credentials,\n never reorders the catalog, and is never registered as a model-callable tool.\n- **Delivery is never claimed.** The capture happens before connection setup, so\n `delivery_status` stays `unknown`.\n\n## Release verification\n\nDo not infer the public surface from handler function names. Verify the model\ncatalog and alias visibility at the exact candidate SHA:\n\n```bash\npython3 scripts/measure-runtime-contract.py\ncargo test -p codewhale-tui --lib --locked core::engine::tests::default_active_contract_keeps_discovery_and_core_tools_eager -- --exact\ncargo test -p codewhale-tui --lib --locked tools::file_tool::tests::primitive_schemas_are_separate_and_small_contract_shaped -- --exact\ncargo test -p codewhale-tui --lib --locked tools::shell::tests::lowercase_bash_schema_is_small_contract -- --exact\ncargo test --locked -p codewhale-tui --lib core::engine::tests::print_mode_tool_catalog_metrics -- --ignored --exact --nocapture\n```\n\nCheck the test names against the source before trusting a green run: `cargo test`\nexits 0 with \"0 passed; N filtered out\" when a filter matches nothing, so a\nmisspelled filter is indistinguishable from a pass. Each `--exact` command\nabove must report `1 passed` (the ignored metrics test reports `1 passed`\nonly because `--ignored` selects it); `0 passed` means the filter matched\nnothing and the check did not run.\n\nThe provider-free receipt must report the ten default-active names listed\nabove.\nabove. A separate repository-wide tool count may include deferred, dynamic,\nfeature-gated, and compatibility-only registrations; it is not the number of\ntools placed in the first-turn model catalog.\n\n\n### Source navigation receipts\n\nSource reads may include a bounded pod/symbol/link footer. Edits retain their unified diff in metadata and can name affected declarations and confirmed same-directory callers. File-name search and grep report matched directory pods; project maps can report bounded key-file symbols. Sounding content uses the same denied-read and credential exclusions plus protected regular-file handles. Caller content scans at most32 deterministic candidates at256KiB each; candidates outside that budget are reported as unscanned, never as additional known callers. Key-file content is capped at8MiB. Enumeration itself and platform I/O latency are not wall-clock bounded.\n" }, { "path": "docs/TOOL_LIFECYCLE.md", @@ -192,8 +192,8 @@ }, { "path": "docs/public-surface-facts.json", - "sha256": "9651f09b589b1979cc217b67afe8fbcd42a37e5c4c4bd51f02d0c949a4b5d583", - "content": "{\n \"schemaVersion\": 2,\n \"product\": {\n \"name\": \"Codewhale\",\n \"description\": \"Terminal coding agent for supported hosted and local models. One runtime on your machine. Rust, MIT.\",\n \"positioning\": \"Terminal-native, model/provider-neutral, local-first coding agent\",\n \"license\": \"MIT\",\n \"terminology\": {\n \"Fleet\": \"the user's model inventory: who is in the roster and which member is selected\",\n \"Workflow\": \"what order the work follows\",\n \"Lane\": \"one running Workflow instance\",\n \"Runtime\": \"where, how, and with what authority selected work executes\"\n },\n \"sources\": [\n \"Cargo.toml\",\n \"LICENSE\",\n \"README.md\",\n \"npm/codewhale/package.json\",\n \"docs/FLEET.md\"\n ]\n },\n \"sourceCandidate\": {\n \"version\": \"0.10.2\",\n \"providerCount\": 50,\n \"toolCount\": 79,\n \"sandboxBackends\": [\n \"seatbelt (macOS, when available)\",\n \"bubblewrap (Linux, default when installed and working)\"\n ],\n \"sources\": [\n \"Cargo.toml\",\n \"crates/tui/src/config.rs\",\n \"crates/tui/src/sandbox/mod.rs\",\n \"web/scripts/derive-facts.mjs\"\n ],\n \"providerCountDefinition\": \"Website descriptor identities in this source snapshot, excluding Custom and retired identities; includes protocol/plan variants. Not a count of distinct vendors, ProviderKind identities, live catalogs, or providers in any published release.\"\n },\n \"latestPublishedRelease\": {\n \"tag\": \"v0.10.1\",\n \"version\": \"0.10.1\",\n \"publishedAt\": \"2026-10-08T01:47:44Z\",\n \"url\": \"https://github.com/codewhale-hq/CodeWhale/releases/tag/v0.10.1\",\n \"sources\": [\n \"web/data/latest-published-release.json\"\n ]\n },\n \"install\": {\n \"recommended\": \"curl -fsSL https://codewhale.net/install.sh | sh\",\n \"binaries\": [\n \"codewhale\",\n \"codew\"\n ],\n \"channels\": {\n \"npm\": \"published releases only\",\n \"cargo\": \"published crates only\",\n \"prebuiltArchives\": \"published GitHub Releases only\",\n \"cnbMirror\": \"documented targets only\"\n },\n \"androidTermux\": {\n \"status\": \"preview\",\n \"npm\": \"preview\",\n \"requiresMatchingPublishedAssets\": true,\n \"sourceBuild\": true\n },\n \"sources\": [\n \"README.md\",\n \"npm/codewhale/package.json\",\n \"npm/codewhale/README.md\",\n \"npm/codewhale/scripts/artifacts.js\",\n \"docs/INSTALL.md\",\n \"docs/CNB_MIRROR.md\"\n ]\n },\n \"control\": {\n \"modes\": [\n \"Plan\",\n \"Work\",\n \"Operate\"\n ],\n \"permissionPostures\": [\n \"Ask\",\n \"Auto-Review\",\n \"Full Access\"\n ],\n \"shortcuts\": {\n \"mode\": {\n \"chord\": \"Tab\",\n \"when\": \"composer empty\"\n },\n \"permissionPosture\": {\n \"chord\": \"Shift+Tab\",\n \"when\": \"always (suppressed only under a non-Config modal)\"\n }\n },\n \"sources\": [\n \"docs/MODES.md\",\n \"docs/KEYBINDINGS.md\",\n \"crates/tui/src/settings.rs\"\n ]\n },\n \"toolSurface\": {\n \"defaultActive\": [\n \"read\",\n \"write\",\n \"edit\",\n \"file_search\",\n \"bash\",\n \"agent\",\n \"workflow\",\n \"todo_write\",\n \"create_goal\",\n \"get_goal\",\n \"update_goal\",\n \"task_shell_wait\",\n \"load_skill\"\n ],\n \"schemas\": {\n \"read\": [\n \"path\",\n \"offset?\",\n \"limit?\"\n ],\n \"write\": [\n \"path\",\n \"content\"\n ],\n \"edit\": [\n \"path\",\n \"edits\"\n ],\n \"bash\": [\n \"command\",\n \"timeout?\"\n ]\n },\n \"deferred\": {\n \"Web\": [\n \"search\",\n \"fetch\",\n \"wait\"\n ]\n },\n \"compatibility\": {\n \"legacyAliases\": \"hidden-exact\",\n \"modelVisible\": false,\n \"toolSearchDiscoverable\": false\n },\n \"activationCache\": {\n \"maximumNames\": 8,\n \"maximumSchemaBytes\": 16384,\n \"scope\": \"per conversation; each subagent owns an independent policy-filtered cache\"\n },\n \"agentConcurrency\": {\n \"defaultConfigured\": 64,\n \"maximumConfigured\": 128,\n \"maximumAdmitted\": 1024\n },\n \"sources\": [\n \"docs/TOOL_SURFACE.md\",\n \"docs/RUNTIME_SIMPLIFICATION_DESIGN.md\",\n \"crates/tui/src/core/engine/tool_catalog.rs\",\n \"crates/tui/src/tools/registry.rs\",\n \"crates/tui/src/config/subagent_limits.rs\"\n ]\n },\n \"surfaces\": {\n \"availableInSourceCandidate\": [\n \"TUI\",\n \"codewhale exec\",\n \"Web client\",\n \"Runtime API\",\n \"MCP\",\n \"Fleet\"\n ],\n \"sources\": [\n \"docs/GUIDE.md\",\n \"docs/WEB.md\",\n \"docs/RUNTIME_API.md\",\n \"docs/MCP.md\",\n \"docs/FLEET.md\"\n ]\n },\n \"trust\": {\n \"localRuntime\": \"runtime, workspace state, and audit log stay on the user's machine\",\n \"hostedProviderBoundary\": \"the selected hosted provider receives turn context required for inference\",\n \"localInference\": \"a loopback local-model route can keep inference local\",\n \"telemetry\": \"Codewhale 0.10.2 counts anonymous usage by default and discloses it at first launch (policy notice version 5, naming Codewhale and PostHog as processors), while the earlier 0.9.11 release asked first; turning it off is a saved choice that later versions keep, an opt-out recorded under the earlier opt-in policy stays off, and showing the notice never records any acceptance on the user's behalf; Codewhale does not collect conversations, code, prompts, files, file/repo/branch names, model content, credentials, or per-turn/per-tool timelines; while on, sessions post aggregate counts and closed enums to the first-party endpoint https://telemetry.codewhale.net/v1/telemetry, whose storage has no IP, country, or geo column and a fixed three-month retention; optional PostHog forwarding requires separate operator configuration and verified IP-safe egress, and its retention is a separate project setting; telemetry_endpoint = \\\"\\\" writes only to a local dry-run file; no mandatory hosted relay\",\n \"account\": \"no account required for the local runtime\",\n \"plan\": \"Plan centrally refuses file mutation and shell execution; policy-allowed research may contact external services and local session state can still be persisted.\",\n \"sandbox\": \"Seatbelt is used on macOS when available; Linux bubblewrap is on by default once installed and verified working, and prefer_bwrap = false opts out; Windows currently reports no OS sandbox\",\n \"audit\": \"sensitive events append best-effort to $CODEWHALE_HOME/audit.log (default ~/.codewhale/audit.log); write failures are logged\",\n \"usage\": \"provider token and cache usage is shown locally when available\",\n \"sources\": [\n \"docs/PROVIDERS.md\",\n \"docs/MODES.md\",\n \"docs/SANDBOX.md\",\n \"docs/RUNTIME_API.md\",\n \"docs/WEB.md\",\n \"crates/tui/src/audit.rs\",\n \"web/app/[locale]/faq/page.tsx\",\n \"web/app/[locale]/install/page.tsx\"\n ]\n },\n \"platforms\": {\n \"prebuilt\": [\n \"Linux x64\",\n \"Linux arm64\",\n \"macOS x64\",\n \"macOS arm64\",\n \"Windows x64\",\n \"Windows arm64\"\n ],\n \"preview\": [\n \"Android/Termux arm64\"\n ],\n \"sources\": [\n \"docs/INSTALL.md\"\n ]\n },\n \"repository\": {\n \"canonical\": \"https://github.com/codewhale-hq/CodeWhale\",\n \"mirrors\": [\n \"https://cnb.cool/codewhale.net/codewhale\",\n \"https://npmmirror.com/package/codewhale\"\n ],\n \"creditSources\": [\n \"CHANGELOG.md\",\n \"docs/CONTRIBUTORS.md\",\n \"web/lib/release-credits.ts\"\n ],\n \"requiredCandidateCredits\": [\"@BX166\"]\n },\n \"screenshot\": {\n \"readme\": \"web/public/codewhale-tui-0102-demo.png\",\n \"website\": \"web/public/codewhale-tui-0102-demo.png\",\n \"sourceVersion\": \"0.10.2\",\n \"sourceCommit\": \"bd0c32567024d5bc3efac7d13331c68711de031e\",\n \"sourceSha256\": \"43ebde941cedb690612945b712e9565e92a441045c78646bf8841f3ad43ac369\",\n \"sourceDirty\": true,\n \"sourceDiffSha256\": \"499468bbf066901c9b229c912fe64d7fdcb672674483ce8dbe59db7c66110c45\",\n \"binarySha256\": \"6ac94bff89046b5de4386c4ed39da2b86b2c53233d3c652f09022c83095e14dd\",\n \"terminal\": \"real PTY cell capture; the product page renders native cells as selectable HTML text, and the README image is the same frame exported as SVG by web/scripts/render-terminal-capture.mjs and rasterized as PNG\",\n \"theme\": \"Underwater (default)\",\n \"capture\": \"Captured on 2026-10-08 from the development build of a dirty 0.10.2 candidate based on bd0c32567024d5bc3efac7d13331c68711de031e by the opt-in website_current_terminal_capture test using crates/tui/tests/support/qa_harness. Exact source diff and binary hashes are recorded here and in the capture manifest. A deterministic loopback provider drives the real Engine to edit a JSON exporter, run three passing local Python tests and delegate a source-grounded read-only review. Home, unsent composer, configured provider manager, help, Tasks and Fleet are actual application output with no retouched cells or fixture history. Sealed HOME and Full Access apply only to the isolated demo workspace. This is a local scripted demo, not published-release or external-provider qualification.\",\n \"sources\": [\n \"crates/tui/tests/support/qa_harness\",\n \"crates/tui/tests/cucumber/launch_card_pty.rs\",\n \"web/lib/terminal-captures/manifest.json\",\n \"web/lib/terminal-captures/website-home-100x24.json\",\n \"web/scripts/render-terminal-capture.mjs\",\n \"web/public/codewhale-tui-0102-demo.png\"\n ]\n }\n}\n" + "sha256": "0885f0b74ecbf3c1381cc1d633a23041b38ab3ba96f45eda6da2d40d2707b1b9", + "content": "{\n \"schemaVersion\": 2,\n \"product\": {\n \"name\": \"Codewhale\",\n \"description\": \"Terminal coding agent for supported hosted and local models. One runtime on your machine. Rust, MIT.\",\n \"positioning\": \"Terminal-native, model/provider-neutral, local-first coding agent\",\n \"license\": \"MIT\",\n \"terminology\": {\n \"Fleet\": \"the user's model inventory: who is in the roster and which member is selected\",\n \"Workflow\": \"what order the work follows\",\n \"Lane\": \"one running Workflow instance\",\n \"Runtime\": \"where, how, and with what authority selected work executes\"\n },\n \"sources\": [\n \"Cargo.toml\",\n \"LICENSE\",\n \"README.md\",\n \"npm/codewhale/package.json\",\n \"docs/FLEET.md\"\n ]\n },\n \"sourceCandidate\": {\n \"version\": \"0.10.2\",\n \"providerCount\": 50,\n \"toolCount\": 79,\n \"sandboxBackends\": [\n \"seatbelt (macOS, when available)\",\n \"bubblewrap (Linux, default when installed and working)\"\n ],\n \"sources\": [\n \"Cargo.toml\",\n \"crates/tui/src/config.rs\",\n \"crates/tui/src/sandbox/mod.rs\",\n \"web/scripts/derive-facts.mjs\"\n ],\n \"providerCountDefinition\": \"Website descriptor identities in this source snapshot, excluding Custom and retired identities; includes protocol/plan variants. Not a count of distinct vendors, ProviderKind identities, live catalogs, or providers in any published release.\"\n },\n \"latestPublishedRelease\": {\n \"tag\": \"v0.10.1\",\n \"version\": \"0.10.1\",\n \"publishedAt\": \"2026-10-08T01:47:44Z\",\n \"url\": \"https://github.com/codewhale-hq/CodeWhale/releases/tag/v0.10.1\",\n \"sources\": [\n \"web/data/latest-published-release.json\"\n ]\n },\n \"install\": {\n \"recommended\": \"curl -fsSL https://codewhale.net/install.sh | sh\",\n \"binaries\": [\n \"codewhale\",\n \"codew\"\n ],\n \"channels\": {\n \"npm\": \"published releases only\",\n \"cargo\": \"published crates only\",\n \"prebuiltArchives\": \"published GitHub Releases only\",\n \"cnbMirror\": \"documented targets only\"\n },\n \"androidTermux\": {\n \"status\": \"preview\",\n \"npm\": \"preview\",\n \"requiresMatchingPublishedAssets\": true,\n \"sourceBuild\": true\n },\n \"sources\": [\n \"README.md\",\n \"npm/codewhale/package.json\",\n \"npm/codewhale/README.md\",\n \"npm/codewhale/scripts/artifacts.js\",\n \"docs/INSTALL.md\",\n \"docs/CNB_MIRROR.md\"\n ]\n },\n \"control\": {\n \"modes\": [\n \"Plan\",\n \"Work\",\n \"Operate\"\n ],\n \"permissionPostures\": [\n \"Ask\",\n \"Auto-Review\",\n \"Full Access\"\n ],\n \"shortcuts\": {\n \"mode\": {\n \"chord\": \"Tab\",\n \"when\": \"composer empty\"\n },\n \"permissionPosture\": {\n \"chord\": \"Shift+Tab\",\n \"when\": \"always (suppressed only under a non-Config modal)\"\n }\n },\n \"sources\": [\n \"docs/MODES.md\",\n \"docs/KEYBINDINGS.md\",\n \"crates/tui/src/settings.rs\"\n ]\n },\n \"toolSurface\": {\n \"defaultActive\": [\n \"read\",\n \"write\",\n \"edit\",\n \"file_search\",\n \"bash\",\n \"todo_write\",\n \"create_goal\",\n \"task_shell_wait\",\n \"load_skill\"\n\n ],\n \"schemas\": {\n \"read\": [\n \"path\",\n \"offset?\",\n \"limit?\"\n ],\n \"write\": [\n \"path\",\n \"content\"\n ],\n \"edit\": [\n \"path\",\n \"edits\"\n ],\n \"bash\": [\n \"command\",\n \"timeout?\"\n ]\n },\n \"deferred\": {\n \"Web\": [\n \"search\",\n \"fetch\",\n \"wait\"\n ]\n },\n \"compatibility\": {\n \"legacyAliases\": \"hidden-exact\",\n \"modelVisible\": false,\n \"toolSearchDiscoverable\": false\n },\n \"activationCache\": {\n \"maximumNames\": 8,\n \"maximumSchemaBytes\": 16384,\n \"scope\": \"per conversation; each subagent owns an independent policy-filtered cache\"\n },\n \"agentConcurrency\": {\n \"defaultConfigured\": 64,\n \"maximumConfigured\": 128,\n \"maximumAdmitted\": 1024\n },\n \"sources\": [\n \"docs/TOOL_SURFACE.md\",\n \"docs/RUNTIME_SIMPLIFICATION_DESIGN.md\",\n \"crates/tui/src/core/engine/tool_catalog.rs\",\n \"crates/tui/src/tools/registry.rs\",\n \"crates/tui/src/config/subagent_limits.rs\"\n ]\n },\n \"surfaces\": {\n \"availableInSourceCandidate\": [\n \"TUI\",\n \"codewhale exec\",\n \"Web client\",\n \"Runtime API\",\n \"MCP\",\n \"Fleet\"\n ],\n \"sources\": [\n \"docs/GUIDE.md\",\n \"docs/WEB.md\",\n \"docs/RUNTIME_API.md\",\n \"docs/MCP.md\",\n \"docs/FLEET.md\"\n ]\n },\n \"trust\": {\n \"localRuntime\": \"runtime, workspace state, and audit log stay on the user's machine\",\n \"hostedProviderBoundary\": \"the selected hosted provider receives turn context required for inference\",\n \"localInference\": \"a loopback local-model route can keep inference local\",\n \"telemetry\": \"Codewhale 0.10.2 counts anonymous usage by default and discloses it at first launch (policy notice version 5, naming Codewhale and PostHog as processors), while the earlier 0.9.11 release asked first; turning it off is a saved choice that later versions keep, an opt-out recorded under the earlier opt-in policy stays off, and showing the notice never records any acceptance on the user's behalf; Codewhale does not collect conversations, code, prompts, files, file/repo/branch names, model content, credentials, or per-turn/per-tool timelines; while on, sessions post aggregate counts and closed enums to the first-party endpoint https://telemetry.codewhale.net/v1/telemetry, whose storage has no IP, country, or geo column and a fixed three-month retention; optional PostHog forwarding requires separate operator configuration and verified IP-safe egress, and its retention is a separate project setting; telemetry_endpoint = \\\"\\\" writes only to a local dry-run file; no mandatory hosted relay\",\n \"account\": \"no account required for the local runtime\",\n \"plan\": \"Plan centrally refuses file mutation and shell execution; policy-allowed research may contact external services and local session state can still be persisted.\",\n \"sandbox\": \"Seatbelt is used on macOS when available; Linux bubblewrap is on by default once installed and verified working, and prefer_bwrap = false opts out; Windows currently reports no OS sandbox\",\n \"audit\": \"sensitive events append best-effort to $CODEWHALE_HOME/audit.log (default ~/.codewhale/audit.log); write failures are logged\",\n \"usage\": \"provider token and cache usage is shown locally when available\",\n \"sources\": [\n \"docs/PROVIDERS.md\",\n \"docs/MODES.md\",\n \"docs/SANDBOX.md\",\n \"docs/RUNTIME_API.md\",\n \"docs/WEB.md\",\n \"crates/tui/src/audit.rs\",\n \"web/app/[locale]/faq/page.tsx\",\n \"web/app/[locale]/install/page.tsx\"\n ]\n },\n \"platforms\": {\n \"prebuilt\": [\n \"Linux x64\",\n \"Linux arm64\",\n \"macOS x64\",\n \"macOS arm64\",\n \"Windows x64\",\n \"Windows arm64\"\n ],\n \"preview\": [\n \"Android/Termux arm64\"\n ],\n \"sources\": [\n \"docs/INSTALL.md\"\n ]\n },\n \"repository\": {\n \"canonical\": \"https://github.com/codewhale-hq/CodeWhale\",\n \"mirrors\": [\n \"https://cnb.cool/codewhale.net/codewhale\",\n \"https://npmmirror.com/package/codewhale\"\n ],\n \"creditSources\": [\n \"CHANGELOG.md\",\n \"docs/CONTRIBUTORS.md\",\n \"web/lib/release-credits.ts\"\n ],\n \"requiredCandidateCredits\": [\"@BX166\"]\n },\n \"screenshot\": {\n \"readme\": \"web/public/codewhale-tui-0102-demo.png\",\n \"website\": \"web/public/codewhale-tui-0102-demo.png\",\n \"sourceVersion\": \"0.10.2\",\n \"sourceCommit\": \"bd0c32567024d5bc3efac7d13331c68711de031e\",\n \"sourceSha256\": \"43ebde941cedb690612945b712e9565e92a441045c78646bf8841f3ad43ac369\",\n \"sourceDirty\": true,\n \"sourceDiffSha256\": \"499468bbf066901c9b229c912fe64d7fdcb672674483ce8dbe59db7c66110c45\",\n \"binarySha256\": \"6ac94bff89046b5de4386c4ed39da2b86b2c53233d3c652f09022c83095e14dd\",\n \"terminal\": \"real PTY cell capture; the product page renders native cells as selectable HTML text, and the README image is the same frame exported as SVG by web/scripts/render-terminal-capture.mjs and rasterized as PNG\",\n \"theme\": \"Underwater (default)\",\n \"capture\": \"Captured on 2026-10-08 from the development build of a dirty 0.10.2 candidate based on bd0c32567024d5bc3efac7d13331c68711de031e by the opt-in website_current_terminal_capture test using crates/tui/tests/support/qa_harness. Exact source diff and binary hashes are recorded here and in the capture manifest. A deterministic loopback provider drives the real Engine to edit a JSON exporter, run three passing local Python tests and delegate a source-grounded read-only review. Home, unsent composer, configured provider manager, help, Tasks and Fleet are actual application output with no retouched cells or fixture history. Sealed HOME and Full Access apply only to the isolated demo workspace. This is a local scripted demo, not published-release or external-provider qualification.\",\n \"sources\": [\n \"crates/tui/tests/support/qa_harness\",\n \"crates/tui/tests/cucumber/launch_card_pty.rs\",\n \"web/lib/terminal-captures/manifest.json\",\n \"web/lib/terminal-captures/website-home-100x24.json\",\n \"web/scripts/render-terminal-capture.mjs\",\n \"web/public/codewhale-tui-0102-demo.png\"\n ]\n }\n}\n" }, { "path": "docs/PREVIEW_REQUEST.md", @@ -357,13 +357,13 @@ }, { "path": "docs/MODES.md", - "sha256": "ea1e371421770a8582195f4c80c70fe9d067b3800ef143c7fea1fdcf444f0ecd", - "content": "# Modes and Permission Postures\n\n> 阅读简体中文版:[zh_hans/MODES.md](zh_hans/MODES.md)\n\nCodewhale has three related concepts:\n\n- **TUI mode**: what kind of visible interaction you're in (Plan/Work/Operate).\n- **Permission posture**: how aggressively the UI asks before executing tools.\n- **Workflow**: named steps that coordinate sub-agents, with dependencies and\n results, available in any TUI mode.\n\nModel selection is separate. `--model auto` and `/model auto` route each turn to\na concrete model and thinking level; they are not TUI modes and are not part of\nthe `Tab` cycle.\n\nWorkflow runs named steps through the same sub-agent runtime; Fleet manages\ntheir saved roles and model assignments. A workflow adds ordering, result\nhandoffs, gates and a progress view. The active mode and permission posture\nstill control what each step may execute.\n\nBefore assigning steps, query `agent(action=\"roster\")` for the saved Fleet\nmodels and roles. A plan child can select a listed model with `model`, or use\nthe saved `role`/`profile` assignment. An Exact Fleet fixes each member's route.\n\n## TUI Modes\n\nPress `Tab` to complete composer menus or cycle through the visible modes\nwhen the composer is empty: **Plan → Work → Operate → Plan**. `Tab` never sends\nor queues composer text; use `Enter` to send or queue it.\nPress `Shift+Tab` to cycle permission posture (Ask → Auto-Review → Full Access).\nEvery change of effective permission writes a `Permissions changed to …` line\nin the transcript.\nPress `Ctrl+T` to cycle reasoning effort.\nRun `/mode` to open the mode picker, or switch directly with `/mode work`,\n`/mode plan`, or `/mode operate`.\n\n- **Plan**: design-first prompting. The stable primitive names remain familiar, but the runtime centrally refuses file mutation and shell execution. Read-only inspection and policy-allowed research, including deferred Web search/fetch, remain available. When a Plan turn successfully finishes a nonempty response, including a prose-only plan, the TUI asks how to continue: Work (Ask), Work (Auto-Review), or keep planning. Work sends the exact approved response as a normal follow-up and preserves that turn's existing To-dos, or adds one pending plan item without replacing unrelated work.\n- **Work** (internally `agent`): ordinary multi-step execution. The first-turn toolbox includes `read`, `write`, `edit`, `file_search`, `bash`, `agent`, `workflow`, `todo_write`, and `load_skill`, plus `create_goal`, `get_goal`, `update_goal`, and `task_shell_wait` so goal and background-shell controls are available without discovery. The agent calls `create_goal` when a request is a durable objective; the host never infers a goal from wording, and the contract must not tell the model otherwise. Approval, sandbox, repository law, and managed policy decide what may execute.\n- **Operate**: Work at full strength, the \"ultra\" mode. Same tools and authority as Work; the difference is how they are used. Each substantive request becomes a goal (`create_goal`), the plan stays visible (`todo_write`), multi-part work runs through `workflow` and parallel `agent` workers, non-trivial changes get an independent reviewer before they are called done, long commands keep running in the background, and recurring or watch-type work is offered as an `automation` (created after approval). It keeps going until the goal is verified, blocked on you, or paused.\n\n`Act` and `/mode act` remain compatibility aliases for Work. Saved settings\nstill normalize to the internal value `agent`.\n\n### Tool availability by mode\n\n| Tool family | Plan | Work | Operate |\n|:---|:---:|:---:|:---:|\n| `read` and policy-allowed deferred research tools | yes | yes | yes |\n| `write` and `edit` | visible names; execution denied | approval- and policy-gated | same as Work |\n| `bash` | visible name; execution denied | approval- and policy-gated | same as Work; delegation is preferred when parallelism or isolation helps |\n| `agent` | yes, subject to child-depth authority | yes, subject to child-depth authority | yes, subject to child-depth authority |\n| Deferred native, MCP, and plugin tools | discoverable through `tool_search` when policy permits | same | same |\n| Paid or external-service tools | follows permission posture | follows permission posture | follows permission posture |\n| Access outside the workspace root | explicit trusted paths only | only through trusted paths or trust mode | same trusted-path/trust policy as Work; fleet profiles never widen it |\n\nOperate changes scheduling emphasis, not authority. It neither adds a\nmode-specific tool denial nor bypasses the active approval, sandbox, shell,\nask-rule, repository-law, or managed-policy boundary. Plan remains the\nmode-specific execution boundary for shell and write-capable tools; that\nauthority difference does not require a different primitive vocabulary.\n\n### Operate loop (one screen)\n\n```text\nUser message\n → small / chat / one-file? → parent does it (Work-equivalent tools)\n → multi-step work? → goal → named steps + dependencies + completion checks\n → Workflow phases → independent sub-agents in parallel\n → collect results → check evidence → hand off to the next phase\n → missing required result? → stop dependent work and repair the step\n → one independent task? → one direct sub-agent\n → parent integrates results and reports completed, blocked and next steps\n```\n\nLifecycle claims stay exact: dispatched ≠ settled ≠ verified.\n\n`allow_shell` controls whether `bash` can execute; it does not rename the tool\nor make mode the approval authority. Durable tasks and automation keep\nconservative omitted-field defaults and receive shell authority only when their\nsettings explicitly grant it. Stateful terminal/background controls are\nspecialized deferred tools rather than fields on the small foreground `bash`\nschema. Full Access changes the permission posture: the deterministic\nAuto-Review floor applies in the reviewing postures and is skipped under Full\nAccess, while repository-policy holds remain authoritative.\n\nAction-capable modes can discover the deferred `rlm` family through\n`tool_search`; its `open`, `eval`, `configure`, and `close` actions own persistent\nRLM sessions. The legacy split `rlm_*` spellings remain replay-only aliases.\nInside an RLM Python REPL, `sub_query_batch` fans out 1-16 cheap parallel child\ncalls pinned to `deepseek-v4-flash`.\n\nThe fast `deepseek-v4-flash` / thinking-off path is called Fin in the product\nlanguage. Fin is a seam for routing, summaries, cheap child calls, and\ncoordination work; it does not change approval behavior.\n\nThe orchestration controls remain available without taking over the starting\nscreen: `/auto` turns on Auto-Review so the agent just works, `/goal` keeps one\nobjective across turns, and `/workflow` prepares a repeatable ordered or\nfan-out workflow. They are directly callable and searchable through the full\ncommand palette, but are not pinned to the starter slash menu, idle welcome,\nfooter, or default Hotbar. A bare `/` instead opens the small task-oriented\nstarter set; use `/help` or the command palette for the complete inventory.\n\n`/goal ` sets a session objective with an optional token budget and\nkeeps active objectives visible as Work context. The agent may also create the\ngoal itself when a direct request describes a verifiable end state that will\ntake more than one turn (\"until the tests pass\", \"make X work end to end\"); it\nthen shows one receipt line and you can `/goal pause` or `/goal clear` it. Bare\n`/goal` shows progress (state, elapsed, continuations, and how to continue when\nno turn is running); with no goal and no conversation yet it prints usage.\n`/goal pause` stops goal continuation without changing the objective, `/goal\nresume` resumes and sends the objective back into the turn, `/goal complete`\nmarks it done, `/goal blocked` marks it blocked, and `/goal clear` removes it. Goal state does not change the active TUI mode,\npermission posture, or model route. This remains distinct from `--model auto`, which\nonly controls model and thinking selection.\n\nWorkflow builds on the same separation: a goal can ask the agent to keep\nworking, while Workflow supplies the repeatable workflow/progress surface for\nlarge fanout. In the UI, a Workflow run should be shown as an overlay on the\nmain screen, not as another mode beside Plan, Work, and Operate.\n\nApp-server clients can persist a thread-scoped goal with `thread/goal/set`, read\nit with `thread/goal/get`, and clear it with `thread/goal/clear`. That persisted\nrecord carries `active`, `paused`, `blocked`, `usage_limited`, `budget_limited`,\nor `complete` status plus token/time accounting fields for clients that need\nthread resume semantics.\n\n## Mode Persistence\n\nChoosing a mode interactively also sets the mode a fresh session starts in.\nTab/Shift+Tab cycling, the `Alt+A` / `Alt+P` / `Alt+Y` shortcuts, the hotbar's\nPlan/Work/Operate actions, and `/mode` all write `default_mode` to\n`~/.codewhale/settings.toml`, so switching to Operate survives a restart. The\nwrite happens off the event loop; if it fails, the TUI says so in a warning\ntoast rather than reverting silently on the next launch.\n\nMode, thinking level, and the model picker share one serialized writer, so the\nselection you made last is the one on disk — a burst of Tab presses cannot end\nup persisting whichever write happened to finish last — and a mode write never\nrolls back an unrelated key such as `default_model`.\n\nTwo paths deliberately do **not** rewrite the startup default: restoring a saved\nsession (which re-installs the mode that session was in) and a mode change\nrefused because a turn is in flight. The legacy `yolo` entry point installs Work\nplus Full Access, and `agent` is what it persists — `yolo` is a permission\nalias, never a startup mode.\n\nRe-selecting the mode you are already in is not a no-op. After a restored\nsession the live mode and `default_mode` routinely disagree, so choosing the\nlive mode again is how you make it durable; Codewhale confirms with a\n\"saved as startup default\" receipt rather than reporting \"already in that mode\".\n\nWhile a turn is running, every change to the live route is refused — mode,\nmodel, thinking level, and provider — no matter which surface you use. That\nnow includes the slash surfaces (`/mode`, `/model`, `/provider`,\n`/config `, `/config preset`), which are reachable mid-turn. Press\nEsc to interrupt first. The restart-only `default_mode` key is exempt, because\nit does not touch the running turn.\n\nCodewhale writes `settings.toml` under a lock that spans processes, and replaces\nthe file atomically, so a second Codewhale instance on the same home directory\ncannot lose your selection or read a half-written file. At exit, queued writes\nare flushed before the terminal is restored; anything that failed is printed on\nthe way out instead of disappearing with the alternate screen.\n\n## Compatibility Notes\n\n- Older settings files with `default_mode = \"normal\"` still load as `agent`; saving rewrites the normalized value.\n\n## Escape Key Behavior\n\n`Esc` is a cancel stack, not a mode switch.\n\n- Close slash menus or transient UI first.\n- Cancel the active request if a turn is running.\n- Discard a queued draft if the composer is empty.\n- Clear the current input if text is present.\n- Otherwise it is a no-op.\n\n## Permission Posture\n\nPermission posture controls tool approval and whether a turn may pause for a\nmissing user decision. It is one layer of the full\n[authorization order](AUTHORIZATION_ORDER.md), not a bypass for tool admission,\nrepository law, or sandbox enforcement. Cycle it with `Shift+Tab`, or edit it\nat runtime:\n\n```text\n/config\n# edit the approval_mode row to: suggest | auto | never\n```\n\nLegacy note: `/set approval_mode ...` was retired in favor of `/config`.\n\n- `suggest` (**Ask**, default): tool approvals may interrupt, and Codewhale asks\n when an unresolved user choice materially changes authority, cost, scope, or\n outcome.\n- `auto` (**Auto-Review**): reviews tool calls automatically. Deliberate user\n questions remain available in interactive sessions through `request_user_input`;\n a question parks the turn until answered, canceled, or its configured timeout\n expires. Headless `exec` withholds that tool because it has no responder.\n Tool safety holds remain separate from user questions. Two layers decide approvals. The\n **deterministic floor** (configured block rules plus the built-in safety\n floor) allows proven-safe calls and hard-blocks publish-like actions and\n destructive background/headless work; it is never model-reviewed. Fallback\n holds — calls the deterministic engine could not prove safe — escalate to a\n one-shot **model guardian** (v0.9.8) that returns risk, allow/deny, and a\n rationale. The guardian sees the exact held call and deterministic\n observations in separate JSON fields; conversation history, skill\n instructions, attachments, and expanded model context are excluded. It does\n not infer user intent or compute a generic user-intent score.\n High or critical risk cannot auto-run even if the model says allow. It has no\n tools, remembers no rules, and denies rather than truncates an oversized\n exact call. Exactly one reviewer request is made; incomplete or malformed\n output, timeout, cancellation, or provider failure fails closed. Repeated\n denials stop the turn: after three guardian denials in a row, or ten among\n the turn's last fifty reviews, the turn ends as interrupted with a status\n line, and calls still in that batch are refused without another review.\n Only explicit denials count (not timeouts or failures), an allowed review\n resets the run of three, the count starts fresh with each turn, and a child\n agent counts its own turns. Headless\n adapters use the deterministic-only tier. Repo-law\n holds that explicitly require a person block in Auto-Review rather than\n opening a hidden approval modal.\n\nThe LLM reviewer is closest to OpenAI Codex's experimental Auto-Review at\ncommit [`6fc6b9d6d2580d62622fc9884b5f5707f6505a5e`](https://github.com/openai/codex/tree/6fc6b9d6d2580d62622fc9884b5f5707f6505a5e).\nCodex's [guardian entry point](https://github.com/openai/codex/blob/6fc6b9d6d2580d62622fc9884b5f5707f6505a5e/codex-rs/core/src/guardian/mod.rs)\nreconstructs conversation context and runs a dedicated review session.\nCodewhale deliberately adopts only the exact-action structured decision,\n90-second deadline, fail-closed result, and per-turn denial circuit breaker. It\ndoes not copy Codex's transcript reconstruction, user-authorization score,\nreviewer tools, retries, persistent review session, or denial ledger.\n\nKimi Code at commit\n[`1414d4602898f406e540b23342cb18db23ff9efc`](https://github.com/MoonshotAI/kimi-code/tree/1414d4602898f406e540b23342cb18db23ff9efc)\nalso has no LLM reviewer. Its ordered\n[permission policy](https://github.com/MoonshotAI/kimi-code/blob/1414d4602898f406e540b23342cb18db23ff9efc/packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicyService.ts)\napplies explicit deny rules and then its\n[Auto policy](https://github.com/MoonshotAI/kimi-code/blob/1414d4602898f406e540b23342cb18db23ff9efc/packages/agent-core-v2/src/agent/permissionPolicy/policies/auto-mode-approve.ts)\nreturns `approve` directly. Codewhale uses the deterministic floor and guardian\ndescribed above, while keeping deliberate user questions available.\n\nThe sandbox and escalation baseline is grounded in DeepSeek Harness\n`0.1.0-rc.5` at\ncommit [`47f943859bef60e4160492346772ded9b24f765a`](https://github.com/deepseek-ai/deepseek-harness/tree/47f943859bef60e4160492346772ded9b24f765a):\nits [sandbox contract](https://github.com/deepseek-ai/deepseek-harness/blob/47f943859bef60e4160492346772ded9b24f765a/docs/subsystems/sandbox.md)\ndefines per-call `read-only`, `workspace-write`, and `danger-full-access`\nboundaries and forbids silent unconfined fallback; its\n[approval contract](https://github.com/deepseek-ai/deepseek-harness/blob/47f943859bef60e4160492346772ded9b24f765a/docs/subsystems/approval.md)\ngrants only `allowed-once` and fails closed on rejection, cancellation, or an\nunavailable answerer; and its\n[sandbox result contract](https://github.com/deepseek-ai/deepseek-harness/blob/47f943859bef60e4160492346772ded9b24f765a/packages/shell/bash-sandbox/README.md)\ntells the model to retry a denied command exactly once with the narrowest wider\nmode plus a justification. DeepSeek Harness does not add an LLM reviewer to\nthat path. Codewhale's autonomous posture adds only the single stateless\nguardian request described above; deterministic hard blocks remain\nnon-bypassable in the reviewing postures.\n- `bypass` (**Full Access**): ordinary tool calls do not show approval prompts,\n while deliberate user questions remain available. The deterministic\n Auto-Review floor is skipped — publish-like commands, destructive\n background/headless work, and the Windows npm-launcher platform hold do not\n strand a call the person already granted. Non-bypassable registered holds\n auto-approve instead of opening a contradictory modal. Repository-law and\n managed-policy holds fail closed as hard blocks instead of contradicting\n Full Access with an approval modal.\n- `never`: blocks any tool that is not considered safe/read-only; deliberate\n user questions remain available.\n\nThe effective posture and its question discipline are projected into every\nturn from the same runtime authority that gates tools. A mode/posture change is\ntherefore visible to the next turn. Untrusted runtime-generated input is\nnarrowed before metadata is built and cannot invent approval authority. An\nexplicit Full Access sub-agent handoff preserves the parent's standing posture\nso ordinary child work does not begin prompting again.\n\n### Children (sub-agents and fleet workers)\n\nChildren inherit the session posture faithfully rather than a bare\nauto-approve bit:\n\n- **Auto-Review**: a worker's held call goes through the same deterministic\n policy (proven-safe calls run; publish-like and destructive background work\n is hard-blocked) and, for holds it cannot prove safe, the same one-shot\n model guardian using the child's own session client. No prompt is ever\n opened for a child; an unavailable guardian denies, fail closed.\n- **Ask**: a call the role may delegate runs. A held call is raised as an\n approval prompt in the parent's UI (`agent::approval:`) when the\n host is an interactive TUI; the worker waits visibly (`waiting for user`)\n and the person's answer is routed back to it, whether the parent turn is\n idle or itself awaiting an approval. Hosts that cannot prompt deny with the\n reason.\n- **Full Access**: calls run, including destructive detached work — the\n Auto-Review floor is skipped exactly as it is for the parent turn. The\n execution envelope still applies and never widens.\n\nRole posture and the execution envelope are checked before and after this\ngate and never widen. Every decision a person did not make at a prompt is\nwritten to the audit log and to the child's transcript as a one-line note\n(`Auto-Review allowed 'bash' (low risk, model guardian): …`), visible when\nthe worker is focused.\n\n## Small-Screen Status Behavior\n\nWhen terminal height is constrained, the status area compacts first so header/chat/composer/footer remain visible:\n\n- Loading and queued status rows are budgeted by available height.\n- Queued previews collapse to compact summaries when full previews do not fit.\n- `/queue` workflows remain available; compact status only affects rendering density.\n\n## Workspace Boundary and Trust Mode\n\nBy default, file tools are restricted to the `--workspace` directory. Enable trust mode to allow file access outside the workspace:\n\n```text\n/trust on\n```\n\nBare `/trust` (like `/trust status`) only *reports* the current setting — it\ndoes not enable anything. Use `/trust off` to restrict access again.\n\nFull Access enables trust mode automatically.\n\n## MCP Behavior\n\nMCP tools are exposed as `mcp__` and use the same approval flow as\nbuilt-in tools. Read-only MCP helpers may auto-run in Ask and Auto-Review when\npolicy permits; MCP tools with possible side effects require approval. Full\nAccess does not bypass hard policy holds.\n\nA tool's own MCP annotations count only as far as their source is trusted. A\ntool from a reviewed, enabled plugin that declares `readOnlyHint: true` runs\nlike the built-in read helpers, with no prompt; the same claim from any other\nserver is ignored. A tool that declares `destructiveHint: true` never gets\nthat relaxation, even from a reviewed plugin, and its approval card says the\nserver marked it destructive. Full Access still runs it without a prompt, like\nany other tool that would ask.\n\nSee `MCP.md`.\n\n## Related CLI Flags\n\nRun `codewhale --help` for the canonical list. Common flags:\n\n- `-p, --prompt `: one-shot prompt mode (prints and exits)\n- `codewhale exec --auto --output-format stream-json `: run the tool-backed non-interactive agent and emit one JSON object per line for harnesses and backend wrappers. `--auto` enables Full Access by default (no Codewhale shell sandbox, trust mode on); add `--sandbox workspace-write` to keep the shell sandbox and file-tool workspace restrictions, with trust mode off. Any `--sandbox` other than `danger-full-access` (or the equivalent `sandbox_mode` in config) keeps trust mode off; `external-sandbox` relies on your own isolation. Exec runs that offer tools are saved in every output format. Exit codes: `0` on success, `1` for genuine task/agent failures, `75` (`EX_TEMPFAIL`) when the turn ended on a retryable infrastructure failure (provider/transport `network`/`timeout` after all in-session retries) so harnesses can tell a retryable infra exit apart from a task failure; the terminal stream `metadata` event's `error_category` carries the same classification\n- Put exec flags before the prompt. A prompt starting with `-` needs `--` first, for example `codewhale exec -- \"- explain this option\"`. A lone `-` is literal text. Known exec long flags (including `--flag=value`) after the prompt are rejected unless they follow an explicit `--`, which marks them as literal prompt text.\n- `codewhale exec --prompt-file ` / `cat prompt.txt | codewhale exec --prompt-file -`: read the prompt from a file or stdin instead of argv, for prompts past the OS per-argument limit (~128 KiB on Linux). Conflicts with a positional prompt, and a positional `-` is literal prompt text; `--prompt-file -` is refused with `--parent-death-watch`, which owns stdin\n- `codewhale exec --resume ` / `--session-id `: continue a saved session non-interactively\n- `codewhale exec --continue `: continue the most recent saved session for this workspace non-interactively\n- `codewhale fork ` / `codewhale fork --last`: copy a saved session into a new sibling session; forked sessions retain additive parent-session metadata and show that lineage in session listings\n- `--model `: when using the `codewhale` facade, forward a DeepSeek model override to the TUI\n- `--workspace `: workspace root for file tools\n- `-r, --resume `: resume a saved session\n- `-c, --continue`: resume the most recent session in this workspace\n- `--max-subagents `: clamp to `1..=128`\n- `--mouse-capture` / `--no-mouse-capture`: opt in or out of internal mouse scrolling, transcript selection, right-click context actions, and transcript scrollbar dragging. Mouse capture is enabled by default on non-Windows terminals and on Windows Terminal/ConEmu/Cmder so drag selection copies only transcript text, removes visual wrap-column line breaks from paragraphs, and stays scoped to the transcript pane; hold Shift while dragging or use `--no-mouse-capture` for raw terminal selection. It defaults off on legacy Windows console (CMD without `WT_SESSION` / `ConEmuPID`) and inside JetBrains JediTerm — PyCharm/IDEA/CLion/etc. — where the terminal advertises mouse support but forwards SGR mouse events as raw text (#878, #898). Use `--mouse-capture` to opt in anywhere it's defaulted off. Raw terminal selection may cross the right workbar and include visual wraps because the terminal, not the TUI, owns the selection.\n- `--profile `: select config profile\n- `--config `: config file path\n- `-v, --verbose`: verbose logging\n\n## Branching and Rollback\n\nCodewhale has three related but intentionally separate recovery paths:\n\n- `codewhale fork ` creates a new saved session from an existing saved\n conversation and records the source session id. This is the safe way to\n explore a different answer path without overwriting the original session.\n- Esc-Esc backtrack rewinds the live transcript to a previous user prompt and\n restores that prompt into the composer for editing. It does not change\n files; `/restore` rolls the folder back to a chosen restore point and needs\n `/trust on` or Full Access.\n- `/restore` and the `revert_turn` tool restore workspace files from side-git\n snapshots. `/restore list [N]` lists more snapshot options before choosing a\n rollback point. They do not rewrite conversation history.\n\nA Pi-style in-file tree browser is a larger UI/data-model project. v0.8.40\nships the bounded fork/backtrack primitives and explicit lineage metadata.\n" + "sha256": "02c1b42ecbf4030bdeca7c6dad252c5a34fa21d26349e034766d43379222d11c", + "content": "# Modes and Permission Postures\n\n> 阅读简体中文版:[zh_hans/MODES.md](zh_hans/MODES.md)\n\nCodewhale has three related concepts:\n\n- **TUI mode**: what kind of visible interaction you're in (Plan/Work/Operate).\n- **Permission posture**: how aggressively the UI asks before executing tools.\n- **Workflow**: named steps that coordinate sub-agents, with dependencies and\n results, available in any TUI mode.\n\nModel selection is separate. `--model auto` and `/model auto` route each turn to\na concrete model and thinking level; they are not TUI modes and are not part of\nthe `Tab` cycle.\n\nWorkflow runs named steps through the same sub-agent runtime; Fleet manages\ntheir saved roles and model assignments. A workflow adds ordering, result\nhandoffs, gates and a progress view. The active mode and permission posture\nstill control what each step may execute.\n\nBefore assigning steps, query `agent(action=\"roster\")` for the saved Fleet\nmodels and roles. A plan child can select a listed model with `model`, or use\nthe saved `role`/`profile` assignment. An Exact Fleet fixes each member's route.\n\n## TUI Modes\n\nPress `Tab` to complete composer menus or cycle through the visible modes\nwhen the composer is empty: **Plan → Work → Operate → Plan**. `Tab` never sends\nor queues composer text; use `Enter` to send or queue it.\nPress `Shift+Tab` to cycle permission posture (Ask → Auto-Review → Full Access).\nEvery change of effective permission writes a `Permissions changed to …` line\nin the transcript.\nPress `Ctrl+T` to cycle reasoning effort.\nRun `/mode` to open the mode picker, or switch directly with `/mode work`,\n`/mode plan`, or `/mode operate`.\n\n- **Plan**: design-first prompting. The stable primitive names remain familiar, but the runtime centrally refuses file mutation and shell execution. Read-only inspection and policy-allowed research, including deferred Web search/fetch, remain available. When a Plan turn successfully finishes a nonempty response, including a prose-only plan, the TUI asks how to continue: Work (Ask), Work (Auto-Review), or keep planning. Work sends the exact approved response as a normal follow-up and preserves that turn's existing To-dos, or adds one pending plan item without replacing unrelated work.\n- **Work** (internally `agent`): ordinary multi-step execution. The first-turn toolbox includes `read`, `write`, `edit`, `file_search`, `bash`, `todo_write`, `load_skill`, and `task_shell_wait`, plus `create_goal` and the synthetic `tool_search`; `agent`, `workflow`, `get_goal`, and `update_goal` load through discovery (`get_goal`/`update_goal` ride eager while a goal is active). The agent calls `create_goal` when a request is a durable objective; the host never infers a goal from wording, and the contract must not tell the model otherwise. Approval, sandbox, repository law, and managed policy decide what may execute.\n- **Operate**: Work at full strength, the \"ultra\" mode. Same tools and authority as Work; the difference is how they are used. Each substantive request becomes a goal (`create_goal`), the plan stays visible (`todo_write`), multi-part work runs through `workflow` and parallel `agent` workers, non-trivial changes get an independent reviewer before they are called done, long commands keep running in the background, and recurring or watch-type work is offered as an `automation` (created after approval). It keeps going until the goal is verified, blocked on you, or paused.\n\n`Act` and `/mode act` remain compatibility aliases for Work. Saved settings\nstill normalize to the internal value `agent`.\n\n### Tool availability by mode\n\n| Tool family | Plan | Work | Operate |\n|:---|:---:|:---:|:---:|\n| `read` and policy-allowed deferred research tools | yes | yes | yes |\n| `write` and `edit` | visible names; execution denied | approval- and policy-gated | same as Work |\n| `bash` | visible name; execution denied | approval- and policy-gated | same as Work; delegation is preferred when parallelism or isolation helps |\n| `agent` | yes, subject to child-depth authority | yes, subject to child-depth authority | yes, subject to child-depth authority |\n| Deferred native, MCP, and plugin tools | discoverable through `tool_search` when policy permits | same | same |\n| Paid or external-service tools | follows permission posture | follows permission posture | follows permission posture |\n| Access outside the workspace root | explicit trusted paths only | only through trusted paths or trust mode | same trusted-path/trust policy as Work; fleet profiles never widen it |\n\nOperate changes scheduling emphasis, not authority. It neither adds a\nmode-specific tool denial nor bypasses the active approval, sandbox, shell,\nask-rule, repository-law, or managed-policy boundary. Plan remains the\nmode-specific execution boundary for shell and write-capable tools; that\nauthority difference does not require a different primitive vocabulary.\n\n### Operate loop (one screen)\n\n```text\nUser message\n → small / chat / one-file? → parent does it (Work-equivalent tools)\n → multi-step work? → goal → named steps + dependencies + completion checks\n → Workflow phases → independent sub-agents in parallel\n → collect results → check evidence → hand off to the next phase\n → missing required result? → stop dependent work and repair the step\n → one independent task? → one direct sub-agent\n → parent integrates results and reports completed, blocked and next steps\n```\n\nLifecycle claims stay exact: dispatched ≠ settled ≠ verified.\n\n`allow_shell` controls whether `bash` can execute; it does not rename the tool\nor make mode the approval authority. Durable tasks and automation keep\nconservative omitted-field defaults and receive shell authority only when their\nsettings explicitly grant it. Stateful terminal/background controls are\nspecialized deferred tools rather than fields on the small foreground `bash`\nschema. Full Access changes the permission posture: the deterministic\nAuto-Review floor applies in the reviewing postures and is skipped under Full\nAccess, while repository-policy holds remain authoritative.\n\nAction-capable modes can discover the deferred `rlm` family through\n`tool_search`; its `open`, `eval`, `configure`, and `close` actions own persistent\nRLM sessions. The legacy split `rlm_*` spellings remain replay-only aliases.\nInside an RLM Python REPL, `sub_query_batch` fans out 1-16 cheap parallel child\ncalls pinned to `deepseek-v4-flash`.\n\nThe fast `deepseek-v4-flash` / thinking-off path is called Fin in the product\nlanguage. Fin is a seam for routing, summaries, cheap child calls, and\ncoordination work; it does not change approval behavior.\n\nThe orchestration controls remain available without taking over the starting\nscreen: `/auto` turns on Auto-Review so the agent just works, `/goal` keeps one\nobjective across turns, and `/workflow` prepares a repeatable ordered or\nfan-out workflow. They are directly callable and searchable through the full\ncommand palette, but are not pinned to the starter slash menu, idle welcome,\nfooter, or default Hotbar. A bare `/` instead opens the small task-oriented\nstarter set; use `/help` or the command palette for the complete inventory.\n\n`/goal ` sets a session objective with an optional token budget and\nkeeps active objectives visible as Work context. The agent may also create the\ngoal itself when a direct request describes a verifiable end state that will\ntake more than one turn (\"until the tests pass\", \"make X work end to end\"); it\nthen shows one receipt line and you can `/goal pause` or `/goal clear` it. Bare\n`/goal` shows progress (state, elapsed, continuations, and how to continue when\nno turn is running); with no goal and no conversation yet it prints usage.\n`/goal pause` stops goal continuation without changing the objective, `/goal\nresume` resumes and sends the objective back into the turn, `/goal complete`\nmarks it done, `/goal blocked` marks it blocked, and `/goal clear` removes it. Goal state does not change the active TUI mode,\npermission posture, or model route. This remains distinct from `--model auto`, which\nonly controls model and thinking selection.\n\nWorkflow builds on the same separation: a goal can ask the agent to keep\nworking, while Workflow supplies the repeatable workflow/progress surface for\nlarge fanout. In the UI, a Workflow run should be shown as an overlay on the\nmain screen, not as another mode beside Plan, Work, and Operate.\n\nApp-server clients can persist a thread-scoped goal with `thread/goal/set`, read\nit with `thread/goal/get`, and clear it with `thread/goal/clear`. That persisted\nrecord carries `active`, `paused`, `blocked`, `usage_limited`, `budget_limited`,\nor `complete` status plus token/time accounting fields for clients that need\nthread resume semantics.\n\n## Mode Persistence\n\nChoosing a mode interactively also sets the mode a fresh session starts in.\nTab/Shift+Tab cycling, the `Alt+A` / `Alt+P` / `Alt+Y` shortcuts, the hotbar's\nPlan/Work/Operate actions, and `/mode` all write `default_mode` to\n`~/.codewhale/settings.toml`, so switching to Operate survives a restart. The\nwrite happens off the event loop; if it fails, the TUI says so in a warning\ntoast rather than reverting silently on the next launch.\n\nMode, thinking level, and the model picker share one serialized writer, so the\nselection you made last is the one on disk — a burst of Tab presses cannot end\nup persisting whichever write happened to finish last — and a mode write never\nrolls back an unrelated key such as `default_model`.\n\nTwo paths deliberately do **not** rewrite the startup default: restoring a saved\nsession (which re-installs the mode that session was in) and a mode change\nrefused because a turn is in flight. The legacy `yolo` entry point installs Work\nplus Full Access, and `agent` is what it persists — `yolo` is a permission\nalias, never a startup mode.\n\nRe-selecting the mode you are already in is not a no-op. After a restored\nsession the live mode and `default_mode` routinely disagree, so choosing the\nlive mode again is how you make it durable; Codewhale confirms with a\n\"saved as startup default\" receipt rather than reporting \"already in that mode\".\n\nWhile a turn is running, every change to the live route is refused — mode,\nmodel, thinking level, and provider — no matter which surface you use. That\nnow includes the slash surfaces (`/mode`, `/model`, `/provider`,\n`/config `, `/config preset`), which are reachable mid-turn. Press\nEsc to interrupt first. The restart-only `default_mode` key is exempt, because\nit does not touch the running turn.\n\nCodewhale writes `settings.toml` under a lock that spans processes, and replaces\nthe file atomically, so a second Codewhale instance on the same home directory\ncannot lose your selection or read a half-written file. At exit, queued writes\nare flushed before the terminal is restored; anything that failed is printed on\nthe way out instead of disappearing with the alternate screen.\n\n## Compatibility Notes\n\n- Older settings files with `default_mode = \"normal\"` still load as `agent`; saving rewrites the normalized value.\n\n## Escape Key Behavior\n\n`Esc` is a cancel stack, not a mode switch.\n\n- Close slash menus or transient UI first.\n- Cancel the active request if a turn is running.\n- Discard a queued draft if the composer is empty.\n- Clear the current input if text is present.\n- Otherwise it is a no-op.\n\n## Permission Posture\n\nPermission posture controls tool approval and whether a turn may pause for a\nmissing user decision. It is one layer of the full\n[authorization order](AUTHORIZATION_ORDER.md), not a bypass for tool admission,\nrepository law, or sandbox enforcement. Cycle it with `Shift+Tab`, or edit it\nat runtime:\n\n```text\n/config\n# edit the approval_mode row to: suggest | auto | never\n```\n\nLegacy note: `/set approval_mode ...` was retired in favor of `/config`.\n\n- `suggest` (**Ask**, default): tool approvals may interrupt, and Codewhale asks\n when an unresolved user choice materially changes authority, cost, scope, or\n outcome.\n- `auto` (**Auto-Review**): reviews tool calls automatically. Deliberate user\n questions remain available in interactive sessions through `request_user_input`;\n a question parks the turn until answered, canceled, or its configured timeout\n expires. Headless `exec` withholds that tool because it has no responder.\n Tool safety holds remain separate from user questions. Two layers decide approvals. The\n **deterministic floor** (configured block rules plus the built-in safety\n floor) allows proven-safe calls and hard-blocks publish-like actions and\n destructive background/headless work; it is never model-reviewed. Fallback\n holds — calls the deterministic engine could not prove safe — escalate to a\n one-shot **model guardian** (v0.9.8) that returns risk, allow/deny, and a\n rationale. The guardian sees the exact held call and deterministic\n observations in separate JSON fields; conversation history, skill\n instructions, attachments, and expanded model context are excluded. It does\n not infer user intent or compute a generic user-intent score.\n High or critical risk cannot auto-run even if the model says allow. It has no\n tools, remembers no rules, and denies rather than truncates an oversized\n exact call. Exactly one reviewer request is made; incomplete or malformed\n output, timeout, cancellation, or provider failure fails closed. Repeated\n denials stop the turn: after three guardian denials in a row, or ten among\n the turn's last fifty reviews, the turn ends as interrupted with a status\n line, and calls still in that batch are refused without another review.\n Only explicit denials count (not timeouts or failures), an allowed review\n resets the run of three, the count starts fresh with each turn, and a child\n agent counts its own turns. Headless\n adapters use the deterministic-only tier. Repo-law\n holds that explicitly require a person block in Auto-Review rather than\n opening a hidden approval modal.\n\nThe LLM reviewer is closest to OpenAI Codex's experimental Auto-Review at\ncommit [`6fc6b9d6d2580d62622fc9884b5f5707f6505a5e`](https://github.com/openai/codex/tree/6fc6b9d6d2580d62622fc9884b5f5707f6505a5e).\nCodex's [guardian entry point](https://github.com/openai/codex/blob/6fc6b9d6d2580d62622fc9884b5f5707f6505a5e/codex-rs/core/src/guardian/mod.rs)\nreconstructs conversation context and runs a dedicated review session.\nCodewhale deliberately adopts only the exact-action structured decision,\n90-second deadline, fail-closed result, and per-turn denial circuit breaker. It\ndoes not copy Codex's transcript reconstruction, user-authorization score,\nreviewer tools, retries, persistent review session, or denial ledger.\n\nKimi Code at commit\n[`1414d4602898f406e540b23342cb18db23ff9efc`](https://github.com/MoonshotAI/kimi-code/tree/1414d4602898f406e540b23342cb18db23ff9efc)\nalso has no LLM reviewer. Its ordered\n[permission policy](https://github.com/MoonshotAI/kimi-code/blob/1414d4602898f406e540b23342cb18db23ff9efc/packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicyService.ts)\napplies explicit deny rules and then its\n[Auto policy](https://github.com/MoonshotAI/kimi-code/blob/1414d4602898f406e540b23342cb18db23ff9efc/packages/agent-core-v2/src/agent/permissionPolicy/policies/auto-mode-approve.ts)\nreturns `approve` directly. Codewhale uses the deterministic floor and guardian\ndescribed above, while keeping deliberate user questions available.\n\nThe sandbox and escalation baseline is grounded in DeepSeek Harness\n`0.1.0-rc.5` at\ncommit [`47f943859bef60e4160492346772ded9b24f765a`](https://github.com/deepseek-ai/deepseek-harness/tree/47f943859bef60e4160492346772ded9b24f765a):\nits [sandbox contract](https://github.com/deepseek-ai/deepseek-harness/blob/47f943859bef60e4160492346772ded9b24f765a/docs/subsystems/sandbox.md)\ndefines per-call `read-only`, `workspace-write`, and `danger-full-access`\nboundaries and forbids silent unconfined fallback; its\n[approval contract](https://github.com/deepseek-ai/deepseek-harness/blob/47f943859bef60e4160492346772ded9b24f765a/docs/subsystems/approval.md)\ngrants only `allowed-once` and fails closed on rejection, cancellation, or an\nunavailable answerer; and its\n[sandbox result contract](https://github.com/deepseek-ai/deepseek-harness/blob/47f943859bef60e4160492346772ded9b24f765a/packages/shell/bash-sandbox/README.md)\ntells the model to retry a denied command exactly once with the narrowest wider\nmode plus a justification. DeepSeek Harness does not add an LLM reviewer to\nthat path. Codewhale's autonomous posture adds only the single stateless\nguardian request described above; deterministic hard blocks remain\nnon-bypassable in the reviewing postures.\n- `bypass` (**Full Access**): ordinary tool calls do not show approval prompts,\n while deliberate user questions remain available. The deterministic\n Auto-Review floor is skipped — publish-like commands, destructive\n background/headless work, and the Windows npm-launcher platform hold do not\n strand a call the person already granted. Non-bypassable registered holds\n auto-approve instead of opening a contradictory modal. Repository-law and\n managed-policy holds fail closed as hard blocks instead of contradicting\n Full Access with an approval modal.\n- `never`: blocks any tool that is not considered safe/read-only; deliberate\n user questions remain available.\n\nThe effective posture and its question discipline are projected into every\nturn from the same runtime authority that gates tools. A mode/posture change is\ntherefore visible to the next turn. Untrusted runtime-generated input is\nnarrowed before metadata is built and cannot invent approval authority. An\nexplicit Full Access sub-agent handoff preserves the parent's standing posture\nso ordinary child work does not begin prompting again.\n\n### Children (sub-agents and fleet workers)\n\nChildren inherit the session posture faithfully rather than a bare\nauto-approve bit:\n\n- **Auto-Review**: a worker's held call goes through the same deterministic\n policy (proven-safe calls run; publish-like and destructive background work\n is hard-blocked) and, for holds it cannot prove safe, the same one-shot\n model guardian using the child's own session client. No prompt is ever\n opened for a child; an unavailable guardian denies, fail closed.\n- **Ask**: a call the role may delegate runs. A held call is raised as an\n approval prompt in the parent's UI (`agent::approval:`) when the\n host is an interactive TUI; the worker waits visibly (`waiting for user`)\n and the person's answer is routed back to it, whether the parent turn is\n idle or itself awaiting an approval. Hosts that cannot prompt deny with the\n reason.\n- **Full Access**: calls run, including destructive detached work — the\n Auto-Review floor is skipped exactly as it is for the parent turn. The\n execution envelope still applies and never widens.\n\nRole posture and the execution envelope are checked before and after this\ngate and never widen. Every decision a person did not make at a prompt is\nwritten to the audit log and to the child's transcript as a one-line note\n(`Auto-Review allowed 'bash' (low risk, model guardian): …`), visible when\nthe worker is focused.\n\n## Small-Screen Status Behavior\n\nWhen terminal height is constrained, the status area compacts first so header/chat/composer/footer remain visible:\n\n- Loading and queued status rows are budgeted by available height.\n- Queued previews collapse to compact summaries when full previews do not fit.\n- `/queue` workflows remain available; compact status only affects rendering density.\n\n## Workspace Boundary and Trust Mode\n\nBy default, file tools are restricted to the `--workspace` directory. Enable trust mode to allow file access outside the workspace:\n\n```text\n/trust on\n```\n\nBare `/trust` (like `/trust status`) only *reports* the current setting — it\ndoes not enable anything. Use `/trust off` to restrict access again.\n\nFull Access enables trust mode automatically.\n\n## MCP Behavior\n\nMCP tools are exposed as `mcp__` and use the same approval flow as\nbuilt-in tools. Read-only MCP helpers may auto-run in Ask and Auto-Review when\npolicy permits; MCP tools with possible side effects require approval. Full\nAccess does not bypass hard policy holds.\n\nA tool's own MCP annotations count only as far as their source is trusted. A\ntool from a reviewed, enabled plugin that declares `readOnlyHint: true` runs\nlike the built-in read helpers, with no prompt; the same claim from any other\nserver is ignored. A tool that declares `destructiveHint: true` never gets\nthat relaxation, even from a reviewed plugin, and its approval card says the\nserver marked it destructive. Full Access still runs it without a prompt, like\nany other tool that would ask.\n\nSee `MCP.md`.\n\n## Related CLI Flags\n\nRun `codewhale --help` for the canonical list. Common flags:\n\n- `-p, --prompt `: one-shot prompt mode (prints and exits)\n- `codewhale exec --auto --output-format stream-json `: run the tool-backed non-interactive agent and emit one JSON object per line for harnesses and backend wrappers. `--auto` enables Full Access by default (no Codewhale shell sandbox, trust mode on); add `--sandbox workspace-write` to keep the shell sandbox and file-tool workspace restrictions, with trust mode off. Any `--sandbox` other than `danger-full-access` (or the equivalent `sandbox_mode` in config) keeps trust mode off; `external-sandbox` relies on your own isolation. Exec runs that offer tools are saved in every output format. Exit codes: `0` on success, `1` for genuine task/agent failures, `75` (`EX_TEMPFAIL`) when the turn ended on a retryable infrastructure failure (provider/transport `network`/`timeout` after all in-session retries) so harnesses can tell a retryable infra exit apart from a task failure; the terminal stream `metadata` event's `error_category` carries the same classification\n- Put exec flags before the prompt. A prompt starting with `-` needs `--` first, for example `codewhale exec -- \"- explain this option\"`. A lone `-` is literal text. Known exec long flags (including `--flag=value`) after the prompt are rejected unless they follow an explicit `--`, which marks them as literal prompt text.\n- `codewhale exec --prompt-file ` / `cat prompt.txt | codewhale exec --prompt-file -`: read the prompt from a file or stdin instead of argv, for prompts past the OS per-argument limit (~128 KiB on Linux). Conflicts with a positional prompt, and a positional `-` is literal prompt text; `--prompt-file -` is refused with `--parent-death-watch`, which owns stdin\n- `codewhale exec --resume ` / `--session-id `: continue a saved session non-interactively\n- `codewhale exec --continue `: continue the most recent saved session for this workspace non-interactively\n- `codewhale fork ` / `codewhale fork --last`: copy a saved session into a new sibling session; forked sessions retain additive parent-session metadata and show that lineage in session listings\n- `--model `: when using the `codewhale` facade, forward a DeepSeek model override to the TUI\n- `--workspace `: workspace root for file tools\n- `-r, --resume `: resume a saved session\n- `-c, --continue`: resume the most recent session in this workspace\n- `--max-subagents `: clamp to `1..=128`\n- `--mouse-capture` / `--no-mouse-capture`: opt in or out of internal mouse scrolling, transcript selection, right-click context actions, and transcript scrollbar dragging. Mouse capture is enabled by default on non-Windows terminals and on Windows Terminal/ConEmu/Cmder so drag selection copies only transcript text, removes visual wrap-column line breaks from paragraphs, and stays scoped to the transcript pane; hold Shift while dragging or use `--no-mouse-capture` for raw terminal selection. It defaults off on legacy Windows console (CMD without `WT_SESSION` / `ConEmuPID`) and inside JetBrains JediTerm — PyCharm/IDEA/CLion/etc. — where the terminal advertises mouse support but forwards SGR mouse events as raw text (#878, #898). Use `--mouse-capture` to opt in anywhere it's defaulted off. Raw terminal selection may cross the right workbar and include visual wraps because the terminal, not the TUI, owns the selection.\n- `--profile `: select config profile\n- `--config `: config file path\n- `-v, --verbose`: verbose logging\n\n## Branching and Rollback\n\nCodewhale has three related but intentionally separate recovery paths:\n\n- `codewhale fork ` creates a new saved session from an existing saved\n conversation and records the source session id. This is the safe way to\n explore a different answer path without overwriting the original session.\n- Esc-Esc backtrack rewinds the live transcript to a previous user prompt and\n restores that prompt into the composer for editing. It does not change\n files; `/restore` rolls the folder back to a chosen restore point and needs\n `/trust on` or Full Access.\n- `/restore` and the `revert_turn` tool restore workspace files from side-git\n snapshots. `/restore list [N]` lists more snapshot options before choosing a\n rollback point. They do not rewrite conversation history.\n\nA Pi-style in-file tree browser is a larger UI/data-model project. v0.8.40\nships the bounded fork/backtrack primitives and explicit lineage metadata.\n" }, { "path": "docs/public-surface-facts.json", - "sha256": "9651f09b589b1979cc217b67afe8fbcd42a37e5c4c4bd51f02d0c949a4b5d583", - "content": "{\n \"schemaVersion\": 2,\n \"product\": {\n \"name\": \"Codewhale\",\n \"description\": \"Terminal coding agent for supported hosted and local models. One runtime on your machine. Rust, MIT.\",\n \"positioning\": \"Terminal-native, model/provider-neutral, local-first coding agent\",\n \"license\": \"MIT\",\n \"terminology\": {\n \"Fleet\": \"the user's model inventory: who is in the roster and which member is selected\",\n \"Workflow\": \"what order the work follows\",\n \"Lane\": \"one running Workflow instance\",\n \"Runtime\": \"where, how, and with what authority selected work executes\"\n },\n \"sources\": [\n \"Cargo.toml\",\n \"LICENSE\",\n \"README.md\",\n \"npm/codewhale/package.json\",\n \"docs/FLEET.md\"\n ]\n },\n \"sourceCandidate\": {\n \"version\": \"0.10.2\",\n \"providerCount\": 50,\n \"toolCount\": 79,\n \"sandboxBackends\": [\n \"seatbelt (macOS, when available)\",\n \"bubblewrap (Linux, default when installed and working)\"\n ],\n \"sources\": [\n \"Cargo.toml\",\n \"crates/tui/src/config.rs\",\n \"crates/tui/src/sandbox/mod.rs\",\n \"web/scripts/derive-facts.mjs\"\n ],\n \"providerCountDefinition\": \"Website descriptor identities in this source snapshot, excluding Custom and retired identities; includes protocol/plan variants. Not a count of distinct vendors, ProviderKind identities, live catalogs, or providers in any published release.\"\n },\n \"latestPublishedRelease\": {\n \"tag\": \"v0.10.1\",\n \"version\": \"0.10.1\",\n \"publishedAt\": \"2026-10-08T01:47:44Z\",\n \"url\": \"https://github.com/codewhale-hq/CodeWhale/releases/tag/v0.10.1\",\n \"sources\": [\n \"web/data/latest-published-release.json\"\n ]\n },\n \"install\": {\n \"recommended\": \"curl -fsSL https://codewhale.net/install.sh | sh\",\n \"binaries\": [\n \"codewhale\",\n \"codew\"\n ],\n \"channels\": {\n \"npm\": \"published releases only\",\n \"cargo\": \"published crates only\",\n \"prebuiltArchives\": \"published GitHub Releases only\",\n \"cnbMirror\": \"documented targets only\"\n },\n \"androidTermux\": {\n \"status\": \"preview\",\n \"npm\": \"preview\",\n \"requiresMatchingPublishedAssets\": true,\n \"sourceBuild\": true\n },\n \"sources\": [\n \"README.md\",\n \"npm/codewhale/package.json\",\n \"npm/codewhale/README.md\",\n \"npm/codewhale/scripts/artifacts.js\",\n \"docs/INSTALL.md\",\n \"docs/CNB_MIRROR.md\"\n ]\n },\n \"control\": {\n \"modes\": [\n \"Plan\",\n \"Work\",\n \"Operate\"\n ],\n \"permissionPostures\": [\n \"Ask\",\n \"Auto-Review\",\n \"Full Access\"\n ],\n \"shortcuts\": {\n \"mode\": {\n \"chord\": \"Tab\",\n \"when\": \"composer empty\"\n },\n \"permissionPosture\": {\n \"chord\": \"Shift+Tab\",\n \"when\": \"always (suppressed only under a non-Config modal)\"\n }\n },\n \"sources\": [\n \"docs/MODES.md\",\n \"docs/KEYBINDINGS.md\",\n \"crates/tui/src/settings.rs\"\n ]\n },\n \"toolSurface\": {\n \"defaultActive\": [\n \"read\",\n \"write\",\n \"edit\",\n \"file_search\",\n \"bash\",\n \"agent\",\n \"workflow\",\n \"todo_write\",\n \"create_goal\",\n \"get_goal\",\n \"update_goal\",\n \"task_shell_wait\",\n \"load_skill\"\n ],\n \"schemas\": {\n \"read\": [\n \"path\",\n \"offset?\",\n \"limit?\"\n ],\n \"write\": [\n \"path\",\n \"content\"\n ],\n \"edit\": [\n \"path\",\n \"edits\"\n ],\n \"bash\": [\n \"command\",\n \"timeout?\"\n ]\n },\n \"deferred\": {\n \"Web\": [\n \"search\",\n \"fetch\",\n \"wait\"\n ]\n },\n \"compatibility\": {\n \"legacyAliases\": \"hidden-exact\",\n \"modelVisible\": false,\n \"toolSearchDiscoverable\": false\n },\n \"activationCache\": {\n \"maximumNames\": 8,\n \"maximumSchemaBytes\": 16384,\n \"scope\": \"per conversation; each subagent owns an independent policy-filtered cache\"\n },\n \"agentConcurrency\": {\n \"defaultConfigured\": 64,\n \"maximumConfigured\": 128,\n \"maximumAdmitted\": 1024\n },\n \"sources\": [\n \"docs/TOOL_SURFACE.md\",\n \"docs/RUNTIME_SIMPLIFICATION_DESIGN.md\",\n \"crates/tui/src/core/engine/tool_catalog.rs\",\n \"crates/tui/src/tools/registry.rs\",\n \"crates/tui/src/config/subagent_limits.rs\"\n ]\n },\n \"surfaces\": {\n \"availableInSourceCandidate\": [\n \"TUI\",\n \"codewhale exec\",\n \"Web client\",\n \"Runtime API\",\n \"MCP\",\n \"Fleet\"\n ],\n \"sources\": [\n \"docs/GUIDE.md\",\n \"docs/WEB.md\",\n \"docs/RUNTIME_API.md\",\n \"docs/MCP.md\",\n \"docs/FLEET.md\"\n ]\n },\n \"trust\": {\n \"localRuntime\": \"runtime, workspace state, and audit log stay on the user's machine\",\n \"hostedProviderBoundary\": \"the selected hosted provider receives turn context required for inference\",\n \"localInference\": \"a loopback local-model route can keep inference local\",\n \"telemetry\": \"Codewhale 0.10.2 counts anonymous usage by default and discloses it at first launch (policy notice version 5, naming Codewhale and PostHog as processors), while the earlier 0.9.11 release asked first; turning it off is a saved choice that later versions keep, an opt-out recorded under the earlier opt-in policy stays off, and showing the notice never records any acceptance on the user's behalf; Codewhale does not collect conversations, code, prompts, files, file/repo/branch names, model content, credentials, or per-turn/per-tool timelines; while on, sessions post aggregate counts and closed enums to the first-party endpoint https://telemetry.codewhale.net/v1/telemetry, whose storage has no IP, country, or geo column and a fixed three-month retention; optional PostHog forwarding requires separate operator configuration and verified IP-safe egress, and its retention is a separate project setting; telemetry_endpoint = \\\"\\\" writes only to a local dry-run file; no mandatory hosted relay\",\n \"account\": \"no account required for the local runtime\",\n \"plan\": \"Plan centrally refuses file mutation and shell execution; policy-allowed research may contact external services and local session state can still be persisted.\",\n \"sandbox\": \"Seatbelt is used on macOS when available; Linux bubblewrap is on by default once installed and verified working, and prefer_bwrap = false opts out; Windows currently reports no OS sandbox\",\n \"audit\": \"sensitive events append best-effort to $CODEWHALE_HOME/audit.log (default ~/.codewhale/audit.log); write failures are logged\",\n \"usage\": \"provider token and cache usage is shown locally when available\",\n \"sources\": [\n \"docs/PROVIDERS.md\",\n \"docs/MODES.md\",\n \"docs/SANDBOX.md\",\n \"docs/RUNTIME_API.md\",\n \"docs/WEB.md\",\n \"crates/tui/src/audit.rs\",\n \"web/app/[locale]/faq/page.tsx\",\n \"web/app/[locale]/install/page.tsx\"\n ]\n },\n \"platforms\": {\n \"prebuilt\": [\n \"Linux x64\",\n \"Linux arm64\",\n \"macOS x64\",\n \"macOS arm64\",\n \"Windows x64\",\n \"Windows arm64\"\n ],\n \"preview\": [\n \"Android/Termux arm64\"\n ],\n \"sources\": [\n \"docs/INSTALL.md\"\n ]\n },\n \"repository\": {\n \"canonical\": \"https://github.com/codewhale-hq/CodeWhale\",\n \"mirrors\": [\n \"https://cnb.cool/codewhale.net/codewhale\",\n \"https://npmmirror.com/package/codewhale\"\n ],\n \"creditSources\": [\n \"CHANGELOG.md\",\n \"docs/CONTRIBUTORS.md\",\n \"web/lib/release-credits.ts\"\n ],\n \"requiredCandidateCredits\": [\"@BX166\"]\n },\n \"screenshot\": {\n \"readme\": \"web/public/codewhale-tui-0102-demo.png\",\n \"website\": \"web/public/codewhale-tui-0102-demo.png\",\n \"sourceVersion\": \"0.10.2\",\n \"sourceCommit\": \"bd0c32567024d5bc3efac7d13331c68711de031e\",\n \"sourceSha256\": \"43ebde941cedb690612945b712e9565e92a441045c78646bf8841f3ad43ac369\",\n \"sourceDirty\": true,\n \"sourceDiffSha256\": \"499468bbf066901c9b229c912fe64d7fdcb672674483ce8dbe59db7c66110c45\",\n \"binarySha256\": \"6ac94bff89046b5de4386c4ed39da2b86b2c53233d3c652f09022c83095e14dd\",\n \"terminal\": \"real PTY cell capture; the product page renders native cells as selectable HTML text, and the README image is the same frame exported as SVG by web/scripts/render-terminal-capture.mjs and rasterized as PNG\",\n \"theme\": \"Underwater (default)\",\n \"capture\": \"Captured on 2026-10-08 from the development build of a dirty 0.10.2 candidate based on bd0c32567024d5bc3efac7d13331c68711de031e by the opt-in website_current_terminal_capture test using crates/tui/tests/support/qa_harness. Exact source diff and binary hashes are recorded here and in the capture manifest. A deterministic loopback provider drives the real Engine to edit a JSON exporter, run three passing local Python tests and delegate a source-grounded read-only review. Home, unsent composer, configured provider manager, help, Tasks and Fleet are actual application output with no retouched cells or fixture history. Sealed HOME and Full Access apply only to the isolated demo workspace. This is a local scripted demo, not published-release or external-provider qualification.\",\n \"sources\": [\n \"crates/tui/tests/support/qa_harness\",\n \"crates/tui/tests/cucumber/launch_card_pty.rs\",\n \"web/lib/terminal-captures/manifest.json\",\n \"web/lib/terminal-captures/website-home-100x24.json\",\n \"web/scripts/render-terminal-capture.mjs\",\n \"web/public/codewhale-tui-0102-demo.png\"\n ]\n }\n}\n" + "sha256": "0885f0b74ecbf3c1381cc1d633a23041b38ab3ba96f45eda6da2d40d2707b1b9", + "content": "{\n \"schemaVersion\": 2,\n \"product\": {\n \"name\": \"Codewhale\",\n \"description\": \"Terminal coding agent for supported hosted and local models. One runtime on your machine. Rust, MIT.\",\n \"positioning\": \"Terminal-native, model/provider-neutral, local-first coding agent\",\n \"license\": \"MIT\",\n \"terminology\": {\n \"Fleet\": \"the user's model inventory: who is in the roster and which member is selected\",\n \"Workflow\": \"what order the work follows\",\n \"Lane\": \"one running Workflow instance\",\n \"Runtime\": \"where, how, and with what authority selected work executes\"\n },\n \"sources\": [\n \"Cargo.toml\",\n \"LICENSE\",\n \"README.md\",\n \"npm/codewhale/package.json\",\n \"docs/FLEET.md\"\n ]\n },\n \"sourceCandidate\": {\n \"version\": \"0.10.2\",\n \"providerCount\": 50,\n \"toolCount\": 79,\n \"sandboxBackends\": [\n \"seatbelt (macOS, when available)\",\n \"bubblewrap (Linux, default when installed and working)\"\n ],\n \"sources\": [\n \"Cargo.toml\",\n \"crates/tui/src/config.rs\",\n \"crates/tui/src/sandbox/mod.rs\",\n \"web/scripts/derive-facts.mjs\"\n ],\n \"providerCountDefinition\": \"Website descriptor identities in this source snapshot, excluding Custom and retired identities; includes protocol/plan variants. Not a count of distinct vendors, ProviderKind identities, live catalogs, or providers in any published release.\"\n },\n \"latestPublishedRelease\": {\n \"tag\": \"v0.10.1\",\n \"version\": \"0.10.1\",\n \"publishedAt\": \"2026-10-08T01:47:44Z\",\n \"url\": \"https://github.com/codewhale-hq/CodeWhale/releases/tag/v0.10.1\",\n \"sources\": [\n \"web/data/latest-published-release.json\"\n ]\n },\n \"install\": {\n \"recommended\": \"curl -fsSL https://codewhale.net/install.sh | sh\",\n \"binaries\": [\n \"codewhale\",\n \"codew\"\n ],\n \"channels\": {\n \"npm\": \"published releases only\",\n \"cargo\": \"published crates only\",\n \"prebuiltArchives\": \"published GitHub Releases only\",\n \"cnbMirror\": \"documented targets only\"\n },\n \"androidTermux\": {\n \"status\": \"preview\",\n \"npm\": \"preview\",\n \"requiresMatchingPublishedAssets\": true,\n \"sourceBuild\": true\n },\n \"sources\": [\n \"README.md\",\n \"npm/codewhale/package.json\",\n \"npm/codewhale/README.md\",\n \"npm/codewhale/scripts/artifacts.js\",\n \"docs/INSTALL.md\",\n \"docs/CNB_MIRROR.md\"\n ]\n },\n \"control\": {\n \"modes\": [\n \"Plan\",\n \"Work\",\n \"Operate\"\n ],\n \"permissionPostures\": [\n \"Ask\",\n \"Auto-Review\",\n \"Full Access\"\n ],\n \"shortcuts\": {\n \"mode\": {\n \"chord\": \"Tab\",\n \"when\": \"composer empty\"\n },\n \"permissionPosture\": {\n \"chord\": \"Shift+Tab\",\n \"when\": \"always (suppressed only under a non-Config modal)\"\n }\n },\n \"sources\": [\n \"docs/MODES.md\",\n \"docs/KEYBINDINGS.md\",\n \"crates/tui/src/settings.rs\"\n ]\n },\n \"toolSurface\": {\n \"defaultActive\": [\n \"read\",\n \"write\",\n \"edit\",\n \"file_search\",\n \"bash\",\n \"todo_write\",\n \"create_goal\",\n \"task_shell_wait\",\n \"load_skill\"\n\n ],\n \"schemas\": {\n \"read\": [\n \"path\",\n \"offset?\",\n \"limit?\"\n ],\n \"write\": [\n \"path\",\n \"content\"\n ],\n \"edit\": [\n \"path\",\n \"edits\"\n ],\n \"bash\": [\n \"command\",\n \"timeout?\"\n ]\n },\n \"deferred\": {\n \"Web\": [\n \"search\",\n \"fetch\",\n \"wait\"\n ]\n },\n \"compatibility\": {\n \"legacyAliases\": \"hidden-exact\",\n \"modelVisible\": false,\n \"toolSearchDiscoverable\": false\n },\n \"activationCache\": {\n \"maximumNames\": 8,\n \"maximumSchemaBytes\": 16384,\n \"scope\": \"per conversation; each subagent owns an independent policy-filtered cache\"\n },\n \"agentConcurrency\": {\n \"defaultConfigured\": 64,\n \"maximumConfigured\": 128,\n \"maximumAdmitted\": 1024\n },\n \"sources\": [\n \"docs/TOOL_SURFACE.md\",\n \"docs/RUNTIME_SIMPLIFICATION_DESIGN.md\",\n \"crates/tui/src/core/engine/tool_catalog.rs\",\n \"crates/tui/src/tools/registry.rs\",\n \"crates/tui/src/config/subagent_limits.rs\"\n ]\n },\n \"surfaces\": {\n \"availableInSourceCandidate\": [\n \"TUI\",\n \"codewhale exec\",\n \"Web client\",\n \"Runtime API\",\n \"MCP\",\n \"Fleet\"\n ],\n \"sources\": [\n \"docs/GUIDE.md\",\n \"docs/WEB.md\",\n \"docs/RUNTIME_API.md\",\n \"docs/MCP.md\",\n \"docs/FLEET.md\"\n ]\n },\n \"trust\": {\n \"localRuntime\": \"runtime, workspace state, and audit log stay on the user's machine\",\n \"hostedProviderBoundary\": \"the selected hosted provider receives turn context required for inference\",\n \"localInference\": \"a loopback local-model route can keep inference local\",\n \"telemetry\": \"Codewhale 0.10.2 counts anonymous usage by default and discloses it at first launch (policy notice version 5, naming Codewhale and PostHog as processors), while the earlier 0.9.11 release asked first; turning it off is a saved choice that later versions keep, an opt-out recorded under the earlier opt-in policy stays off, and showing the notice never records any acceptance on the user's behalf; Codewhale does not collect conversations, code, prompts, files, file/repo/branch names, model content, credentials, or per-turn/per-tool timelines; while on, sessions post aggregate counts and closed enums to the first-party endpoint https://telemetry.codewhale.net/v1/telemetry, whose storage has no IP, country, or geo column and a fixed three-month retention; optional PostHog forwarding requires separate operator configuration and verified IP-safe egress, and its retention is a separate project setting; telemetry_endpoint = \\\"\\\" writes only to a local dry-run file; no mandatory hosted relay\",\n \"account\": \"no account required for the local runtime\",\n \"plan\": \"Plan centrally refuses file mutation and shell execution; policy-allowed research may contact external services and local session state can still be persisted.\",\n \"sandbox\": \"Seatbelt is used on macOS when available; Linux bubblewrap is on by default once installed and verified working, and prefer_bwrap = false opts out; Windows currently reports no OS sandbox\",\n \"audit\": \"sensitive events append best-effort to $CODEWHALE_HOME/audit.log (default ~/.codewhale/audit.log); write failures are logged\",\n \"usage\": \"provider token and cache usage is shown locally when available\",\n \"sources\": [\n \"docs/PROVIDERS.md\",\n \"docs/MODES.md\",\n \"docs/SANDBOX.md\",\n \"docs/RUNTIME_API.md\",\n \"docs/WEB.md\",\n \"crates/tui/src/audit.rs\",\n \"web/app/[locale]/faq/page.tsx\",\n \"web/app/[locale]/install/page.tsx\"\n ]\n },\n \"platforms\": {\n \"prebuilt\": [\n \"Linux x64\",\n \"Linux arm64\",\n \"macOS x64\",\n \"macOS arm64\",\n \"Windows x64\",\n \"Windows arm64\"\n ],\n \"preview\": [\n \"Android/Termux arm64\"\n ],\n \"sources\": [\n \"docs/INSTALL.md\"\n ]\n },\n \"repository\": {\n \"canonical\": \"https://github.com/codewhale-hq/CodeWhale\",\n \"mirrors\": [\n \"https://cnb.cool/codewhale.net/codewhale\",\n \"https://npmmirror.com/package/codewhale\"\n ],\n \"creditSources\": [\n \"CHANGELOG.md\",\n \"docs/CONTRIBUTORS.md\",\n \"web/lib/release-credits.ts\"\n ],\n \"requiredCandidateCredits\": [\"@BX166\"]\n },\n \"screenshot\": {\n \"readme\": \"web/public/codewhale-tui-0102-demo.png\",\n \"website\": \"web/public/codewhale-tui-0102-demo.png\",\n \"sourceVersion\": \"0.10.2\",\n \"sourceCommit\": \"bd0c32567024d5bc3efac7d13331c68711de031e\",\n \"sourceSha256\": \"43ebde941cedb690612945b712e9565e92a441045c78646bf8841f3ad43ac369\",\n \"sourceDirty\": true,\n \"sourceDiffSha256\": \"499468bbf066901c9b229c912fe64d7fdcb672674483ce8dbe59db7c66110c45\",\n \"binarySha256\": \"6ac94bff89046b5de4386c4ed39da2b86b2c53233d3c652f09022c83095e14dd\",\n \"terminal\": \"real PTY cell capture; the product page renders native cells as selectable HTML text, and the README image is the same frame exported as SVG by web/scripts/render-terminal-capture.mjs and rasterized as PNG\",\n \"theme\": \"Underwater (default)\",\n \"capture\": \"Captured on 2026-10-08 from the development build of a dirty 0.10.2 candidate based on bd0c32567024d5bc3efac7d13331c68711de031e by the opt-in website_current_terminal_capture test using crates/tui/tests/support/qa_harness. Exact source diff and binary hashes are recorded here and in the capture manifest. A deterministic loopback provider drives the real Engine to edit a JSON exporter, run three passing local Python tests and delegate a source-grounded read-only review. Home, unsent composer, configured provider manager, help, Tasks and Fleet are actual application output with no retouched cells or fixture history. Sealed HOME and Full Access apply only to the isolated demo workspace. This is a local scripted demo, not published-release or external-provider qualification.\",\n \"sources\": [\n \"crates/tui/tests/support/qa_harness\",\n \"crates/tui/tests/cucumber/launch_card_pty.rs\",\n \"web/lib/terminal-captures/manifest.json\",\n \"web/lib/terminal-captures/website-home-100x24.json\",\n \"web/scripts/render-terminal-capture.mjs\",\n \"web/public/codewhale-tui-0102-demo.png\"\n ]\n }\n}\n" } ] }, @@ -384,8 +384,8 @@ "sources": [ { "path": "docs/TOOL_SURFACE.md", - "sha256": "89f7a70003d94c9b7e9139355e9c68c57c02e15a5513ce03afcfe67f3e34d16a", - "content": "# Tool surface\n\n> 阅读简体中文版:[zh_hans/TOOL_SURFACE.md](zh_hans/TOOL_SURFACE.md)。\n\nThis document describes the current model-facing tool contract. The v0.9.1\ncutover that produced it is recorded in `docs/RUNTIME_SIMPLIFICATION_DESIGN.md`;\nread the workspace version from `Cargo.toml`, not from this line. The registry\nremains larger than the first-turn catalog so\nsaved transcripts can replay and uncommon capabilities can be loaded on demand.\nThe model should learn one canonical name for each common operation.\n\nImplementation sources:\n\n- `crates/tui/src/core/engine/tool_catalog.rs` owns the eager/deferred catalog.\n- `crates/tui/src/tools/registry.rs` registers canonical tools and hidden aliases.\n- `crates/tui/src/tools/{file,file_tool,shell}.rs` own the small foreground\n primitive behavior and schemas; the other native tools remain searchable.\n- `docs/RUNTIME_SIMPLIFICATION_DESIGN.md` records the v0.9.1 cutover and receipt.\n\n## Default-active contract\n\nNew turns start with thirteen eager native names plus synthetic `tool_search`:\n\n1. `read`\n2. `write`\n3. `edit`\n4. `file_search`\n5. `bash`\n6. `agent`\n7. `workflow`\n8. `todo_write`\n9. `create_goal`\n10. `get_goal`\n11. `update_goal`\n12. `task_shell_wait`\n13. `load_skill`\n14. `tool_search` (synthetic, always active)\n\nThe thirteen native names are `DEFAULT_ACTIVE_NATIVE_TOOLS` in\n`crates/tui/src/core/engine/tool_catalog.rs`, pinned by\n`default_active_contract_keeps_discovery_and_core_tools_eager`. An authority\nboundary may remove `agent` at the maximum child depth, and Plan registers only\nthe foreground shell surface (so no `task_shell_wait`), but route size alone\nmust not change this core vocabulary.\n\nThe direct schemas deliberately stay small:\n\n| Tool | Input | Purpose |\n|---|---|---|\n| `read` | `path`, optional `offset`, optional `limit` | Read a bounded file window with explicit continuation or truncation notices. |\n| `write` | `path`, `content` | Create or replace a file. |\n| `edit` | `path`, `edits` | Apply one or more unambiguous text replacements against one original snapshot. |\n| `file_search` | `query`, optional `path`, `limit`, `extensions`, `exclude` | Find files by name, path fragment or glob and return bounded, ranked matches. Respects ignore files; `exclude` replaces default exclusions. |\n| `bash` | `command`, optional `timeout`, optional `description` | Run one cancellable foreground shell command and return a bounded tail. The optional description is display-only. |\n| `agent` | delegated task and optional scope/context controls | Start or inspect focused child work. |\n| `workflow` | plan/script/source_path plus run controls | Coordinate multi-agent phases with dependencies and completion checks. |\n| `todo_write` | complete replacement list of `{content, status}` items | Keep optional, agent-owned progress notes for genuinely multi-step work. |\n| `create_goal` | objective plus optional budget | Start the session goal the turn works toward. |\n| `get_goal` | none | Read the active goal and its progress. |\n| `update_goal` | terminal status | Mark the goal complete or blocked. |\n| `task_shell_wait` | `task_id`, optional `wait`, `timeout_ms`, `cancel`, `gate` | Poll or stop a background shell task. Completion still arrives as a runtime event, so use it for early output, true barriers, or gate evidence. |\n| `load_skill` | optional `name`, `query` | Load a named skill's `SKILL.md` body and companion-file list; omit `name` or use `name=\"list\"` to list eligible skills, or use `query` to search names and descriptions. |\n| `tool_search` | `query`, optional matching controls | Discover policy-allowed deferred tools and add selected schemas to this conversation's toolbox. |\n\nMode is an authority decision, not a synonym system. Plan, Work, and Operate\nuse the same primitive identities. Plan centrally refuses `write`, `edit`, and\n`bash`; Work and Operate still pass those calls through approval, sandbox,\ntrusted-path, repository-law, and managed-policy gates. Full Access changes\nordinary approval behavior but does not bypass hard safety or repository law.\n\n`update_plan` remains registered only for saved-artifact compatibility and is\nnot model-visible. `tasks`, `Git`, `Run`, `Web`, `remember`, and other\nspecialized capabilities are searchable rather than first-turn ceremony.\n\nOn the TUI's default (calm) shell card the Bash description is the headline,\nshown as written apart from stripped terminal escapes, collapsed whitespace and\na length bound, and the command sits on its own `$` row directly under it in\nevery state and at every width, including automatic runs. A card without a\ndescription leads with the `$` command instead. The labeled card\n(`show_tool_details = true` with `calm_mode = false`) keeps the command in its\nheader and the description as a muted `note:` row. The description never\nreplaces the command and is excluded from execution, exact approval keys and\nAuto-Review input. The original description remains in the recorded tool input\nand exports. Approval prompts, `/jobs`, the activity inspector, ACP and other\nRuntime clients keep showing the command; this change adds no description row\nto those surfaces.\n\n## Local code execution\n\n`code_execution` (Python) and `js_execution` (Node.js) use the same\npermission-aware local launcher as workspace task gates and test runs. Ordinary\napproval retains the effective session policy. To retry a denied exact call\nwith wider permissions, supply `sandbox_permissions` (`workspace-write` or\n`danger-full-access`) and a nonempty `justification`; Ask mode requires explicit\none-shot user approval. The approved policy affects only that call.\n\nBoth tools read source from stdin, return stdout/stderr/return_code, and keep\nthe existing timeout and process-tree cleanup. They are not persistent REPLs;\ntracebacks refer to stdin rather than a temporary script path. The separate\nRLM/REPL kernel is not changed by this launcher integration.\n\nPlatform/backend limits of the shared launcher still apply: no available local\nwrapper means workspace-write remains unenforced, while read-only is refused.\nAn external sandbox session cannot use these local interpreter tools and must\nuse its shell execution path. See [sandbox limits](SANDBOX.md).\n\n## Deferred and dynamic tools\n\n`Web` is conditional and deferred. It is discoverable through `tool_search`\nonly when the active policy and runtime backend permit it. Read-only\nchildren retain its read-only search/fetch evidence path; read-only authority\ndoes not mean \"unable to research.\"\n\nThe durable `github`, `automation`, and `rlm` action families are also deferred\nby default. `rlm` owns `open`, `eval`, `configure`, and `close` actions for a\npersistent local Python session (a subprocess with a scrubbed environment, not\nan OS sandbox). Inline ```` ```repl ```` fences in a reply run in the same kind of\nkernel only when `code_execution` is on the turn's surface (never in Plan mode),\nonly when the fence opens its own line, and only after `code_execution`'s\napproval under the session posture. Feature-gated native tools may be added to\nthe active or deferred catalog only when their implementation and host\ndependencies are available.\n\nMCP tools are dynamic. Successfully connected servers register names such as\n`mcp__` from `~/.codewhale/mcp.json`; a failed or disabled server\nmust not be presented as available. MCP and plugin tools are deferred unless a\nuser explicitly names them in `[tools].always_load`.\n\nFor an unstarted configured server, an MCP-focused `tool_search` first\nperforms bounded discovery under the current turn's server/tool ceiling.\nIt searches real server-provided schemas after connection, rather than\ninventing `mcp_*` definitions. A search inside `execute_tools` describes\nthose schemas without activating them; a direct search uses the existing\nbounded activation cache. Ordinary unrelated searches leave optional servers\nunstarted. The CLI's standalone MCP inspection commands own a separate pool.\n\n### Code mode (`execute_tools`)\n\n`execute_tools` is engine-injected, alongside the synthetic interpreter tools.\nIt runs a JavaScript program whose only host surface is\n`await tools.call(name, args)`, and it is the default way to compose several\ntool calls — MCP and plugin tools included — without round-tripping every\nintermediate result through the conversation. It is hidden from Plan mode and\nrefused under a worker authority envelope.\n\n- **One gate.** In a session turn every nested call is sent back to the turn\n loop and planned exactly like a direct call: deny/allow lists, preparation\n (MCP `readOnlyHint`/`destructiveHint`), `tool_call_before` hooks, ask-rules,\n Auto-Review, repository law, and the Computer Use consent refusal. MCP calls\n run through the session MCP pool. Approving the program grants nothing, so\n `execute_tools` itself is auto-approved. If the permission posture changes\n while a program runs, its remaining nested calls are refused (an approved\n call survives only an equal or broader posture, as for a direct call) and\n the model retries them under the new posture.\n- **Approvals suspend the program.** A nested call that needs approval raises\n the normal approval card (named `execute_tools program call: ...`) and the\n program waits; allow resumes it, deny fails only that nested call as an\n exception the program can catch. Time spent waiting on a decision does not\n count against the program's run deadline, which is the turn's remaining\n wall clock.\n- **Receipts.** The result lists every nested call with its decision (`auto`,\n `approved`, `denied`, `refused`) and status (`ok`, `failed`, `refused`,\n `in_flight`). A program that hits its deadline still returns the receipt;\n `in_flight` calls were cancelled and may have partially run. Each nested\n result is `{content, metadata, truncated}`; an oversized result keeps that\n shape, and `truncated` names the original size and the spillover file with\n the full output.\n- **Discovery without re-pinning.** Inside a program,\n `tools.call('tool_search', {query})` returns matching deferred tools with\n their input schemas and does not activate them, so the request's tool array\n and the session-pinned prefix do not change.\n- **Stays direct:** `agent`, `workflow`, `request_user_input`, nested\n `execute_tools`, interactive shells, sandbox escalation, Computer Use\n consent and scripts, and MCP sign-in (`mcp__authenticate`).\n\nCode mode is on by default (`[features] code_mode = true`), which makes\n`execute_tools` eager from the first request; direct tools and `tool_search`\nstay available either way. Set `code_mode = false` (or run with\n`--disable code_mode`) to go back to deferring `execute_tools` behind\n`tool_search`. The flag is session configuration, so the prompt prefix stays\nstable within a session.\n\nSub-agents get `execute_tools` on the same terms, unless their role is limited\nto the read-only evidence tools or an explicit tool list leaves it out. A\nchild's nested call is planned by the child's own gate, so its grant and its\napproval path apply as for its direct calls, and it is admitted at execution\nagainst the child's write-scope claim and live peers. A child's program also\nstops at the child's tool timeout (time waiting on approvals is not counted)\nor its work deadline, and still returns the receipt. A program in which no\nnested call ran does not count as progress for the child's permission-denial\nguard, but the guard does not yet count a refused nested call as a denial\neither; only the child's step and time budgets end a loop of denied calls\nretried inside programs. Without an engine turn\n(a direct unit call), a program keeps the conservative profile: read-only,\nauto-approved native calls only, no MCP.\n\n### Conversation toolbox cache\n\nA successful search activation is remembered by name for the current\nconversation. The cache is bounded at eight deferred names and 16 KiB of\nserialized schemas. To make room it evicts tools that were never called first\n(oldest activation first), then the least recently called; a tool called in the\ncurrent or previous turn is never evicted. When such tools already fill the\ncache, a search still activates its best match over the bound and holds the\nrest; a later activation restores the bound once those tools go unused. Every\nentry is revalidated against the current catalog and policy before it is\nadvertised again. A session sync clears it. The cache cannot resurrect a\nremoved, denied, or newly-eager tool.\n\nWhen a search's activation pushes a previously activated tool out of the\ncache, that search's result lists it under `evicted_tool_references`, so the\nmodel is told the tool is no longer callable rather than finding it missing\nfrom the next request. A held match is listed under\n`unavailable_tool_references`. Both carry `tool_search_input`, an exact regex\nquery for that one name that loads it again. Only tools that actually left the\nactive set are listed; a tool that became eager stays callable and is not\nreported. Known limit: a tool dropped by the per-turn revalidation (removed,\ndenied, or no longer deferred), or evicted to make room for a direct first call\nto a deferred tool, is still dropped silently, because there is no search\nresult to carry the notice.\n\nEach subagent gets its own policy-filtered deferred catalog, always-present\n`tool_search`, and bounded activation cache. Forked messages and instructions\nremain in context, but the child cache starts empty and discovers tools locally;\nneither forked context nor a cache can become a discovery allowlist. A child can\nstill search every tool its own authority permits, including Web search/fetch\nfor read-only research roles.\n\n## Inspect the model-client request tool payload\n\nRun `/tools` after a model turn to inspect a bounded projection of the exact\ntool field in the latest prepared model-client request. `/tools json` emits the\nsame evidence as bounded machine-readable JSON. Both formats open in a pager;\nthey are not copied into transcript history. `/tool-studio` remains a human-\ncommand compatibility alias; it is not a model tool.\n\nThe snapshot distinguishes an absent tool field from a present empty array. It\nreports the exact model-client tool JSON byte count and SHA-256 digest only when\nmeasurement fits the one-MiB inspection bound; larger payloads stay unavailable.\nProvider adapters may transform, sanitize, or omit those fields while building\na provider-specific wire body, so `/tools` marks provider delivery and the wire\npayload unavailable. Capture and rendering are bounded: retained schemas,\ndescriptions, caller lists, catalog rows, turn IDs, and payload measurement all\ncarry explicit truncation, omission, or unavailable receipts. The snapshot stays\nin memory only for the current session and is replaced on each prepared request.\n\nProvider, model, approval, registry provenance, and runtime capability metadata\nare not fields in the request tool schema. `/tools` therefore reports them as\nunavailable instead of joining against mutable state or inferring values. Use\nthe separate route and permission receipts for those facts.\n\n## Modes and permission postures\n\nModes and permission postures are separate controls:\n\n- **Plan** keeps the stable primitive vocabulary but centrally refuses shell\n execution and file mutation.\n- **Work** is ordinary interactive execution.\n- **Operate** uses the same direct-tool authority as Work. Small work stays\n direct; multi-step delegation uses a compact Workflow plan with dependencies,\n bounded scopes, and completion evidence. Fleet manages the same sub-agents\n and roles. One bounded, independent task can use a direct agent; `followup`\n reuses that agent for continued work.\n- **Ask**, **Auto-Review**, and **Full Access** control approval behavior within\n an action-capable mode. They never widen Plan into write or shell access.\n\nSee `docs/MODES.md` for the full mode and posture contract.\n\n## Compatibility names\n\nThe model-facing contract is the lowercase core above. Saved v0.9.x\ntranscripts and protocol clients may still call exact hidden compatibility\nnames such as `File`, `Bash`, and the older single-operation file names. Those\nnames never enter a new model catalog or `tool_search` result.\n\nCompatibility is execution compatibility, not fuzzy aliasing: an exact legacy\ncall must reach the handler for its legacy schema. It must not be rewritten\ninto a small lowercase primitive whose input shape is different. Unknown or\nretired names still fail closed instead of guessing a destination.\n\nSpecialized native families such as `Git`, `Run`, and `Web` are not aliases for\nthe lowercase core. They remain real, policy-filtered deferred tools and are\nloaded through `tool_search` when needed.\n\n## Long-running work\n\n`bash` runs one cancellable foreground command. It does not carry background,\nTTY, wait, interact, or cancel action fields. Stateful process and terminal\ncontrol stays out of the shell schema: `task_shell_wait` is eager for the\nbackground jobs `bash` starts, while interactive terminal control remains\ndeferred behind `tool_search`; neither enlarges the first-turn shell schema.\n\nUse `tasks` when the work itself needs a durable lifecycle, structured gates,\nartifacts, replayable timelines, or a stable task id. Large tool results should\nremain behind bounded handles or artifacts instead of being copied wholesale\ninto the parent transcript.\n\n## Parallel fan-out\n\nThe sub-agent capacity source of truth is\n`crates/tui/src/config/subagent_limits.rs`:\n\n- default configured concurrency: **64**;\n- maximum configured concurrency: **128**;\n- maximum admitted running-plus-queued work: **1024**.\n\nThese are capacity ceilings, not advice to dispatch every available slot. A\nmanager should use the smallest useful fan-out, preserve a single owner for\nfan-in, and verify worker receipts before reporting combined completion.\n\nRLM child-query batching is a different, cheaper cost class. Its\n`sub_query_batch` helper accepts 1–16 one-shot children inside a live `rlm`\nsession; it is not a substitute for tool-carrying `agent` workers.\n\n## Human inspection: `/tools` (`/tool-studio`)\n\n`/tools` renders a **read-only, bounded human projection** of the tool field of\nthe request that was prepared for one `(turn, step)`. It is not a second\nregistry and not an execution surface.\n\n**The seam.** The snapshot is built in `crates/tui/src/core/engine/turn_loop.rs`\nimmediately after `MessageRequest` is constructed, from `request.tools` — the\nsame value the model client is handed. The engine resolves the surrounding\nper-turn data once in `engine.rs` (`ToolSurfaceContext`: flattened registry\nfacts, the MCP pool's own server attribution, the engine-injected catalog names,\nand the resolved model client's receipt) and passes it as plain data, so the\nper-step seam never re-locks the MCP pool or holds a tool object.\n\n**Turn and step identity.** The tool set can differ between steps of a turn, so\neach snapshot is stamped with turn id and step and each seam emits its own. The\nTUI keeps only the latest (`SessionState.last_tool_request_snapshot`). Before\nthe first seam there is no snapshot and `/tools` says so rather than rebuilding\na registry in the UI.\n\nTwo kinds of fact are kept apart:\n\n- **Wire facts** come from the prepared request: name, description, schema,\n `defer_loading` / `strict` / `allowed_callers` / `cache_control`, byte\n accounting, and the catalog digest.\n- **Surface facts** come from the `ToolSurfaceContext`: provenance\n (`builtin` / `plugin` / `mcp` / `synthetic` / `unknown`), MCP server identity,\n declared capabilities, declared approval requirement, and model visibility.\n\nContract:\n\n- **One digest.** `active_tool_catalog_sha256`\n (`crates/tui/src/core/engine/preview.rs`) is the single definition of the\n active-tool-catalog hash. The request manifest publishes it as\n `ToolSurfaceFacts::active_tool_catalog_sha256` and `/tools` reports the same\n value for the same prepared request; neither surface keeps a hash of its own.\n- **Nothing is guessed.** MCP server identity is shown only when the real pool\n attributed that exact model tool name. `McpPool::mcp_model_tool_name` is the\n single definition shared by the model catalog and the human attribution, and\n an ambiguous name (two servers colliding on one model name) resolves to no\n server. Synthetic provenance comes from\n `default_synthetic_catalog_tool_names`, which is asserted against the engine's\n own `is_synthetic_catalog_tool` predicate. A transmitted tool with no registry\n entry reports `capabilities: unknown`, never \"none\".\n- **Provider availability follows the resolved client.** It comes from\n `Engine::tool_surface_provider_receipt`, never from \"a tool registry exists\".\n With no client the receipt is `unavailable` even when the registry is full.\n- **Unknown shrinks, it does not vanish.** `unavailable_for_this_request` always\n contains `provider_wire_payload`: nothing on this path observes what the\n provider adapter finally transmits. It additionally contains `provider` and\n `model` without a resolved client, and `provenance` / `capabilities` /\n `approval` when no surface context was captured.\n- **Absent stays distinct from empty.** A request with no tools field is not a\n request with an empty tools array; an unresolved field is `unknown` with a\n reason, not a default.\n- **Bounded.** Rendering is capped by tool count (32), name, description, schema\n bytes, allowed-caller count, and a payload measurement bound, each with an\n explicit truncation or omission receipt. Registered tools that this request\n does *not* carry are reported as a bounded name list plus an exact count\n rather than expanding the projection.\n- **Inert.** The snapshot lives beside the transcript, never in\n `session.messages`, so it cannot enter a model request or perturb the\n provider's prefix cache. It never executes a tool, never reads credentials,\n never reorders the catalog, and is never registered as a model-callable tool.\n- **Delivery is never claimed.** The capture happens before connection setup, so\n `delivery_status` stays `unknown`.\n\n## Release verification\n\nDo not infer the public surface from handler function names. Verify the model\ncatalog and alias visibility at the exact candidate SHA:\n\n```bash\npython3 scripts/measure-runtime-contract.py\ncargo test -p codewhale-tui --lib --locked core::engine::tests::default_active_contract_keeps_discovery_and_core_tools_eager -- --exact\ncargo test -p codewhale-tui --lib --locked tools::file_tool::tests::primitive_schemas_are_separate_and_small_contract_shaped -- --exact\ncargo test -p codewhale-tui --lib --locked tools::shell::tests::lowercase_bash_schema_is_small_contract -- --exact\ncargo test --locked -p codewhale-tui --lib core::engine::tests::print_mode_tool_catalog_metrics -- --ignored --exact --nocapture\n```\n\nCheck the test names against the source before trusting a green run: `cargo test`\nexits 0 with \"0 passed; N filtered out\" when a filter matches nothing, so a\nmisspelled filter is indistinguishable from a pass. Each `--exact` command\nabove must report `1 passed` (the ignored metrics test reports `1 passed`\nonly because `--ignored` selects it); `0 passed` means the filter matched\nnothing and the check did not run.\n\nThe provider-free receipt must report the thirteen default-active names listed\nabove. A separate repository-wide tool count may include deferred, dynamic,\nfeature-gated, and compatibility-only registrations; it is not the number of\ntools placed in the first-turn model catalog.\n" + "sha256": "f14fbc4d884fa628afd5c82c501d0cda4fb069afaf1aedbe851f253b90b217cb", + "content": "# Tool surface\n\n> 阅读简体中文版:[zh_hans/TOOL_SURFACE.md](zh_hans/TOOL_SURFACE.md)。\n\nThis document describes the current model-facing tool contract. The v0.9.1\ncutover that produced it is recorded in `docs/RUNTIME_SIMPLIFICATION_DESIGN.md`;\nread the workspace version from `Cargo.toml`, not from this line. The registry\nremains larger than the first-turn catalog so\nsaved transcripts can replay and uncommon capabilities can be loaded on demand.\nThe model should learn one canonical name for each common operation.\n\nImplementation sources:\n\n- `crates/tui/src/core/engine/tool_catalog.rs` owns the eager/deferred catalog.\n- `crates/tui/src/tools/registry.rs` registers canonical tools and hidden aliases.\n- `crates/tui/src/tools/{file,file_tool,shell}.rs` own the small foreground\n primitive behavior and schemas; the other native tools remain searchable.\n- `docs/RUNTIME_SIMPLIFICATION_DESIGN.md` records the v0.9.1 cutover and receipt.\n\n## Default-active contract\n\nNew turns start with nine eager native names plus synthetic `tool_search`:\n\n1. `read`\n2. `write`\n3. `edit`\n4. `file_search`\n5. `bash`\n6. `todo_write`\n7. `task_shell_wait`\n8. `load_skill`\n9. `create_goal`\n10. `tool_search` (synthetic, always active)\n\nThe nine native names are `DEFAULT_ACTIVE_NATIVE_TOOLS` in\n`crates/tui/src/core/engine/tool_catalog.rs`, pinned by\n`default_active_contract_keeps_discovery_and_core_tools_eager`. `agent`,\n`workflow`, `get_goal`, and `update_goal` are deferred by default and load\nvia `tool_search` (`get_goal`/`update_goal` ride eager only while a goal is\nactive). An authority boundary may remove `agent` at the maximum child depth,\nand Plan registers only the foreground shell surface (so no `task_shell_wait`),\nbut route size alone must not change this core vocabulary.\n\nThe core schemas deliberately stay small (deferred rows load via\n`tool_search` rather than riding every turn):\n\n| Tool | Input | Purpose |\n|---|---|---|\n| `read` | `path`, optional `offset`, optional `limit` | Read a bounded file window with explicit continuation or truncation notices. |\n| `write` | `path`, `content` | Create or replace a file. |\n| `edit` | `path`, `edits` | Apply one or more unambiguous text replacements against one original snapshot. |\n| `file_search` | `query`, optional `path`, `limit`, `extensions`, `exclude` | Find files by name, path fragment or glob and return bounded, ranked matches. Respects ignore files; `exclude` replaces default exclusions. |\n| `bash` | `command`, optional `timeout`, optional `description` | Run one cancellable foreground shell command and return a bounded tail. The optional description is display-only. |\n| `agent` (deferred) | delegated task and optional scope/context controls | Start or inspect focused child work. |\n| `workflow` (deferred) | plan/script/source_path plus run controls | Coordinate multi-agent phases with dependencies and completion checks. |\n| `todo_write` | complete replacement list of `{content, status}` items | Keep optional, agent-owned progress notes for genuinely multi-step work. |\n| `create_goal` | objective plus optional budget | Start the session goal the turn works toward. |\n| `get_goal` (deferred unless a goal is active) | none | Read the active goal and its progress. |\n| `update_goal` (deferred unless a goal is active) | terminal status | Mark the goal complete or blocked. |\n| `task_shell_wait` | `task_id`, optional `wait`, `timeout_ms`, `cancel`, `gate` | Poll or stop a background shell task. Completion still arrives as a runtime event, so use it for early output, true barriers, or gate evidence. |\n| `load_skill` | optional `name`, `query` | Load a named skill's `SKILL.md` body and companion-file list; omit `name` or use `name=\"list\"` to list eligible skills, or use `query` to search names and descriptions. |\n| `tool_search` | `query`, optional matching controls | Discover policy-allowed deferred tools and add selected schemas to this conversation's toolbox. |\n\nMode is an authority decision, not a synonym system. Plan, Work, and Operate\nuse the same primitive identities. Plan centrally refuses `write`, `edit`, and\n`bash`; Work and Operate still pass those calls through approval, sandbox,\ntrusted-path, repository-law, and managed-policy gates. Full Access changes\nordinary approval behavior but does not bypass hard safety or repository law.\n\n`update_plan` remains registered only for saved-artifact compatibility and is\nnot model-visible. `tasks`, `Git`, `Run`, `Web`, `remember`, and other\nspecialized capabilities are searchable rather than first-turn ceremony.\n\nOn the TUI's default (calm) shell card the Bash description is the headline,\nshown as written apart from stripped terminal escapes, collapsed whitespace and\na length bound, and the command sits on its own `$` row directly under it in\nevery state and at every width, including automatic runs. A card without a\ndescription leads with the `$` command instead. The labeled card\n(`show_tool_details = true` with `calm_mode = false`) keeps the command in its\nheader and the description as a muted `note:` row. The description never\nreplaces the command and is excluded from execution, exact approval keys and\nAuto-Review input. The original description remains in the recorded tool input\nand exports. Approval prompts, `/jobs`, the activity inspector, ACP and other\nRuntime clients keep showing the command; this change adds no description row\nto those surfaces.\n\n## Local code execution\n\n`code_execution` (Python) and `js_execution` (Node.js) use the same\npermission-aware local launcher as workspace task gates and test runs. Ordinary\napproval retains the effective session policy. To retry a denied exact call\nwith wider permissions, supply `sandbox_permissions` (`workspace-write` or\n`danger-full-access`) and a nonempty `justification`; Ask mode requires explicit\none-shot user approval. The approved policy affects only that call.\n\nBoth tools read source from stdin, return stdout/stderr/return_code, and keep\nthe existing timeout and process-tree cleanup. They are not persistent REPLs;\ntracebacks refer to stdin rather than a temporary script path. The separate\nRLM/REPL kernel is not changed by this launcher integration.\n\nPlatform/backend limits of the shared launcher still apply: no available local\nwrapper means workspace-write remains unenforced, while read-only is refused.\nAn external sandbox session cannot use these local interpreter tools and must\nuse its shell execution path. See [sandbox limits](SANDBOX.md).\n\n## Deferred and dynamic tools\n\n`Web` is conditional and deferred. It is discoverable through `tool_search`\nonly when the active policy and runtime backend permit it. Read-only\nchildren retain its read-only search/fetch evidence path; read-only authority\ndoes not mean \"unable to research.\"\n\nThe durable `github`, `automation`, and `rlm` action families are also deferred\nby default. `rlm` owns `open`, `eval`, `configure`, and `close` actions for a\npersistent local Python session (a subprocess with a scrubbed environment, not\nan OS sandbox). Inline ```` ```repl ```` fences in a reply run in the same kind of\nkernel only when `code_execution` is on the turn's surface (never in Plan mode),\nonly when the fence opens its own line, and only after `code_execution`'s\napproval under the session posture. Feature-gated native tools may be added to\nthe active or deferred catalog only when their implementation and host\ndependencies are available.\n\nMCP tools are dynamic. Successfully connected servers register names such as\n`mcp__` from `~/.codewhale/mcp.json`; a failed or disabled server\nmust not be presented as available. MCP and plugin tools are deferred unless a\nuser explicitly names them in `[tools].always_load`.\n\nFor an unstarted configured server, an MCP-focused `tool_search` first\nperforms bounded discovery under the current turn's server/tool ceiling.\nIt searches real server-provided schemas after connection, rather than\ninventing `mcp_*` definitions. A search inside `execute_tools` describes\nthose schemas without activating them; a direct search uses the existing\nbounded activation cache. Ordinary unrelated searches leave optional servers\nunstarted. The CLI's standalone MCP inspection commands own a separate pool.\n\n### Code mode (`execute_tools`)\n\n`execute_tools` is engine-injected, alongside the synthetic interpreter tools.\nIt runs a JavaScript program whose only host surface is\n`await tools.call(name, args)`, and it is the default way to compose several\ntool calls — MCP and plugin tools included — without round-tripping every\nintermediate result through the conversation. It is hidden from Plan mode and\nrefused under a worker authority envelope.\n\n- **One gate.** In a session turn every nested call is sent back to the turn\n loop and planned exactly like a direct call: deny/allow lists, preparation\n (MCP `readOnlyHint`/`destructiveHint`), `tool_call_before` hooks, ask-rules,\n Auto-Review, repository law, and the Computer Use consent refusal. MCP calls\n run through the session MCP pool. Approving the program grants nothing, so\n `execute_tools` itself is auto-approved. If the permission posture changes\n while a program runs, its remaining nested calls are refused (an approved\n call survives only an equal or broader posture, as for a direct call) and\n the model retries them under the new posture.\n- **Approvals suspend the program.** A nested call that needs approval raises\n the normal approval card (named `execute_tools program call: ...`) and the\n program waits; allow resumes it, deny fails only that nested call as an\n exception the program can catch. Time spent waiting on a decision does not\n count against the program's run deadline, which is the turn's remaining\n wall clock.\n- **Receipts.** The result lists every nested call with its decision (`auto`,\n `approved`, `denied`, `refused`) and status (`ok`, `failed`, `refused`,\n `in_flight`). A program that hits its deadline still returns the receipt;\n `in_flight` calls were cancelled and may have partially run. Each nested\n result is `{content, metadata, truncated}`; an oversized result keeps that\n shape, and `truncated` names the original size and the spillover file with\n the full output.\n- **Discovery without re-pinning.** Inside a program,\n `tools.call('tool_search', {query})` returns matching deferred tools with\n their input schemas and does not activate them, so the request's tool array\n and the session-pinned prefix do not change.\n- **Stays direct:** `agent`, `workflow`, `request_user_input`, nested\n `execute_tools`, interactive shells, sandbox escalation, Computer Use\n consent and scripts, and MCP sign-in (`mcp__authenticate`).\n\nCode mode is on by default (`[features] code_mode = true`), which makes\n`execute_tools` eager from the first request; direct tools and `tool_search`\nstay available either way. Set `code_mode = false` (or run with\n`--disable code_mode`) to go back to deferring `execute_tools` behind\n`tool_search`. The flag is session configuration, so the prompt prefix stays\nstable within a session.\n\nSub-agents get `execute_tools` on the same terms, unless their role is limited\nto the read-only evidence tools or an explicit tool list leaves it out. A\nchild's nested call is planned by the child's own gate, so its grant and its\napproval path apply as for its direct calls, and it is admitted at execution\nagainst the child's write-scope claim and live peers. A child's program also\nstops at the child's tool timeout (time waiting on approvals is not counted)\nor its work deadline, and still returns the receipt. A program in which no\nnested call ran does not count as progress for the child's permission-denial\nguard, but the guard does not yet count a refused nested call as a denial\neither; only the child's step and time budgets end a loop of denied calls\nretried inside programs. Without an engine turn\n(a direct unit call), a program keeps the conservative profile: read-only,\nauto-approved native calls only, no MCP.\n\n### Conversation toolbox cache\n\nA successful search activation is remembered by name for the current\nconversation. The cache is bounded at eight deferred names and 16 KiB of\nserialized schemas. To make room it evicts tools that were never called first\n(oldest activation first), then the least recently called; a tool called in the\ncurrent or previous turn is never evicted. When such tools already fill the\ncache, a search still activates its best match over the bound and holds the\nrest; a later activation restores the bound once those tools go unused. Every\nentry is revalidated against the current catalog and policy before it is\nadvertised again. A session sync clears it. The cache cannot resurrect a\nremoved, denied, or newly-eager tool.\n\nWhen a search's activation pushes a previously activated tool out of the\ncache, that search's result lists it under `evicted_tool_references`, so the\nmodel is told the tool is no longer callable rather than finding it missing\nfrom the next request. A held match is listed under\n`unavailable_tool_references`. Both carry `tool_search_input`, an exact regex\nquery for that one name that loads it again. Only tools that actually left the\nactive set are listed; a tool that became eager stays callable and is not\nreported. Known limit: a tool dropped by the per-turn revalidation (removed,\ndenied, or no longer deferred), or evicted to make room for a direct first call\nto a deferred tool, is still dropped silently, because there is no search\nresult to carry the notice.\n\nEach subagent gets its own policy-filtered deferred catalog, always-present\n`tool_search`, and bounded activation cache. Forked messages and instructions\nremain in context, but the child cache starts empty and discovers tools locally;\nneither forked context nor a cache can become a discovery allowlist. A child can\nstill search every tool its own authority permits, including Web search/fetch\nfor read-only research roles.\n\n## Inspect the model-client request tool payload\n\nRun `/tools` after a model turn to inspect a bounded projection of the exact\ntool field in the latest prepared model-client request. `/tools json` emits the\nsame evidence as bounded machine-readable JSON. Both formats open in a pager;\nthey are not copied into transcript history. `/tool-studio` remains a human-\ncommand compatibility alias; it is not a model tool.\n\nThe snapshot distinguishes an absent tool field from a present empty array. It\nreports the exact model-client tool JSON byte count and SHA-256 digest only when\nmeasurement fits the one-MiB inspection bound; larger payloads stay unavailable.\nProvider adapters may transform, sanitize, or omit those fields while building\na provider-specific wire body, so `/tools` marks provider delivery and the wire\npayload unavailable. Capture and rendering are bounded: retained schemas,\ndescriptions, caller lists, catalog rows, turn IDs, and payload measurement all\ncarry explicit truncation, omission, or unavailable receipts. The snapshot stays\nin memory only for the current session and is replaced on each prepared request.\n\nProvider, model, approval, registry provenance, and runtime capability metadata\nare not fields in the request tool schema. `/tools` therefore reports them as\nunavailable instead of joining against mutable state or inferring values. Use\nthe separate route and permission receipts for those facts.\n\n## Modes and permission postures\n\nModes and permission postures are separate controls:\n\n- **Plan** keeps the stable primitive vocabulary but centrally refuses shell\n execution and file mutation.\n- **Work** is ordinary interactive execution.\n- **Operate** uses the same direct-tool authority as Work. Small work stays\n direct; multi-step delegation uses a compact Workflow plan with dependencies,\n bounded scopes, and completion evidence. Fleet manages the same sub-agents\n and roles. One bounded, independent task can use a direct agent; `followup`\n reuses that agent for continued work.\n- **Ask**, **Auto-Review**, and **Full Access** control approval behavior within\n an action-capable mode. They never widen Plan into write or shell access.\n\nSee `docs/MODES.md` for the full mode and posture contract.\n\n## Compatibility names\n\nThe model-facing contract is the lowercase core above. Saved v0.9.x\ntranscripts and protocol clients may still call exact hidden compatibility\nnames such as `File`, `Bash`, and the older single-operation file names. Those\nnames never enter a new model catalog or `tool_search` result.\n\nCompatibility is execution compatibility, not fuzzy aliasing: an exact legacy\ncall must reach the handler for its legacy schema. It must not be rewritten\ninto a small lowercase primitive whose input shape is different. Unknown or\nretired names still fail closed instead of guessing a destination.\n\nSpecialized native families such as `Git`, `Run`, and `Web` are not aliases for\nthe lowercase core. They remain real, policy-filtered deferred tools and are\nloaded through `tool_search` when needed.\n\n## Long-running work\n\n`bash` runs one cancellable foreground command. It does not carry background,\nTTY, wait, interact, or cancel action fields. Stateful process and terminal\ncontrol stays out of the shell schema: `task_shell_wait` is eager for the\nbackground jobs `bash` starts, while interactive terminal control remains\ndeferred behind `tool_search`; neither enlarges the first-turn shell schema.\n\nUse `tasks` when the work itself needs a durable lifecycle, structured gates,\nartifacts, replayable timelines, or a stable task id. Large tool results should\nremain behind bounded handles or artifacts instead of being copied wholesale\ninto the parent transcript.\n\n## Parallel fan-out\n\nThe sub-agent capacity source of truth is\n`crates/tui/src/config/subagent_limits.rs`:\n\n- default configured concurrency: **64**;\n- maximum configured concurrency: **128**;\n- maximum admitted running-plus-queued work: **1024**.\n\nThese are capacity ceilings, not advice to dispatch every available slot. A\nmanager should use the smallest useful fan-out, preserve a single owner for\nfan-in, and verify worker receipts before reporting combined completion.\n\nRLM child-query batching is a different, cheaper cost class. Its\n`sub_query_batch` helper accepts 1–16 one-shot children inside a live `rlm`\nsession; it is not a substitute for tool-carrying `agent` workers.\n\n## Human inspection: `/tools` (`/tool-studio`)\n\n`/tools` renders a **read-only, bounded human projection** of the tool field of\nthe request that was prepared for one `(turn, step)`. It is not a second\nregistry and not an execution surface.\n\n**The seam.** The snapshot is built in `crates/tui/src/core/engine/turn_loop.rs`\nimmediately after `MessageRequest` is constructed, from `request.tools` — the\nsame value the model client is handed. The engine resolves the surrounding\nper-turn data once in `engine.rs` (`ToolSurfaceContext`: flattened registry\nfacts, the MCP pool's own server attribution, the engine-injected catalog names,\nand the resolved model client's receipt) and passes it as plain data, so the\nper-step seam never re-locks the MCP pool or holds a tool object.\n\n**Turn and step identity.** The tool set can differ between steps of a turn, so\neach snapshot is stamped with turn id and step and each seam emits its own. The\nTUI keeps only the latest (`SessionState.last_tool_request_snapshot`). Before\nthe first seam there is no snapshot and `/tools` says so rather than rebuilding\na registry in the UI.\n\nTwo kinds of fact are kept apart:\n\n- **Wire facts** come from the prepared request: name, description, schema,\n `defer_loading` / `strict` / `allowed_callers` / `cache_control`, byte\n accounting, and the catalog digest.\n- **Surface facts** come from the `ToolSurfaceContext`: provenance\n (`builtin` / `plugin` / `mcp` / `synthetic` / `unknown`), MCP server identity,\n declared capabilities, declared approval requirement, and model visibility.\n\nContract:\n\n- **One digest.** `active_tool_catalog_sha256`\n (`crates/tui/src/core/engine/preview.rs`) is the single definition of the\n active-tool-catalog hash. The request manifest publishes it as\n `ToolSurfaceFacts::active_tool_catalog_sha256` and `/tools` reports the same\n value for the same prepared request; neither surface keeps a hash of its own.\n- **Nothing is guessed.** MCP server identity is shown only when the real pool\n attributed that exact model tool name. `McpPool::mcp_model_tool_name` is the\n single definition shared by the model catalog and the human attribution, and\n an ambiguous name (two servers colliding on one model name) resolves to no\n server. Synthetic provenance comes from\n `default_synthetic_catalog_tool_names`, which is asserted against the engine's\n own `is_synthetic_catalog_tool` predicate. A transmitted tool with no registry\n entry reports `capabilities: unknown`, never \"none\".\n- **Provider availability follows the resolved client.** It comes from\n `Engine::tool_surface_provider_receipt`, never from \"a tool registry exists\".\n With no client the receipt is `unavailable` even when the registry is full.\n- **Unknown shrinks, it does not vanish.** `unavailable_for_this_request` always\n contains `provider_wire_payload`: nothing on this path observes what the\n provider adapter finally transmits. It additionally contains `provider` and\n `model` without a resolved client, and `provenance` / `capabilities` /\n `approval` when no surface context was captured.\n- **Absent stays distinct from empty.** A request with no tools field is not a\n request with an empty tools array; an unresolved field is `unknown` with a\n reason, not a default.\n- **Bounded.** Rendering is capped by tool count (32), name, description, schema\n bytes, allowed-caller count, and a payload measurement bound, each with an\n explicit truncation or omission receipt. Registered tools that this request\n does *not* carry are reported as a bounded name list plus an exact count\n rather than expanding the projection.\n- **Inert.** The snapshot lives beside the transcript, never in\n `session.messages`, so it cannot enter a model request or perturb the\n provider's prefix cache. It never executes a tool, never reads credentials,\n never reorders the catalog, and is never registered as a model-callable tool.\n- **Delivery is never claimed.** The capture happens before connection setup, so\n `delivery_status` stays `unknown`.\n\n## Release verification\n\nDo not infer the public surface from handler function names. Verify the model\ncatalog and alias visibility at the exact candidate SHA:\n\n```bash\npython3 scripts/measure-runtime-contract.py\ncargo test -p codewhale-tui --lib --locked core::engine::tests::default_active_contract_keeps_discovery_and_core_tools_eager -- --exact\ncargo test -p codewhale-tui --lib --locked tools::file_tool::tests::primitive_schemas_are_separate_and_small_contract_shaped -- --exact\ncargo test -p codewhale-tui --lib --locked tools::shell::tests::lowercase_bash_schema_is_small_contract -- --exact\ncargo test --locked -p codewhale-tui --lib core::engine::tests::print_mode_tool_catalog_metrics -- --ignored --exact --nocapture\n```\n\nCheck the test names against the source before trusting a green run: `cargo test`\nexits 0 with \"0 passed; N filtered out\" when a filter matches nothing, so a\nmisspelled filter is indistinguishable from a pass. Each `--exact` command\nabove must report `1 passed` (the ignored metrics test reports `1 passed`\nonly because `--ignored` selects it); `0 passed` means the filter matched\nnothing and the check did not run.\n\nThe provider-free receipt must report the ten default-active names listed\nabove.\nabove. A separate repository-wide tool count may include deferred, dynamic,\nfeature-gated, and compatibility-only registrations; it is not the number of\ntools placed in the first-turn model catalog.\n\n\n### Source navigation receipts\n\nSource reads may include a bounded pod/symbol/link footer. Edits retain their unified diff in metadata and can name affected declarations and confirmed same-directory callers. File-name search and grep report matched directory pods; project maps can report bounded key-file symbols. Sounding content uses the same denied-read and credential exclusions plus protected regular-file handles. Caller content scans at most32 deterministic candidates at256KiB each; candidates outside that budget are reported as unscanned, never as additional known callers. Key-file content is capped at8MiB. Enumeration itself and platform I/O latency are not wall-clock bounded.\n" }, { "path": "docs/RUNTIME_SIMPLIFICATION_DESIGN.md", diff --git a/web/data/features.generated.json b/web/data/features.generated.json index 4db19c7563..2584670251 100644 --- a/web/data/features.generated.json +++ b/web/data/features.generated.json @@ -2,7 +2,7 @@ "format": "codewhale.features.v1", "source": { "path": "docs/features.toml", - "sha256": "fc3dbf4a8e94c12617f737fcdcaf4a2dfd336458b4aa067d8a4cce6d589d71d3" + "sha256": "fa0e52f5196f6caa02a7eb445ebd4c8e04989a9f46de4589833c69a5a00fe962" }, "features": [ { @@ -1021,6 +1021,48 @@ "flag": null, "docs": "docs/PLUGINS.md", "registryLine": 754 + }, + { + "id": "fork-prefix-inheritance", + "name": "Fork prefix inheritance", + "summary": "Allows matching first-level Chat Completions forks to reuse a parent request header.", + "status": "experimental", + "surfaces": { + "tui": "preview", + "api": "preview" + }, + "since": "0.10.2", + "flag": null, + "docs": "docs/CACHE.md#fork-prefix-inheritance", + "registryLine": 764 + }, + { + "id": "fork-trial-diagnostics", + "name": "Fork trial diagnostics", + "summary": "Opt-in JSON stderr observations of classified fork decisions and actual first-response cache usage.", + "status": "flagged", + "surfaces": { + "tui": "preview", + "api": "preview" + }, + "since": "0.10.2", + "flag": null, + "docs": "docs/CACHE.md#fork-trial-diagnostics", + "registryLine": 774 + }, + { + "id": "source-navigation-echoes", + "name": "Source navigation echoes", + "summary": "File and search results include bounded source symbols, neighboring files and change attribution.", + "status": "experimental", + "surfaces": { + "tui": "preview", + "api": "preview" + }, + "since": "0.10.2", + "flag": null, + "docs": "docs/ECHOLOCATION.md", + "registryLine": 784 } ] } diff --git a/web/lib/content/tools.ts b/web/lib/content/tools.ts index 55671ad64d..d2c3ef059f 100644 --- a/web/lib/content/tools.ts +++ b/web/lib/content/tools.ts @@ -12,14 +12,14 @@ export const TOOLS_COPY = { { name: "edit", detail: { en: "path · edits", zh: "path · edits" } }, { name: "file_search", detail: { en: "Find workspace files by name, path, or glob pattern.", zh: "按文件名、路径或 glob 通配模式查找工作区文件。" } }, { name: "bash", detail: { en: "command · timeout?", zh: "command · timeout?" } }, - { name: "agent · workflow", detail: { en: "Delegate work and coordinate dependent phases.", zh: "委派工作并协调有依赖关系的阶段。" } }, + { name: "agent · workflow", detail: { en: "Loaded on demand via tool_search. Delegate work and coordinate dependent phases.", zh: "通过 tool_search 按需加载。委派工作并协调有依赖关系的阶段。" } }, { name: "todo_write", detail: { en: "Replace the task list with content and status entries.", zh: "用包含内容与状态的条目替换任务列表。" } }, - { name: "create_goal · get_goal · update_goal", detail: { en: "Create, inspect and update the active goal.", zh: "创建、查看与更新当前目标。" } }, + { name: "create_goal · get_goal · update_goal", detail: { en: "create_goal is available without discovery; get_goal/update_goal load on demand while a goal is active.", zh: "create_goal 无需搜索即可使用;get_goal/update_goal 仅在目标激活时按需加载。" } }, { name: "task_shell_wait", detail: { en: "Poll or stop a background shell task by task_id.", zh: "按 task_id 轮询或停止后台 shell 任务。" } }, { name: "load_skill", detail: { en: "Load a skill by exact name, or pass query to search the catalogue.", zh: "按确切名称加载技能,或传入 query 搜索技能目录。" } }, { name: "tool_search", detail: { en: "Discover policy-allowed tools. Each child has its own search and activation cache.", zh: "发现策略允许的工具。每个子 Agent 拥有独立的搜索与激活缓存。" } }, { name: "Git · Run · tasks · remember · Web · MCP · plugins", detail: { en: "Deferred: loaded by tool_search only when policy permits.", zh: "延迟加载:仅在策略允许时由 tool_search 加载。" } }, - { name: "8 names / 16 KiB", detail: { en: "Conversation toolbox cache; independent per child and revalidated every turn.", zh: "会话工具箱缓存;每个子 Agent 独立,每轮重新校验。" } }, + { name: "8 names / 24 KiB", detail: { en: "Conversation toolbox cache; independent per child and revalidated every turn.", zh: "会话工具箱缓存;每个子 Agent 独立,每轮重新校验。" } }, { name: "Web search/fetch", detail: { en: "Scout and Reviewer children can discover these without gaining mutation or arbitrary network authority.", zh: "侦察与审查子 Agent 可发现这些工具,但不会因此获得写入或任意网络权限。" } }, { name: "MCP", detail: { en: "mcp__ — registered from ~/.codewhale/mcp.json.", zh: "mcp__——从 ~/.codewhale/mcp.json 注册。" } }, ], diff --git a/web/lib/public-surface-contract.test.ts b/web/lib/public-surface-contract.test.ts index 6bd75ed2be..29d5207ac5 100644 --- a/web/lib/public-surface-contract.test.ts +++ b/web/lib/public-surface-contract.test.ts @@ -473,7 +473,7 @@ done expect(registry).toContain("Arc::new(LowercaseBashTool)"); expect(registry).toContain('FileTool::new("File")'); expect(registry).toContain('BashTool::new("Bash")'); - expect(toolsPage).toContain("8 names / 16 KiB"); + expect(toolsPage).toContain("8 names / 24 KiB"); expect(toolsPage).toContain("Web search/fetch"); expect(toolsPage).not.toContain("docs/TOOL_LIFECYCLE.md"); }); diff --git a/web/lib/release-credits.ts b/web/lib/release-credits.ts index e234694f0e..dae9f414e9 100644 --- a/web/lib/release-credits.ts +++ b/web/lib/release-credits.ts @@ -40,7 +40,11 @@ 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[] = []; +export const UNRELEASED_CONTRIBUTORS: string[] = [ + "@aboimpinto", + "@AdityaVG13", + "@printf033", +]; /** * Contributors who helped with reports, reproductions, and verification.