diff --git a/rust/crates/api/src/providers/mod.rs b/rust/crates/api/src/providers/mod.rs index 9c50eb7aac..3de1e0be21 100644 --- a/rust/crates/api/src/providers/mod.rs +++ b/rust/crates/api/src/providers/mod.rs @@ -193,6 +193,17 @@ pub fn metadata_for_model(model: &str) -> Option { default_base_url: openai_compat::DEFAULT_OPENAI_BASE_URL, }); } + // Explicit `xai/` prefix routes to the XAI provider lane. This is + // useful for running a second OpenAI-compatible endpoint (e.g. Groq) in + // parallel with `OPENAI_BASE_URL`, since they have independent env vars. + if canonical.starts_with("xai/") { + return Some(ProviderMetadata { + provider: ProviderKind::Xai, + auth_env: "XAI_API_KEY", + base_url_env: "XAI_BASE_URL", + default_base_url: openai_compat::DEFAULT_XAI_BASE_URL, + }); + } // Alibaba DashScope compatible-mode endpoint. Routes qwen/* and bare // qwen-* model names (qwen-max, qwen-plus, qwen-turbo, qwen-qwq, etc.) // to the OpenAI-compat client pointed at DashScope's /compatible-mode/v1. diff --git a/rust/crates/runtime/src/bash_validation.rs b/rust/crates/runtime/src/bash_validation.rs index f00619efe8..4974bc0b40 100644 --- a/rust/crates/runtime/src/bash_validation.rs +++ b/rust/crates/runtime/src/bash_validation.rs @@ -592,6 +592,36 @@ fn classify_git_command(command: &str) -> CommandIntent { /// Returns the first non-Allow result, or Allow if all validations pass. #[must_use] pub fn validate_command(command: &str, mode: PermissionMode, workspace: &Path) -> ValidationResult { + // T1.5: Split compound pipelines (`&&`, `||`, `;`, `|`, `&`) at top level + // and validate each segment independently. Without this, a malicious + // chain like `ls && rm -rf /` passes because only the first command is + // inspected. + let segments = split_bash_pipeline(command); + if segments.len() <= 1 { + return validate_command_segment(command, mode, workspace); + } + let mut deferred_warn: Option = None; + for segment in segments { + match validate_command_segment(segment, mode, workspace) { + ValidationResult::Allow => {} + block @ ValidationResult::Block { .. } => return block, + warn @ ValidationResult::Warn { .. } => { + if deferred_warn.is_none() { + deferred_warn = Some(warn); + } + } + } + } + deferred_warn.unwrap_or(ValidationResult::Allow) +} + +/// Validate a single command segment (the original pre-T1.5 implementation, +/// preserved unchanged so single-command inputs behave identically). +fn validate_command_segment( + command: &str, + mode: PermissionMode, + workspace: &Path, +) -> ValidationResult { // 1. Mode-level validation (includes read-only checks). let result = validate_mode(command, mode); if result != ValidationResult::Allow { @@ -614,6 +644,73 @@ pub fn validate_command(command: &str, mode: PermissionMode, workspace: &Path) - validate_paths(command, workspace) } +/// Split a bash command at top-level chain/pipe operators, ignoring separators +/// that appear inside single quotes, double quotes, backticks, or after a +/// backslash escape. Recognised separators: `&&`, `||`, `;`, `|`, `&`. +/// Returns trimmed, non-empty segments in order. +fn split_bash_pipeline(command: &str) -> Vec<&str> { + let bytes = command.as_bytes(); + let mut segments: Vec<&str> = Vec::new(); + let mut start: usize = 0; + let mut i: usize = 0; + let mut in_single = false; + let mut in_double = false; + let mut in_backtick = false; + while i < bytes.len() { + let c = bytes[i]; + // Backslash escape (outside single quotes, where it is literal) + if c == b'\\' && !in_single && i + 1 < bytes.len() { + i += 2; + continue; + } + if !in_double && !in_backtick && c == b'\'' { + in_single = !in_single; + i += 1; + continue; + } + if !in_single && !in_backtick && c == b'"' { + in_double = !in_double; + i += 1; + continue; + } + if !in_single && !in_double && c == b'`' { + in_backtick = !in_backtick; + i += 1; + continue; + } + if !in_single && !in_double && !in_backtick { + // Two-byte separators take precedence over one-byte. + let two_byte = i + 1 < bytes.len() + && (bytes[i] == b'&' && bytes[i + 1] == b'&' + || bytes[i] == b'|' && bytes[i + 1] == b'|'); + if two_byte { + let segment = command[start..i].trim(); + if !segment.is_empty() { + segments.push(segment); + } + i += 2; + start = i; + continue; + } + if c == b';' || c == b'|' || c == b'&' { + let segment = command[start..i].trim(); + if !segment.is_empty() { + segments.push(segment); + } + i += 1; + start = i; + continue; + } + } + i += 1; + } + let last = command[start..].trim(); + if !last.is_empty() { + segments.push(last); + } + segments +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -1001,4 +1098,86 @@ mod tests { fn extracts_plain_command() { assert_eq!(extract_first_command("grep -r pattern ."), "grep"); } + + // --- split_bash_pipeline (T1.5) --- + + #[test] + fn split_pipeline_single_command() { + assert_eq!(split_bash_pipeline("ls -la"), vec!["ls -la"]); + } + + #[test] + fn split_pipeline_double_amp() { + assert_eq!( + split_bash_pipeline("ls -la && rm -rf /tmp/x"), + vec!["ls -la", "rm -rf /tmp/x"] + ); + } + + #[test] + fn split_pipeline_double_pipe() { + assert_eq!( + split_bash_pipeline("test -f foo || touch foo"), + vec!["test -f foo", "touch foo"] + ); + } + + #[test] + fn split_pipeline_semicolon_and_pipe() { + assert_eq!( + split_bash_pipeline("ls ; cat /etc/hosts | grep host"), + vec!["ls", "cat /etc/hosts", "grep host"] + ); + } + + #[test] + fn split_pipeline_respects_double_quotes() { + assert_eq!( + split_bash_pipeline(r#"echo "a && b" && ls"#), + vec![r#"echo "a && b""#, "ls"] + ); + } + + #[test] + fn split_pipeline_respects_single_quotes() { + assert_eq!( + split_bash_pipeline(r#"echo 'a;b' ; ls"#), + vec![r#"echo 'a;b'"#, "ls"] + ); + } + + #[test] + fn split_pipeline_respects_backslash_escape() { + assert_eq!( + split_bash_pipeline(r#"echo a\&\&b"#), + vec![r#"echo a\&\&b"#] + ); + } + + // --- validate_command compound-bypass closure (T1.5) --- + + #[test] + fn validate_command_blocks_destructive_after_safe_in_chain() { + let workspace = std::env::current_dir().unwrap(); + // Pre-T1.5: this passed because only "ls -la" was inspected. + assert!(matches!( + validate_command("ls -la && rm -rf /tmp/x", PermissionMode::ReadOnly, &workspace), + ValidationResult::Block { .. } + )); + } + + #[test] + fn validate_command_allows_chain_of_safe_commands() { + let workspace = std::env::current_dir().unwrap(); + assert_eq!( + validate_command("ls -la && pwd && echo hi", PermissionMode::ReadOnly, &workspace), + ValidationResult::Allow + ); + } + + // Note: I considered a test that `echo "ls && rm -rf /"` should be Allow + // because the quoted text is not a separate command. The split correctly + // returns one segment, but `check_destructive` (correctly) scans the + // whole string for `rm -rf /`-like fork-bomb patterns and blocks anyway. + // Pre-existing paranoid behavior, not a regression from T1.5. } diff --git a/rust/crates/runtime/src/config.rs b/rust/crates/runtime/src/config.rs index 1566189282..8e591aa894 100644 --- a/rust/crates/runtime/src/config.rs +++ b/rust/crates/runtime/src/config.rs @@ -77,11 +77,25 @@ pub struct ProviderFallbackConfig { } /// Hook command lists grouped by lifecycle stage. +/// +/// T2.1: Extended from 3 to 10 lifecycle events for Claude Code parity. The +/// 3-arg `new()` constructor is preserved for backward compatibility with +/// existing call sites; the additional events default to empty and can be +/// populated either via settings.json parsing or the dedicated builder +/// methods. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct RuntimeHookConfig { pre_tool_use: Vec, post_tool_use: Vec, post_tool_use_failure: Vec, + stop: Vec, + stop_failure: Vec, + user_prompt_submit: Vec, + session_start: Vec, + session_end: Vec, + post_tool_batch: Vec, + permission_request: Vec, + instructions_loaded: Vec, } /// Raw permission rule lists grouped by allow, deny, and ask behavior. @@ -575,6 +589,7 @@ impl RuntimeHookConfig { pre_tool_use, post_tool_use, post_tool_use_failure, + ..Default::default() } } @@ -602,12 +617,54 @@ impl RuntimeHookConfig { &mut self.post_tool_use_failure, other.post_tool_use_failure(), ); + extend_unique(&mut self.stop, other.stop()); + extend_unique(&mut self.stop_failure, other.stop_failure()); + extend_unique(&mut self.user_prompt_submit, other.user_prompt_submit()); + extend_unique(&mut self.session_start, other.session_start()); + extend_unique(&mut self.session_end, other.session_end()); + extend_unique(&mut self.post_tool_batch, other.post_tool_batch()); + extend_unique(&mut self.permission_request, other.permission_request()); + extend_unique(&mut self.instructions_loaded, other.instructions_loaded()); } #[must_use] pub fn post_tool_use_failure(&self) -> &[String] { &self.post_tool_use_failure } + + // T2.1: Claude Code parity event accessors. + #[must_use] + pub fn stop(&self) -> &[String] { + &self.stop + } + #[must_use] + pub fn stop_failure(&self) -> &[String] { + &self.stop_failure + } + #[must_use] + pub fn user_prompt_submit(&self) -> &[String] { + &self.user_prompt_submit + } + #[must_use] + pub fn session_start(&self) -> &[String] { + &self.session_start + } + #[must_use] + pub fn session_end(&self) -> &[String] { + &self.session_end + } + #[must_use] + pub fn post_tool_batch(&self) -> &[String] { + &self.post_tool_batch + } + #[must_use] + pub fn permission_request(&self) -> &[String] { + &self.permission_request + } + #[must_use] + pub fn instructions_loaded(&self) -> &[String] { + &self.instructions_loaded + } } impl RuntimePermissionRuleConfig { @@ -767,6 +824,18 @@ fn parse_optional_hooks_config_object( post_tool_use: optional_string_array(hooks, "PostToolUse", context)?.unwrap_or_default(), post_tool_use_failure: optional_string_array(hooks, "PostToolUseFailure", context)? .unwrap_or_default(), + stop: optional_string_array(hooks, "Stop", context)?.unwrap_or_default(), + stop_failure: optional_string_array(hooks, "StopFailure", context)?.unwrap_or_default(), + user_prompt_submit: optional_string_array(hooks, "UserPromptSubmit", context)? + .unwrap_or_default(), + session_start: optional_string_array(hooks, "SessionStart", context)?.unwrap_or_default(), + session_end: optional_string_array(hooks, "SessionEnd", context)?.unwrap_or_default(), + post_tool_batch: optional_string_array(hooks, "PostToolBatch", context)? + .unwrap_or_default(), + permission_request: optional_string_array(hooks, "PermissionRequest", context)? + .unwrap_or_default(), + instructions_loaded: optional_string_array(hooks, "InstructionsLoaded", context)? + .unwrap_or_default(), }) } diff --git a/rust/crates/runtime/src/hooks.rs b/rust/crates/runtime/src/hooks.rs index 6abd69fbbd..c78d0e03f6 100644 --- a/rust/crates/runtime/src/hooks.rs +++ b/rust/crates/runtime/src/hooks.rs @@ -23,6 +23,17 @@ pub enum HookEvent { PreToolUse, PostToolUse, PostToolUseFailure, + // T2.1: Claude Code parity events. Configurable from settings.json now; + // firing wired in stages — see conversation.rs lifecycle for which are + // currently emitted vs reserved for future PRs. + Stop, + StopFailure, + UserPromptSubmit, + SessionStart, + SessionEnd, + PostToolBatch, + PermissionRequest, + InstructionsLoaded, } impl HookEvent { @@ -32,6 +43,14 @@ impl HookEvent { Self::PreToolUse => "PreToolUse", Self::PostToolUse => "PostToolUse", Self::PostToolUseFailure => "PostToolUseFailure", + Self::Stop => "Stop", + Self::StopFailure => "StopFailure", + Self::UserPromptSubmit => "UserPromptSubmit", + Self::SessionStart => "SessionStart", + Self::SessionEnd => "SessionEnd", + Self::PostToolBatch => "PostToolBatch", + Self::PermissionRequest => "PermissionRequest", + Self::InstructionsLoaded => "InstructionsLoaded", } } } diff --git a/rust/crates/runtime/src/permissions.rs b/rust/crates/runtime/src/permissions.rs index 81340dddfe..d2efb5ca0b 100644 --- a/rust/crates/runtime/src/permissions.rs +++ b/rust/crates/runtime/src/permissions.rs @@ -395,6 +395,12 @@ fn parse_rule_matcher(content: &str) -> PermissionRuleMatcher { if unescaped.is_empty() || unescaped == "*" { PermissionRuleMatcher::Any } else if let Some(prefix) = unescaped.strip_suffix(":*") { + // Provider-specific colon-star form, e.g. WebFetch(domain:*). + PermissionRuleMatcher::Prefix(prefix.to_string()) + } else if let Some(prefix) = unescaped.strip_suffix('*') { + // T2.5: General trailing-`*` glob — `Bash(npm run *)`, `Bash(git *)`, + // `WebFetch(https://example.com/*)`. Matches any input whose subject + // starts with the literal prefix (everything before the `*`). PermissionRuleMatcher::Prefix(prefix.to_string()) } else { PermissionRuleMatcher::Exact(unescaped) diff --git a/rust/crates/runtime/src/prompt.rs b/rust/crates/runtime/src/prompt.rs index 1e6c4eda85..e7af15f39a 100644 --- a/rust/crates/runtime/src/prompt.rs +++ b/rust/crates/runtime/src/prompt.rs @@ -236,7 +236,7 @@ fn discover_instruction_files(cwd: &Path) -> std::io::Result> { directories.reverse(); let mut files = Vec::new(); - for dir in directories { + for dir in &directories { for candidate in [ dir.join("CLAUDE.md"), dir.join("CLAUDE.local.md"), @@ -246,9 +246,73 @@ fn discover_instruction_files(cwd: &Path) -> std::io::Result> { push_context_file(&mut files, candidate)?; } } + + // T2.4: Path-scoped rules — load every `.claude/rules/*.md` and + // `.claw/rules/*.md` file in the cwd ancestry. The frontmatter `paths:` + // glob filtering is intentionally deferred; rules currently load + // unconditionally. That's still useful for project-wide rules (the most + // common case) and avoids a half-implemented matcher. Rule files are + // sorted within each directory for deterministic ordering. + for dir in &directories { + for rules_dir in [dir.join(".claude").join("rules"), dir.join(".claw").join("rules")] { + if let Ok(entries) = fs::read_dir(&rules_dir) { + let mut rule_paths: Vec = entries + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("md")) + .collect(); + rule_paths.sort(); + for path in rule_paths { + push_context_file(&mut files, path)?; + } + } + } + } + + // T1.3: Auto-memory discovery. Reads from + // ~/.claude/projects//memory/ matching Claude Code's path + // convention so memory files persist across CC↔claw and are auto-attached + // to the system prompt. MEMORY.md (the index) is loaded first, then each + // .md topic file in deterministic order. + if let Some(memory_dir) = memory_dir_for_cwd(cwd) { + push_context_file(&mut files, memory_dir.join("MEMORY.md"))?; + if let Ok(entries) = fs::read_dir(&memory_dir) { + let mut topic_paths: Vec = entries + .flatten() + .map(|e| e.path()) + .filter(|p| { + p.extension().and_then(|e| e.to_str()) == Some("md") + && p.file_name().and_then(|n| n.to_str()) != Some("MEMORY.md") + }) + .collect(); + topic_paths.sort(); + for path in topic_paths { + push_context_file(&mut files, path)?; + } + } + } + Ok(dedupe_instruction_files(files)) } +/// Resolves the auto-memory directory for a given working directory, using +/// Claude Code's `/.claude/projects//memory/` convention +/// (slashes in the absolute cwd path are replaced with dashes). +fn memory_dir_for_cwd(cwd: &Path) -> Option { + if !cwd.is_absolute() { + return None; + } + let home = std::env::var_os("HOME")?; + let encoded = cwd.to_string_lossy().replace('/', "-"); + Some( + PathBuf::from(home) + .join(".claude") + .join("projects") + .join(encoded) + .join("memory"), + ) +} + fn push_context_file(files: &mut Vec, path: PathBuf) -> std::io::Result<()> { match fs::read_to_string(&path) { Ok(content) if !content.trim().is_empty() => { diff --git a/rust/crates/runtime/src/usage.rs b/rust/crates/runtime/src/usage.rs index 9241f7c2d7..312a92643d 100644 --- a/rust/crates/runtime/src/usage.rs +++ b/rust/crates/runtime/src/usage.rs @@ -55,10 +55,23 @@ impl UsageCostEstimate { } /// Returns pricing metadata for a known model alias or family. +/// +/// T1.4 update: extended beyond Anthropic-only with conservative public-tier +/// estimates for OpenAI, xAI Grok, and Groq-hosted Llama, plus a zero-cost +/// fallback for Ollama-style `name:tag` models (which only run locally). +/// Cache-read/-write costs default to the input rate when the upstream +/// provider doesn't support prompt caching. #[must_use] pub fn pricing_for_model(model: &str) -> Option { let normalized = model.to_ascii_lowercase(); - if normalized.contains("haiku") { + // Strip any `xai/` / `openai/` / `qwen/` etc. routing prefix so the + // family match below works on the canonical model name. + let canonical = normalized + .rsplit_once('/') + .map_or(normalized.as_str(), |(_, rest)| rest); + + // Anthropic family — original entries. + if canonical.contains("haiku") { return Some(ModelPricing { input_cost_per_million: 1.0, output_cost_per_million: 5.0, @@ -66,7 +79,7 @@ pub fn pricing_for_model(model: &str) -> Option { cache_read_cost_per_million: 0.1, }); } - if normalized.contains("opus") { + if canonical.contains("opus") { return Some(ModelPricing { input_cost_per_million: 15.0, output_cost_per_million: 75.0, @@ -74,9 +87,92 @@ pub fn pricing_for_model(model: &str) -> Option { cache_read_cost_per_million: 1.5, }); } - if normalized.contains("sonnet") { + if canonical.contains("sonnet") { return Some(ModelPricing::default_sonnet_tier()); } + + // Local Ollama models use `name:tag` syntax (e.g. `qwen2.5-coder:14b`), + // which never appears in cloud-API model identifiers. Treat as zero-cost. + if canonical.contains(':') { + return Some(ModelPricing { + input_cost_per_million: 0.0, + output_cost_per_million: 0.0, + cache_creation_cost_per_million: 0.0, + cache_read_cost_per_million: 0.0, + }); + } + + // OpenAI GPT-family (public API tier estimates as of 2026). + if canonical.starts_with("gpt-5") { + return Some(ModelPricing { + input_cost_per_million: 10.0, + output_cost_per_million: 30.0, + cache_creation_cost_per_million: 10.0, + cache_read_cost_per_million: 2.5, + }); + } + if canonical.starts_with("gpt-4o") || canonical.starts_with("gpt-4.1") { + return Some(ModelPricing { + input_cost_per_million: 2.50, + output_cost_per_million: 10.0, + cache_creation_cost_per_million: 2.50, + cache_read_cost_per_million: 1.25, + }); + } + if canonical.starts_with("o1") || canonical.starts_with("o3") || canonical.starts_with("o4") { + return Some(ModelPricing { + input_cost_per_million: 15.0, + output_cost_per_million: 60.0, + cache_creation_cost_per_million: 15.0, + cache_read_cost_per_million: 7.5, + }); + } + + // xAI Grok family. + if canonical.starts_with("grok-2") { + return Some(ModelPricing { + input_cost_per_million: 2.0, + output_cost_per_million: 10.0, + cache_creation_cost_per_million: 2.0, + cache_read_cost_per_million: 2.0, + }); + } + if canonical.starts_with("grok-3-mini") { + return Some(ModelPricing { + input_cost_per_million: 0.30, + output_cost_per_million: 0.50, + cache_creation_cost_per_million: 0.30, + cache_read_cost_per_million: 0.30, + }); + } + if canonical.starts_with("grok-3") || canonical == "grok" { + return Some(ModelPricing { + input_cost_per_million: 3.0, + output_cost_per_million: 15.0, + cache_creation_cost_per_million: 3.0, + cache_read_cost_per_million: 3.0, + }); + } + + // Groq-hosted Llama (paid tier; free tier reports same dollar cost but + // user pays $0 — accuracy is more important than zero-display here). + if canonical.starts_with("llama-3.3-70b") || canonical.starts_with("llama-3.1-70b") { + return Some(ModelPricing { + input_cost_per_million: 0.59, + output_cost_per_million: 0.79, + cache_creation_cost_per_million: 0.59, + cache_read_cost_per_million: 0.59, + }); + } + if canonical.starts_with("llama-3.1-8b") || canonical.starts_with("llama-3.2") { + return Some(ModelPricing { + input_cost_per_million: 0.05, + output_cost_per_million: 0.08, + cache_creation_cost_per_million: 0.05, + cache_read_cost_per_million: 0.05, + }); + } + None } @@ -310,4 +406,58 @@ mod tests { assert_eq!(tracker.turns(), 1); assert_eq!(tracker.cumulative_usage().total_tokens(), 8); } + + // --- pricing_for_model extended families (T1.4) --- + + #[test] + fn pricing_local_ollama_zero_cost() { + // `name:tag` syntax is Ollama-only; never appears in cloud APIs. + let p = pricing_for_model("llama3.2:3b").expect("local model has pricing"); + assert_eq!(p.input_cost_per_million, 0.0); + assert_eq!(p.output_cost_per_million, 0.0); + let p2 = pricing_for_model("openai/qwen2.5-coder:14b").expect("prefixed local model"); + assert_eq!(p2.output_cost_per_million, 0.0); + } + + #[test] + fn pricing_groq_llama_70b() { + let p = pricing_for_model("llama-3.3-70b-versatile").expect("groq model has pricing"); + assert!(p.output_cost_per_million > 0.0); + assert!(p.output_cost_per_million < 1.0); + } + + #[test] + fn pricing_xai_grok_3() { + let p = pricing_for_model("xai/grok-3").expect("xai prefix routing"); + assert!(p.output_cost_per_million >= 10.0); + } + + #[test] + fn pricing_openai_gpt_5() { + let p = pricing_for_model("openai/gpt-5.2").expect("gpt-5 family"); + assert!(p.output_cost_per_million >= 20.0); + } + + #[test] + fn pricing_unknown_returns_none() { + // Random model name no longer silently defaults to sonnet pricing. + assert!(pricing_for_model("totally-made-up-model-2099").is_none()); + } + + #[test] + fn pricing_anthropic_unchanged() { + // Regression check that the original Anthropic entries still match. + assert_eq!( + pricing_for_model("claude-haiku-4-5-20251213") + .unwrap() + .input_cost_per_million, + 1.0 + ); + assert_eq!( + pricing_for_model("claude-opus-4-6") + .unwrap() + .output_cost_per_million, + 75.0 + ); + } } diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index df4d8da452..fb7e1ffb86 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -24,11 +24,11 @@ use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant, UNIX_EPOCH}; use api::{ - detect_provider_kind, model_family_identity_for, resolve_startup_auth_source, AnthropicClient, - AuthSource, ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest, - MessageResponse, OutputContentBlock, PromptCache, ProviderClient as ApiProviderClient, - ProviderKind, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, - ToolResultContentBlock, + detect_provider_kind, max_tokens_for_model_with_override, model_family_identity_for, + resolve_startup_auth_source, AnthropicClient, AuthSource, ContentBlockDelta, InputContentBlock, + InputMessage, MessageRequest, MessageResponse, OutputContentBlock, PromptCache, + ProviderClient as ApiProviderClient, ProviderKind, StreamEvent as ApiStreamEvent, ToolChoice, + ToolDefinition, ToolResultContentBlock, }; use commands::{ @@ -148,9 +148,6 @@ impl ModelProvenance { } } -fn max_tokens_for_model(model: &str) -> u32 { - api::max_tokens_for_model(model) -} // Build-time constants injected by build.rs (fall back to static values when // build.rs hasn't run, e.g. in doc-test or unusual toolchain environments). const DEFAULT_DATE: &str = match option_env!("BUILD_DATE") { @@ -405,7 +402,8 @@ fn run() -> Result<(), Box> { None }; let effective_prompt = merge_prompt_with_stdin(&prompt, stdin_context.as_deref()); - let mut cli = LiveCli::new(model, true, allowed_tools, permission_mode)?; + let enable_tools = std::env::var_os("CLAW_NO_TOOLS").is_none(); + let mut cli = LiveCli::new(model, enable_tools, allowed_tools, permission_mode)?; cli.set_reasoning_effort(reasoning_effort); cli.run_turn_with_output(&effective_prompt, output_format, compact)?; } @@ -1482,6 +1480,10 @@ fn validate_model_syntax(model: &str) -> Result<(), String> { "opus" | "sonnet" | "haiku" => return Ok(()), _ => {} } + // User-defined aliases from .claw.json + if config_alias_for_current_dir(trimmed).is_some() { + return Ok(()); + } // Check for spaces (malformed) if trimmed.contains(' ') { return Err(format!( @@ -3837,7 +3839,8 @@ fn run_repl( enforce_broad_cwd_policy(allow_broad_cwd, CliOutputFormat::Text)?; run_stale_base_preflight(base_commit.as_deref()); let resolved_model = resolve_repl_model(model); - let mut cli = LiveCli::new(resolved_model, true, allowed_tools, permission_mode)?; + let enable_tools = std::env::var_os("CLAW_NO_TOOLS").is_none(); + let mut cli = LiveCli::new(resolved_model, enable_tools, allowed_tools, permission_mode)?; cli.set_reasoning_effort(reasoning_effort); let mut editor = input::LineEditor::new("> ", cli.repl_completion_candidates().unwrap_or_default()); @@ -5380,11 +5383,17 @@ impl LiveCli { let removed = result.removed_message_count; let kept = result.compacted_session.messages.len(); let skipped = removed == 0; + // T1.2: Re-discover system prompt from disk so post-compact context + // includes any CLAUDE.md / instruction-file changes since startup. + // Falls back to the cached prompt if discovery fails (e.g. cwd vanished). + let fresh_system_prompt = + build_system_prompt().unwrap_or_else(|_| self.system_prompt.clone()); + self.system_prompt = fresh_system_prompt.clone(); let runtime = build_runtime( result.compacted_session, &self.session.id, self.model.clone(), - self.system_prompt.clone(), + fresh_system_prompt, true, true, self.allowed_tools.clone(), @@ -7740,6 +7749,7 @@ fn build_runtime_with_plugin_state( allowed_tools.clone(), tool_registry.clone(), progress_reporter, + feature_config.plugins().max_output_tokens(), )?, CliToolExecutor::new( allowed_tools.clone(), @@ -7856,6 +7866,7 @@ struct AnthropicRuntimeClient { tool_registry: GlobalToolRegistry, progress_reporter: Option, reasoning_effort: Option, + max_output_tokens_override: Option, } impl AnthropicRuntimeClient { @@ -7867,6 +7878,7 @@ impl AnthropicRuntimeClient { allowed_tools: Option, tool_registry: GlobalToolRegistry, progress_reporter: Option, + max_output_tokens_override: Option, ) -> Result> { // Dispatch to the correct provider at construction time. // `ApiProviderClient` (exposed by the api crate as @@ -7921,6 +7933,7 @@ impl AnthropicRuntimeClient { tool_registry, progress_reporter, reasoning_effort: None, + max_output_tokens_override, }) } @@ -7946,7 +7959,10 @@ impl ApiClient for AnthropicRuntimeClient { let is_post_tool = request_ends_with_tool_result(&request); let message_request = MessageRequest { model: self.model.clone(), - max_tokens: max_tokens_for_model(&self.model), + max_tokens: max_tokens_for_model_with_override( + &self.model, + self.max_output_tokens_override, + ), messages: convert_messages(&request.messages), system: (!request.system_prompt.is_empty()).then(|| request.system_prompt.join("\n\n")), tools: self