Parcourir la source

feat: git integration, sandbox isolation, init command (merged from rcc branches)

Yeachan-Heo il y a 2 mois
Parent
commit
387a8bb13f

+ 0 - 5
rust/Cargo.lock

@@ -1091,11 +1091,8 @@ dependencies = [
  "compat-harness",
  "crossterm",
  "pulldown-cmark",
- "reqwest",
  "runtime",
- "serde",
  "serde_json",
- "sha2",
  "syntect",
  "tokio",
  "tools",
@@ -1434,12 +1431,10 @@ dependencies = [
 name = "tools"
 version = "0.1.0"
 dependencies = [
- "api",
  "reqwest",
  "runtime",
  "serde",
  "serde_json",
- "tokio",
 ]
 
 [[package]]

+ 0 - 1
rust/crates/api/src/client.rs

@@ -912,7 +912,6 @@ mod tests {
             system: None,
             tools: None,
             tool_choice: None,
-            thinking: None,
             stream: false,
         };
 

+ 2 - 2
rust/crates/api/src/lib.rs

@@ -11,7 +11,7 @@ pub use error::ApiError;
 pub use sse::{parse_frame, SseParser};
 pub use types::{
     ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent,
-    ImageSource, InputContentBlock, InputMessage, MessageDelta, MessageDeltaEvent, MessageRequest,
+    InputContentBlock, InputMessage, MessageDelta, MessageDeltaEvent, MessageRequest,
     MessageResponse, MessageStartEvent, MessageStopEvent, OutputContentBlock, StreamEvent,
-    ThinkingConfig, ToolChoice, ToolDefinition, ToolResultContentBlock, Usage,
+    ToolChoice, ToolDefinition, ToolResultContentBlock, Usage,
 };

+ 0 - 37
rust/crates/api/src/types.rs

@@ -12,8 +12,6 @@ pub struct MessageRequest {
     pub tools: Option<Vec<ToolDefinition>>,
     #[serde(skip_serializing_if = "Option::is_none")]
     pub tool_choice: Option<ToolChoice>,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub thinking: Option<ThinkingConfig>,
     #[serde(default, skip_serializing_if = "std::ops::Not::not")]
     pub stream: bool,
 }
@@ -26,23 +24,6 @@ impl MessageRequest {
     }
 }
 
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct ThinkingConfig {
-    #[serde(rename = "type")]
-    pub kind: String,
-    pub budget_tokens: u32,
-}
-
-impl ThinkingConfig {
-    #[must_use]
-    pub fn enabled(budget_tokens: u32) -> Self {
-        Self {
-            kind: "enabled".to_string(),
-            budget_tokens,
-        }
-    }
-}
-
 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
 pub struct InputMessage {
     pub role: String,
@@ -83,9 +64,6 @@ pub enum InputContentBlock {
     Text {
         text: String,
     },
-    Image {
-        source: ImageSource,
-    },
     ToolUse {
         id: String,
         name: String,
@@ -99,14 +77,6 @@ pub enum InputContentBlock {
     },
 }
 
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct ImageSource {
-    #[serde(rename = "type")]
-    pub kind: String,
-    pub media_type: String,
-    pub data: String,
-}
-
 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
 #[serde(tag = "type", rename_all = "snake_case")]
 pub enum ToolResultContentBlock {
@@ -160,11 +130,6 @@ pub enum OutputContentBlock {
     Text {
         text: String,
     },
-    Thinking {
-        thinking: String,
-        #[serde(default, skip_serializing_if = "Option::is_none")]
-        signature: Option<String>,
-    },
     ToolUse {
         id: String,
         name: String,
@@ -224,8 +189,6 @@ pub struct ContentBlockDeltaEvent {
 #[serde(tag = "type", rename_all = "snake_case")]
 pub enum ContentBlockDelta {
     TextDelta { text: String },
-    ThinkingDelta { thinking: String },
-    SignatureDelta { signature: String },
     InputJsonDelta { partial_json: String },
 }
 

+ 2 - 37
rust/crates/api/tests/client_integration.rs

@@ -4,8 +4,8 @@ use std::time::Duration;
 
 use api::{
     AnthropicClient, ApiError, ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent,
-    ImageSource, InputContentBlock, InputMessage, MessageDeltaEvent, MessageRequest,
-    OutputContentBlock, StreamEvent, ToolChoice, ToolDefinition,
+    InputContentBlock, InputMessage, MessageDeltaEvent, MessageRequest, OutputContentBlock,
+    StreamEvent, ToolChoice, ToolDefinition,
 };
 use serde_json::json;
 use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -75,39 +75,6 @@ async fn send_message_posts_json_and_parses_response() {
     assert_eq!(body["tool_choice"]["type"], json!("auto"));
 }
 
-#[test]
-fn image_content_blocks_serialize_with_base64_source() {
-    let request = MessageRequest {
-        model: "claude-3-7-sonnet-latest".to_string(),
-        max_tokens: 64,
-        messages: vec![InputMessage {
-            role: "user".to_string(),
-            content: vec![InputContentBlock::Image {
-                source: ImageSource {
-                    kind: "base64".to_string(),
-                    media_type: "image/png".to_string(),
-                    data: "AQID".to_string(),
-                },
-            }],
-        }],
-        system: None,
-        tools: None,
-        tool_choice: None,
-        stream: false,
-    };
-
-    let json = serde_json::to_value(request).expect("request should serialize");
-    assert_eq!(json["messages"][0]["content"][0]["type"], json!("image"));
-    assert_eq!(
-        json["messages"][0]["content"][0]["source"],
-        json!({
-            "type": "base64",
-            "media_type": "image/png",
-            "data": "AQID"
-        })
-    );
-}
-
 #[tokio::test]
 async fn stream_message_parses_sse_events_with_tool_use() {
     let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
@@ -291,7 +258,6 @@ async fn live_stream_smoke_test() {
             system: None,
             tools: None,
             tool_choice: None,
-            thinking: None,
             stream: false,
         })
         .await
@@ -472,7 +438,6 @@ fn sample_request(stream: bool) -> MessageRequest {
             }),
         }]),
         tool_choice: Some(ToolChoice::Auto),
-        thinking: None,
         stream,
     }
 }

+ 2 - 39
rust/crates/commands/src/lib.rs

@@ -57,12 +57,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
         argument_hint: None,
         resume_supported: true,
     },
-    SlashCommandSpec {
-        name: "thinking",
-        summary: "Show or toggle extended thinking",
-        argument_hint: Some("[on|off]"),
-        resume_supported: false,
-    },
     SlashCommandSpec {
         name: "model",
         summary: "Show or switch the active model",
@@ -107,7 +101,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
     },
     SlashCommandSpec {
         name: "init",
-        summary: "Bootstrap Claude project files for this repo",
+        summary: "Create a starter CLAUDE.md for this repo",
         argument_hint: None,
         resume_supported: true,
     },
@@ -142,9 +136,6 @@ pub enum SlashCommand {
     Help,
     Status,
     Compact,
-    Thinking {
-        enabled: Option<bool>,
-    },
     Model {
         model: Option<String>,
     },
@@ -189,13 +180,6 @@ impl SlashCommand {
             "help" => Self::Help,
             "status" => Self::Status,
             "compact" => Self::Compact,
-            "thinking" => Self::Thinking {
-                enabled: match parts.next() {
-                    Some("on") => Some(true),
-                    Some("off") => Some(false),
-                    Some(_) | None => None,
-                },
-            },
             "model" => Self::Model {
                 model: parts.next().map(ToOwned::to_owned),
             },
@@ -295,7 +279,6 @@ pub fn handle_slash_command(
             session: session.clone(),
         }),
         SlashCommand::Status
-        | SlashCommand::Thinking { .. }
         | SlashCommand::Model { .. }
         | SlashCommand::Permissions { .. }
         | SlashCommand::Clear { .. }
@@ -324,22 +307,6 @@ mod tests {
     fn parses_supported_slash_commands() {
         assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help));
         assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status));
-        assert_eq!(
-            SlashCommand::parse("/thinking on"),
-            Some(SlashCommand::Thinking {
-                enabled: Some(true),
-            })
-        );
-        assert_eq!(
-            SlashCommand::parse("/thinking off"),
-            Some(SlashCommand::Thinking {
-                enabled: Some(false),
-            })
-        );
-        assert_eq!(
-            SlashCommand::parse("/thinking"),
-            Some(SlashCommand::Thinking { enabled: None })
-        );
         assert_eq!(
             SlashCommand::parse("/model claude-opus"),
             Some(SlashCommand::Model {
@@ -407,7 +374,6 @@ mod tests {
         assert!(help.contains("/help"));
         assert!(help.contains("/status"));
         assert!(help.contains("/compact"));
-        assert!(help.contains("/thinking [on|off]"));
         assert!(help.contains("/model [model]"));
         assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
         assert!(help.contains("/clear [--confirm]"));
@@ -420,7 +386,7 @@ mod tests {
         assert!(help.contains("/version"));
         assert!(help.contains("/export [file]"));
         assert!(help.contains("/session [list|switch <session-id>]"));
-        assert_eq!(slash_command_specs().len(), 16);
+        assert_eq!(slash_command_specs().len(), 15);
         assert_eq!(resume_supported_slash_commands().len(), 11);
     }
 
@@ -468,9 +434,6 @@ mod tests {
         let session = Session::new();
         assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none());
         assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none());
-        assert!(
-            handle_slash_command("/thinking on", &session, CompactionConfig::default()).is_none()
-        );
         assert!(
             handle_slash_command("/model claude", &session, CompactionConfig::default()).is_none()
         );

+ 130 - 7
rust/crates/runtime/src/bash.rs

@@ -1,3 +1,4 @@
+use std::env;
 use std::io;
 use std::process::{Command, Stdio};
 use std::time::Duration;
@@ -7,6 +8,12 @@ use tokio::process::Command as TokioCommand;
 use tokio::runtime::Builder;
 use tokio::time::timeout;
 
+use crate::sandbox::{
+    build_linux_sandbox_command, resolve_sandbox_status_for_request, FilesystemIsolationMode,
+    SandboxConfig, SandboxStatus,
+};
+use crate::ConfigLoader;
+
 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
 pub struct BashCommandInput {
     pub command: String,
@@ -16,6 +23,14 @@ pub struct BashCommandInput {
     pub run_in_background: Option<bool>,
     #[serde(rename = "dangerouslyDisableSandbox")]
     pub dangerously_disable_sandbox: Option<bool>,
+    #[serde(rename = "namespaceRestrictions")]
+    pub namespace_restrictions: Option<bool>,
+    #[serde(rename = "isolateNetwork")]
+    pub isolate_network: Option<bool>,
+    #[serde(rename = "filesystemMode")]
+    pub filesystem_mode: Option<FilesystemIsolationMode>,
+    #[serde(rename = "allowedMounts")]
+    pub allowed_mounts: Option<Vec<String>>,
 }
 
 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -45,13 +60,17 @@ pub struct BashCommandOutput {
     pub persisted_output_path: Option<String>,
     #[serde(rename = "persistedOutputSize")]
     pub persisted_output_size: Option<u64>,
+    #[serde(rename = "sandboxStatus")]
+    pub sandbox_status: Option<SandboxStatus>,
 }
 
 pub fn execute_bash(input: BashCommandInput) -> io::Result<BashCommandOutput> {
+    let cwd = env::current_dir()?;
+    let sandbox_status = sandbox_status_for_input(&input, &cwd);
+
     if input.run_in_background.unwrap_or(false) {
-        let child = Command::new("sh")
-            .arg("-lc")
-            .arg(&input.command)
+        let mut child = prepare_command(&input.command, &cwd, &sandbox_status, false);
+        let child = child
             .stdin(Stdio::null())
             .stdout(Stdio::null())
             .stderr(Stdio::null())
@@ -72,16 +91,20 @@ pub fn execute_bash(input: BashCommandInput) -> io::Result<BashCommandOutput> {
             structured_content: None,
             persisted_output_path: None,
             persisted_output_size: None,
+            sandbox_status: Some(sandbox_status),
         });
     }
 
     let runtime = Builder::new_current_thread().enable_all().build()?;
-    runtime.block_on(execute_bash_async(input))
+    runtime.block_on(execute_bash_async(input, sandbox_status, cwd))
 }
 
-async fn execute_bash_async(input: BashCommandInput) -> io::Result<BashCommandOutput> {
-    let mut command = TokioCommand::new("sh");
-    command.arg("-lc").arg(&input.command);
+async fn execute_bash_async(
+    input: BashCommandInput,
+    sandbox_status: SandboxStatus,
+    cwd: std::path::PathBuf,
+) -> io::Result<BashCommandOutput> {
+    let mut command = prepare_tokio_command(&input.command, &cwd, &sandbox_status, true);
 
     let output_result = if let Some(timeout_ms) = input.timeout {
         match timeout(Duration::from_millis(timeout_ms), command.output()).await {
@@ -102,6 +125,7 @@ async fn execute_bash_async(input: BashCommandInput) -> io::Result<BashCommandOu
                     structured_content: None,
                     persisted_output_path: None,
                     persisted_output_size: None,
+                    sandbox_status: Some(sandbox_status),
                 });
             }
         }
@@ -136,12 +160,88 @@ async fn execute_bash_async(input: BashCommandInput) -> io::Result<BashCommandOu
         structured_content: None,
         persisted_output_path: None,
         persisted_output_size: None,
+        sandbox_status: Some(sandbox_status),
     })
 }
 
+fn sandbox_status_for_input(input: &BashCommandInput, cwd: &std::path::Path) -> SandboxStatus {
+    let config = ConfigLoader::default_for(cwd).load().map_or_else(
+        |_| SandboxConfig::default(),
+        |runtime_config| runtime_config.sandbox().clone(),
+    );
+    let request = config.resolve_request(
+        input.dangerously_disable_sandbox.map(|disabled| !disabled),
+        input.namespace_restrictions,
+        input.isolate_network,
+        input.filesystem_mode,
+        input.allowed_mounts.clone(),
+    );
+    resolve_sandbox_status_for_request(&request, cwd)
+}
+
+fn prepare_command(
+    command: &str,
+    cwd: &std::path::Path,
+    sandbox_status: &SandboxStatus,
+    create_dirs: bool,
+) -> Command {
+    if create_dirs {
+        prepare_sandbox_dirs(cwd);
+    }
+
+    if let Some(launcher) = build_linux_sandbox_command(command, cwd, sandbox_status) {
+        let mut prepared = Command::new(launcher.program);
+        prepared.args(launcher.args);
+        prepared.current_dir(cwd);
+        prepared.envs(launcher.env);
+        return prepared;
+    }
+
+    let mut prepared = Command::new("sh");
+    prepared.arg("-lc").arg(command).current_dir(cwd);
+    if sandbox_status.filesystem_active {
+        prepared.env("HOME", cwd.join(".sandbox-home"));
+        prepared.env("TMPDIR", cwd.join(".sandbox-tmp"));
+    }
+    prepared
+}
+
+fn prepare_tokio_command(
+    command: &str,
+    cwd: &std::path::Path,
+    sandbox_status: &SandboxStatus,
+    create_dirs: bool,
+) -> TokioCommand {
+    if create_dirs {
+        prepare_sandbox_dirs(cwd);
+    }
+
+    if let Some(launcher) = build_linux_sandbox_command(command, cwd, sandbox_status) {
+        let mut prepared = TokioCommand::new(launcher.program);
+        prepared.args(launcher.args);
+        prepared.current_dir(cwd);
+        prepared.envs(launcher.env);
+        return prepared;
+    }
+
+    let mut prepared = TokioCommand::new("sh");
+    prepared.arg("-lc").arg(command).current_dir(cwd);
+    if sandbox_status.filesystem_active {
+        prepared.env("HOME", cwd.join(".sandbox-home"));
+        prepared.env("TMPDIR", cwd.join(".sandbox-tmp"));
+    }
+    prepared
+}
+
+fn prepare_sandbox_dirs(cwd: &std::path::Path) {
+    let _ = std::fs::create_dir_all(cwd.join(".sandbox-home"));
+    let _ = std::fs::create_dir_all(cwd.join(".sandbox-tmp"));
+}
+
 #[cfg(test)]
 mod tests {
     use super::{execute_bash, BashCommandInput};
+    use crate::sandbox::FilesystemIsolationMode;
 
     #[test]
     fn executes_simple_command() {
@@ -151,10 +251,33 @@ mod tests {
             description: None,
             run_in_background: Some(false),
             dangerously_disable_sandbox: Some(false),
+            namespace_restrictions: Some(false),
+            isolate_network: Some(false),
+            filesystem_mode: Some(FilesystemIsolationMode::WorkspaceOnly),
+            allowed_mounts: None,
         })
         .expect("bash command should execute");
 
         assert_eq!(output.stdout, "hello");
         assert!(!output.interrupted);
+        assert!(output.sandbox_status.is_some());
+    }
+
+    #[test]
+    fn disables_sandbox_when_requested() {
+        let output = execute_bash(BashCommandInput {
+            command: String::from("printf 'hello'"),
+            timeout: Some(1_000),
+            description: None,
+            run_in_background: Some(false),
+            dangerously_disable_sandbox: Some(true),
+            namespace_restrictions: None,
+            isolate_network: None,
+            filesystem_mode: None,
+            allowed_mounts: None,
+        })
+        .expect("bash command should execute");
+
+        assert!(!output.sandbox_status.expect("sandbox status").enabled);
     }
 }

+ 6 - 112
rust/crates/runtime/src/compact.rs

@@ -1,6 +1,3 @@
-use std::fs;
-use std::time::{SystemTime, UNIX_EPOCH};
-
 use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -93,7 +90,6 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
     let preserved = session.messages[keep_from..].to_vec();
     let summary = summarize_messages(removed);
     let formatted_summary = format_compact_summary(&summary);
-    persist_compact_summary(&formatted_summary);
     let continuation = get_compact_continuation_message(&summary, true, !preserved.is_empty());
 
     let mut compacted_messages = vec![ConversationMessage {
@@ -109,41 +105,11 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
         compacted_session: Session {
             version: session.version,
             messages: compacted_messages,
-            metadata: session.metadata.clone(),
         },
         removed_message_count: removed.len(),
     }
 }
 
-fn persist_compact_summary(formatted_summary: &str) {
-    if formatted_summary.trim().is_empty() {
-        return;
-    }
-
-    let Ok(cwd) = std::env::current_dir() else {
-        return;
-    };
-    let memory_dir = cwd.join(".claude").join("memory");
-    if fs::create_dir_all(&memory_dir).is_err() {
-        return;
-    }
-
-    let path = memory_dir.join(compact_summary_filename());
-    let _ = fs::write(path, render_memory_file(formatted_summary));
-}
-
-fn compact_summary_filename() -> String {
-    let timestamp = SystemTime::now()
-        .duration_since(UNIX_EPOCH)
-        .unwrap_or_default()
-        .as_secs();
-    format!("summary-{timestamp}.md")
-}
-
-fn render_memory_file(formatted_summary: &str) -> String {
-    format!("# Project memory\n\n{}\n", formatted_summary.trim())
-}
-
 fn summarize_messages(messages: &[ConversationMessage]) -> String {
     let user_messages = messages
         .iter()
@@ -164,7 +130,7 @@ fn summarize_messages(messages: &[ConversationMessage]) -> String {
         .filter_map(|block| match block {
             ContentBlock::ToolUse { name, .. } => Some(name.as_str()),
             ContentBlock::ToolResult { tool_name, .. } => Some(tool_name.as_str()),
-            ContentBlock::Text { .. } | ContentBlock::Thinking { .. } => None,
+            ContentBlock::Text { .. } => None,
         })
         .collect::<Vec<_>>();
     tool_names.sort_unstable();
@@ -234,7 +200,6 @@ fn summarize_messages(messages: &[ConversationMessage]) -> String {
 fn summarize_block(block: &ContentBlock) -> String {
     let raw = match block {
         ContentBlock::Text { text } => text.clone(),
-        ContentBlock::Thinking { text, .. } => format!("thinking: {text}"),
         ContentBlock::ToolUse { name, input, .. } => format!("tool_use {name}({input})"),
         ContentBlock::ToolResult {
             tool_name,
@@ -293,7 +258,7 @@ fn collect_key_files(messages: &[ConversationMessage]) -> Vec<String> {
         .iter()
         .flat_map(|message| message.blocks.iter())
         .map(|block| match block {
-            ContentBlock::Text { text } | ContentBlock::Thinking { text, .. } => text.as_str(),
+            ContentBlock::Text { text } => text.as_str(),
             ContentBlock::ToolUse { input, .. } => input.as_str(),
             ContentBlock::ToolResult { output, .. } => output.as_str(),
         })
@@ -315,15 +280,10 @@ fn infer_current_work(messages: &[ConversationMessage]) -> Option<String> {
 
 fn first_text_block(message: &ConversationMessage) -> Option<&str> {
     message.blocks.iter().find_map(|block| match block {
-        ContentBlock::Text { text } | ContentBlock::Thinking { text, .. }
-            if !text.trim().is_empty() =>
-        {
-            Some(text.as_str())
-        }
+        ContentBlock::Text { text } if !text.trim().is_empty() => Some(text.as_str()),
         ContentBlock::ToolUse { .. }
         | ContentBlock::ToolResult { .. }
-        | ContentBlock::Text { .. }
-        | ContentBlock::Thinking { .. } => None,
+        | ContentBlock::Text { .. } => None,
     })
 }
 
@@ -368,7 +328,7 @@ fn estimate_message_tokens(message: &ConversationMessage) -> usize {
         .blocks
         .iter()
         .map(|block| match block {
-            ContentBlock::Text { text } | ContentBlock::Thinking { text, .. } => text.len() / 4 + 1,
+            ContentBlock::Text { text } => text.len() / 4 + 1,
             ContentBlock::ToolUse { name, input, .. } => (name.len() + input.len()) / 4 + 1,
             ContentBlock::ToolResult {
                 tool_name, output, ..
@@ -418,21 +378,14 @@ fn collapse_blank_lines(content: &str) -> String {
 mod tests {
     use super::{
         collect_key_files, compact_session, estimate_session_tokens, format_compact_summary,
-        infer_pending_work, render_memory_file, should_compact, CompactionConfig,
+        infer_pending_work, should_compact, CompactionConfig,
     };
-    use std::fs;
-    use std::time::{SystemTime, UNIX_EPOCH};
-
     use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
 
     #[test]
     fn formats_compact_summary_like_upstream() {
         let summary = "<analysis>scratch</analysis>\n<summary>Kept work</summary>";
         assert_eq!(format_compact_summary(summary), "Summary:\nKept work");
-        assert_eq!(
-            render_memory_file("Summary:\nKept work"),
-            "# Project memory\n\nSummary:\nKept work\n"
-        );
     }
 
     #[test]
@@ -440,7 +393,6 @@ mod tests {
         let session = Session {
             version: 1,
             messages: vec![ConversationMessage::user_text("hello")],
-            metadata: None,
         };
 
         let result = compact_session(&session, CompactionConfig::default());
@@ -450,63 +402,6 @@ mod tests {
         assert!(result.formatted_summary.is_empty());
     }
 
-    #[test]
-    fn persists_compacted_summaries_under_dot_claude_memory() {
-        let _guard = crate::test_env_lock();
-        let temp = std::env::temp_dir().join(format!(
-            "runtime-compact-memory-{}",
-            SystemTime::now()
-                .duration_since(UNIX_EPOCH)
-                .expect("time after epoch")
-                .as_nanos()
-        ));
-        fs::create_dir_all(&temp).expect("temp dir");
-        let previous = std::env::current_dir().expect("cwd");
-        std::env::set_current_dir(&temp).expect("set cwd");
-
-        let session = Session {
-            version: 1,
-            messages: vec![
-                ConversationMessage::user_text("one ".repeat(200)),
-                ConversationMessage::assistant(vec![ContentBlock::Text {
-                    text: "two ".repeat(200),
-                }]),
-                ConversationMessage::tool_result("1", "bash", "ok ".repeat(200), false),
-                ConversationMessage {
-                    role: MessageRole::Assistant,
-                    blocks: vec![ContentBlock::Text {
-                        text: "recent".to_string(),
-                    }],
-                    usage: None,
-                },
-            ],
-        };
-
-        let result = compact_session(
-            &session,
-            CompactionConfig {
-                preserve_recent_messages: 2,
-                max_estimated_tokens: 1,
-            },
-        );
-        let memory_dir = temp.join(".claude").join("memory");
-        let files = fs::read_dir(&memory_dir)
-            .expect("memory dir exists")
-            .flatten()
-            .map(|entry| entry.path())
-            .collect::<Vec<_>>();
-
-        assert_eq!(result.removed_message_count, 2);
-        assert_eq!(files.len(), 1);
-        let persisted = fs::read_to_string(&files[0]).expect("memory file readable");
-
-        std::env::set_current_dir(previous).expect("restore cwd");
-        fs::remove_dir_all(temp).expect("cleanup temp dir");
-
-        assert!(persisted.contains("# Project memory"));
-        assert!(persisted.contains("Summary:"));
-    }
-
     #[test]
     fn compacts_older_messages_into_a_system_summary() {
         let session = Session {
@@ -525,7 +420,6 @@ mod tests {
                     usage: None,
                 },
             ],
-            metadata: None,
         };
 
         let result = compact_session(

+ 88 - 0
rust/crates/runtime/src/config.rs

@@ -4,6 +4,7 @@ use std::fs;
 use std::path::{Path, PathBuf};
 
 use crate::json::JsonValue;
+use crate::sandbox::{FilesystemIsolationMode, SandboxConfig};
 
 pub const CLAUDE_CODE_SETTINGS_SCHEMA_NAME: &str = "SettingsSchema";
 
@@ -40,6 +41,7 @@ pub struct RuntimeFeatureConfig {
     oauth: Option<OAuthConfig>,
     model: Option<String>,
     permission_mode: Option<ResolvedPermissionMode>,
+    sandbox: SandboxConfig,
 }
 
 #[derive(Debug, Clone, PartialEq, Eq, Default)]
@@ -225,6 +227,7 @@ impl ConfigLoader {
             oauth: parse_optional_oauth_config(&merged_value, "merged settings.oauth")?,
             model: parse_optional_model(&merged_value),
             permission_mode: parse_optional_permission_mode(&merged_value)?,
+            sandbox: parse_optional_sandbox_config(&merged_value)?,
         };
 
         Ok(RuntimeConfig {
@@ -289,6 +292,11 @@ impl RuntimeConfig {
     pub fn permission_mode(&self) -> Option<ResolvedPermissionMode> {
         self.feature_config.permission_mode
     }
+
+    #[must_use]
+    pub fn sandbox(&self) -> &SandboxConfig {
+        &self.feature_config.sandbox
+    }
 }
 
 impl RuntimeFeatureConfig {
@@ -311,6 +319,11 @@ impl RuntimeFeatureConfig {
     pub fn permission_mode(&self) -> Option<ResolvedPermissionMode> {
         self.permission_mode
     }
+
+    #[must_use]
+    pub fn sandbox(&self) -> &SandboxConfig {
+        &self.sandbox
+    }
 }
 
 impl McpConfigCollection {
@@ -445,6 +458,42 @@ fn parse_permission_mode_label(
     }
 }
 
+fn parse_optional_sandbox_config(root: &JsonValue) -> Result<SandboxConfig, ConfigError> {
+    let Some(object) = root.as_object() else {
+        return Ok(SandboxConfig::default());
+    };
+    let Some(sandbox_value) = object.get("sandbox") else {
+        return Ok(SandboxConfig::default());
+    };
+    let sandbox = expect_object(sandbox_value, "merged settings.sandbox")?;
+    let filesystem_mode = optional_string(sandbox, "filesystemMode", "merged settings.sandbox")?
+        .map(parse_filesystem_mode_label)
+        .transpose()?;
+    Ok(SandboxConfig {
+        enabled: optional_bool(sandbox, "enabled", "merged settings.sandbox")?,
+        namespace_restrictions: optional_bool(
+            sandbox,
+            "namespaceRestrictions",
+            "merged settings.sandbox",
+        )?,
+        network_isolation: optional_bool(sandbox, "networkIsolation", "merged settings.sandbox")?,
+        filesystem_mode,
+        allowed_mounts: optional_string_array(sandbox, "allowedMounts", "merged settings.sandbox")?
+            .unwrap_or_default(),
+    })
+}
+
+fn parse_filesystem_mode_label(value: &str) -> Result<FilesystemIsolationMode, ConfigError> {
+    match value {
+        "off" => Ok(FilesystemIsolationMode::Off),
+        "workspace-only" => Ok(FilesystemIsolationMode::WorkspaceOnly),
+        "allow-list" => Ok(FilesystemIsolationMode::AllowList),
+        other => Err(ConfigError::Parse(format!(
+            "merged settings.sandbox.filesystemMode: unsupported filesystem mode {other}"
+        ))),
+    }
+}
+
 fn parse_optional_oauth_config(
     root: &JsonValue,
     context: &str,
@@ -688,6 +737,7 @@ mod tests {
         CLAUDE_CODE_SETTINGS_SCHEMA_NAME,
     };
     use crate::json::JsonValue;
+    use crate::sandbox::FilesystemIsolationMode;
     use std::fs;
     use std::time::{SystemTime, UNIX_EPOCH};
 
@@ -792,6 +842,44 @@ mod tests {
         fs::remove_dir_all(root).expect("cleanup temp dir");
     }
 
+    #[test]
+    fn parses_sandbox_config() {
+        let root = temp_dir();
+        let cwd = root.join("project");
+        let home = root.join("home").join(".claude");
+        fs::create_dir_all(cwd.join(".claude")).expect("project config dir");
+        fs::create_dir_all(&home).expect("home config dir");
+
+        fs::write(
+            cwd.join(".claude").join("settings.local.json"),
+            r#"{
+              "sandbox": {
+                "enabled": true,
+                "namespaceRestrictions": false,
+                "networkIsolation": true,
+                "filesystemMode": "allow-list",
+                "allowedMounts": ["logs", "tmp/cache"]
+              }
+            }"#,
+        )
+        .expect("write local settings");
+
+        let loaded = ConfigLoader::new(&cwd, &home)
+            .load()
+            .expect("config should load");
+
+        assert_eq!(loaded.sandbox().enabled, Some(true));
+        assert_eq!(loaded.sandbox().namespace_restrictions, Some(false));
+        assert_eq!(loaded.sandbox().network_isolation, Some(true));
+        assert_eq!(
+            loaded.sandbox().filesystem_mode,
+            Some(FilesystemIsolationMode::AllowList)
+        );
+        assert_eq!(loaded.sandbox().allowed_mounts, vec!["logs", "tmp/cache"]);
+
+        fs::remove_dir_all(root).expect("cleanup temp dir");
+    }
+
     #[test]
     fn parses_typed_mcp_and_oauth_config() {
         let root = temp_dir();

+ 4 - 54
rust/crates/runtime/src/conversation.rs

@@ -17,8 +17,6 @@ pub struct ApiRequest {
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub enum AssistantEvent {
     TextDelta(String),
-    ThinkingDelta(String),
-    ThinkingSignature(String),
     ToolUse {
         id: String,
         name: String,
@@ -249,26 +247,15 @@ fn build_assistant_message(
     events: Vec<AssistantEvent>,
 ) -> Result<(ConversationMessage, Option<TokenUsage>), RuntimeError> {
     let mut text = String::new();
-    let mut thinking = String::new();
-    let mut thinking_signature: Option<String> = None;
     let mut blocks = Vec::new();
     let mut finished = false;
     let mut usage = None;
 
     for event in events {
         match event {
-            AssistantEvent::TextDelta(delta) => {
-                flush_thinking_block(&mut thinking, &mut thinking_signature, &mut blocks);
-                text.push_str(&delta);
-            }
-            AssistantEvent::ThinkingDelta(delta) => {
-                flush_text_block(&mut text, &mut blocks);
-                thinking.push_str(&delta);
-            }
-            AssistantEvent::ThinkingSignature(signature) => thinking_signature = Some(signature),
+            AssistantEvent::TextDelta(delta) => text.push_str(&delta),
             AssistantEvent::ToolUse { id, name, input } => {
                 flush_text_block(&mut text, &mut blocks);
-                flush_thinking_block(&mut thinking, &mut thinking_signature, &mut blocks);
                 blocks.push(ContentBlock::ToolUse { id, name, input });
             }
             AssistantEvent::Usage(value) => usage = Some(value),
@@ -279,7 +266,6 @@ fn build_assistant_message(
     }
 
     flush_text_block(&mut text, &mut blocks);
-    flush_thinking_block(&mut thinking, &mut thinking_signature, &mut blocks);
 
     if !finished {
         return Err(RuntimeError::new(
@@ -304,19 +290,6 @@ fn flush_text_block(text: &mut String, blocks: &mut Vec<ContentBlock>) {
     }
 }
 
-fn flush_thinking_block(
-    thinking: &mut String,
-    signature: &mut Option<String>,
-    blocks: &mut Vec<ContentBlock>,
-) {
-    if !thinking.is_empty() || signature.is_some() {
-        blocks.push(ContentBlock::Thinking {
-            text: std::mem::take(thinking),
-            signature: signature.take(),
-        });
-    }
-}
-
 type ToolHandler = Box<dyn FnMut(&str) -> Result<String, ToolError>>;
 
 #[derive(Default)]
@@ -352,8 +325,8 @@ impl ToolExecutor for StaticToolExecutor {
 #[cfg(test)]
 mod tests {
     use super::{
-        build_assistant_message, ApiClient, ApiRequest, AssistantEvent, ConversationRuntime,
-        RuntimeError, StaticToolExecutor,
+        ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError,
+        StaticToolExecutor,
     };
     use crate::compact::CompactionConfig;
     use crate::permissions::{
@@ -441,8 +414,8 @@ mod tests {
                 cwd: PathBuf::from("/tmp/project"),
                 current_date: "2026-03-31".to_string(),
                 git_status: None,
+                git_diff: None,
                 instruction_files: Vec::new(),
-                memory_files: Vec::new(),
             })
             .with_os("linux", "6.8")
             .build();
@@ -530,29 +503,6 @@ mod tests {
         ));
     }
 
-    #[test]
-    fn thinking_blocks_are_preserved_separately_from_text() {
-        let (message, usage) = build_assistant_message(vec![
-            AssistantEvent::ThinkingDelta("first ".to_string()),
-            AssistantEvent::ThinkingDelta("second".to_string()),
-            AssistantEvent::ThinkingSignature("sig-1".to_string()),
-            AssistantEvent::TextDelta("final".to_string()),
-            AssistantEvent::MessageStop,
-        ])
-        .expect("assistant message should build");
-
-        assert_eq!(usage, None);
-        assert!(matches!(
-            &message.blocks[0],
-            ContentBlock::Thinking { text, signature }
-                if text == "first second" && signature.as_deref() == Some("sig-1")
-        ));
-        assert!(matches!(
-            &message.blocks[1],
-            ContentBlock::Text { text } if text == "final"
-        ));
-    }
-
     #[test]
     fn reconstructs_usage_tracker_from_restored_session() {
         struct SimpleApi;

+ 2 - 3
rust/crates/runtime/src/lib.rs

@@ -12,6 +12,7 @@ mod oauth;
 mod permissions;
 mod prompt;
 mod remote;
+pub mod sandbox;
 mod session;
 mod usage;
 
@@ -73,9 +74,7 @@ pub use remote::{
     RemoteSessionContext, UpstreamProxyBootstrap, UpstreamProxyState, DEFAULT_REMOTE_BASE_URL,
     DEFAULT_SESSION_TOKEN_PATH, DEFAULT_SYSTEM_CA_BUNDLE, NO_PROXY_HOSTS, UPSTREAM_PROXY_ENV_KEYS,
 };
-pub use session::{
-    ContentBlock, ConversationMessage, MessageRole, Session, SessionError, SessionMetadata,
-};
+pub use session::{ContentBlock, ConversationMessage, MessageRole, Session, SessionError};
 pub use usage::{
     format_usd, pricing_for_model, ModelPricing, TokenUsage, UsageCostEstimate, UsageTracker,
 };

+ 8 - 3
rust/crates/runtime/src/permissions.rs

@@ -5,6 +5,8 @@ pub enum PermissionMode {
     ReadOnly,
     WorkspaceWrite,
     DangerFullAccess,
+    Prompt,
+    Allow,
 }
 
 impl PermissionMode {
@@ -14,6 +16,8 @@ impl PermissionMode {
             Self::ReadOnly => "read-only",
             Self::WorkspaceWrite => "workspace-write",
             Self::DangerFullAccess => "danger-full-access",
+            Self::Prompt => "prompt",
+            Self::Allow => "allow",
         }
     }
 }
@@ -90,7 +94,7 @@ impl PermissionPolicy {
     ) -> PermissionOutcome {
         let current_mode = self.active_mode();
         let required_mode = self.required_mode_for(tool_name);
-        if current_mode >= required_mode {
+        if current_mode == PermissionMode::Allow || current_mode >= required_mode {
             return PermissionOutcome::Allow;
         }
 
@@ -101,8 +105,9 @@ impl PermissionPolicy {
             required_mode,
         };
 
-        if current_mode == PermissionMode::WorkspaceWrite
-            && required_mode == PermissionMode::DangerFullAccess
+        if current_mode == PermissionMode::Prompt
+            || (current_mode == PermissionMode::WorkspaceWrite
+                && required_mode == PermissionMode::DangerFullAccess)
         {
             return match prompter.as_mut() {
                 Some(prompter) => match prompter.decide(&request) {

+ 87 - 79
rust/crates/runtime/src/prompt.rs

@@ -50,8 +50,8 @@ pub struct ProjectContext {
     pub cwd: PathBuf,
     pub current_date: String,
     pub git_status: Option<String>,
+    pub git_diff: Option<String>,
     pub instruction_files: Vec<ContextFile>,
-    pub memory_files: Vec<ContextFile>,
 }
 
 impl ProjectContext {
@@ -61,13 +61,12 @@ impl ProjectContext {
     ) -> std::io::Result<Self> {
         let cwd = cwd.into();
         let instruction_files = discover_instruction_files(&cwd)?;
-        let memory_files = discover_memory_files(&cwd)?;
         Ok(Self {
             cwd,
             current_date: current_date.into(),
             git_status: None,
+            git_diff: None,
             instruction_files,
-            memory_files,
         })
     }
 
@@ -77,6 +76,7 @@ impl ProjectContext {
     ) -> std::io::Result<Self> {
         let mut context = Self::discover(cwd, current_date)?;
         context.git_status = read_git_status(&context.cwd);
+        context.git_diff = read_git_diff(&context.cwd);
         Ok(context)
     }
 }
@@ -147,9 +147,6 @@ impl SystemPromptBuilder {
             if !project_context.instruction_files.is_empty() {
                 sections.push(render_instruction_files(&project_context.instruction_files));
             }
-            if !project_context.memory_files.is_empty() {
-                sections.push(render_memory_files(&project_context.memory_files));
-            }
         }
         if let Some(config) = &self.config {
             sections.push(render_config_section(config));
@@ -192,7 +189,7 @@ pub fn prepend_bullets(items: Vec<String>) -> Vec<String> {
     items.into_iter().map(|item| format!(" - {item}")).collect()
 }
 
-fn discover_context_directories(cwd: &Path) -> Vec<PathBuf> {
+fn discover_instruction_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> {
     let mut directories = Vec::new();
     let mut cursor = Some(cwd);
     while let Some(dir) = cursor {
@@ -200,11 +197,6 @@ fn discover_context_directories(cwd: &Path) -> Vec<PathBuf> {
         cursor = dir.parent();
     }
     directories.reverse();
-    directories
-}
-
-fn discover_instruction_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> {
-    let directories = discover_context_directories(cwd);
 
     let mut files = Vec::new();
     for dir in directories {
@@ -220,26 +212,6 @@ fn discover_instruction_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> {
     Ok(dedupe_instruction_files(files))
 }
 
-fn discover_memory_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> {
-    let mut files = Vec::new();
-    for dir in discover_context_directories(cwd) {
-        let memory_dir = dir.join(".claude").join("memory");
-        let Ok(entries) = fs::read_dir(&memory_dir) else {
-            continue;
-        };
-        let mut paths = entries
-            .flatten()
-            .map(|entry| entry.path())
-            .filter(|path| path.is_file())
-            .collect::<Vec<_>>();
-        paths.sort();
-        for path in paths {
-            push_context_file(&mut files, path)?;
-        }
-    }
-    Ok(dedupe_instruction_files(files))
-}
-
 fn push_context_file(files: &mut Vec<ContextFile>, path: PathBuf) -> std::io::Result<()> {
     match fs::read_to_string(&path) {
         Ok(content) if !content.trim().is_empty() => {
@@ -270,6 +242,38 @@ fn read_git_status(cwd: &Path) -> Option<String> {
     }
 }
 
+fn read_git_diff(cwd: &Path) -> Option<String> {
+    let mut sections = Vec::new();
+
+    let staged = read_git_output(cwd, &["diff", "--cached"])?;
+    if !staged.trim().is_empty() {
+        sections.push(format!("Staged changes:\n{}", staged.trim_end()));
+    }
+
+    let unstaged = read_git_output(cwd, &["diff"])?;
+    if !unstaged.trim().is_empty() {
+        sections.push(format!("Unstaged changes:\n{}", unstaged.trim_end()));
+    }
+
+    if sections.is_empty() {
+        None
+    } else {
+        Some(sections.join("\n\n"))
+    }
+}
+
+fn read_git_output(cwd: &Path, args: &[&str]) -> Option<String> {
+    let output = Command::new("git")
+        .args(args)
+        .current_dir(cwd)
+        .output()
+        .ok()?;
+    if !output.status.success() {
+        return None;
+    }
+    String::from_utf8(output.stdout).ok()
+}
+
 fn render_project_context(project_context: &ProjectContext) -> String {
     let mut lines = vec!["# Project context".to_string()];
     let mut bullets = vec![
@@ -282,31 +286,22 @@ fn render_project_context(project_context: &ProjectContext) -> String {
             project_context.instruction_files.len()
         ));
     }
-    if !project_context.memory_files.is_empty() {
-        bullets.push(format!(
-            "Project memory files discovered: {}.",
-            project_context.memory_files.len()
-        ));
-    }
     lines.extend(prepend_bullets(bullets));
     if let Some(status) = &project_context.git_status {
         lines.push(String::new());
         lines.push("Git status snapshot:".to_string());
         lines.push(status.clone());
     }
+    if let Some(diff) = &project_context.git_diff {
+        lines.push(String::new());
+        lines.push("Git diff snapshot:".to_string());
+        lines.push(diff.clone());
+    }
     lines.join("\n")
 }
 
 fn render_instruction_files(files: &[ContextFile]) -> String {
-    render_context_file_section("# Claude instructions", files)
-}
-
-fn render_memory_files(files: &[ContextFile]) -> String {
-    render_context_file_section("# Project memory", files)
-}
-
-fn render_context_file_section(title: &str, files: &[ContextFile]) -> String {
-    let mut sections = vec![title.to_string()];
+    let mut sections = vec!["# Claude instructions".to_string()];
     let mut remaining_chars = MAX_TOTAL_INSTRUCTION_CHARS;
     for file in files {
         if remaining_chars == 0 {
@@ -498,9 +493,8 @@ fn get_actions_section() -> String {
 mod tests {
     use super::{
         collapse_blank_lines, display_context_path, normalize_instruction_content,
-        render_instruction_content, render_instruction_files, render_memory_files,
-        truncate_instruction_content, ContextFile, ProjectContext, SystemPromptBuilder,
-        SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
+        render_instruction_content, render_instruction_files, truncate_instruction_content,
+        ContextFile, ProjectContext, SystemPromptBuilder, SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
     };
     use crate::config::ConfigLoader;
     use std::fs;
@@ -565,35 +559,6 @@ mod tests {
         fs::remove_dir_all(root).expect("cleanup temp dir");
     }
 
-    #[test]
-    fn discovers_project_memory_files_from_ancestor_chain() {
-        let root = temp_dir();
-        let nested = root.join("apps").join("api");
-        fs::create_dir_all(root.join(".claude").join("memory")).expect("root memory dir");
-        fs::create_dir_all(nested.join(".claude").join("memory")).expect("nested memory dir");
-        fs::write(
-            root.join(".claude").join("memory").join("2026-03-30.md"),
-            "root memory",
-        )
-        .expect("write root memory");
-        fs::write(
-            nested.join(".claude").join("memory").join("2026-03-31.md"),
-            "nested memory",
-        )
-        .expect("write nested memory");
-
-        let context = ProjectContext::discover(&nested, "2026-03-31").expect("context should load");
-        let contents = context
-            .memory_files
-            .iter()
-            .map(|file| file.content.as_str())
-            .collect::<Vec<_>>();
-
-        assert_eq!(contents, vec!["root memory", "nested memory"]);
-        assert!(render_memory_files(&context.memory_files).contains("# Project memory"));
-        fs::remove_dir_all(root).expect("cleanup temp dir");
-    }
-
     #[test]
     fn dedupes_identical_instruction_content_across_scopes() {
         let root = temp_dir();
@@ -652,6 +617,49 @@ mod tests {
         assert!(status.contains("## No commits yet on") || status.contains("## "));
         assert!(status.contains("?? CLAUDE.md"));
         assert!(status.contains("?? tracked.txt"));
+        assert!(context.git_diff.is_none());
+
+        fs::remove_dir_all(root).expect("cleanup temp dir");
+    }
+
+    #[test]
+    fn discover_with_git_includes_diff_snapshot_for_tracked_changes() {
+        let root = temp_dir();
+        fs::create_dir_all(&root).expect("root dir");
+        std::process::Command::new("git")
+            .args(["init", "--quiet"])
+            .current_dir(&root)
+            .status()
+            .expect("git init should run");
+        std::process::Command::new("git")
+            .args(["config", "user.email", "tests@example.com"])
+            .current_dir(&root)
+            .status()
+            .expect("git config email should run");
+        std::process::Command::new("git")
+            .args(["config", "user.name", "Runtime Prompt Tests"])
+            .current_dir(&root)
+            .status()
+            .expect("git config name should run");
+        fs::write(root.join("tracked.txt"), "hello\n").expect("write tracked file");
+        std::process::Command::new("git")
+            .args(["add", "tracked.txt"])
+            .current_dir(&root)
+            .status()
+            .expect("git add should run");
+        std::process::Command::new("git")
+            .args(["commit", "-m", "init", "--quiet"])
+            .current_dir(&root)
+            .status()
+            .expect("git commit should run");
+        fs::write(root.join("tracked.txt"), "hello\nworld\n").expect("rewrite tracked file");
+
+        let context =
+            ProjectContext::discover_with_git(&root, "2026-03-31").expect("context should load");
+
+        let diff = context.git_diff.expect("git diff should be present");
+        assert!(diff.contains("Unstaged changes:"));
+        assert!(diff.contains("tracked.txt"));
 
         fs::remove_dir_all(root).expect("cleanup temp dir");
     }

+ 364 - 0
rust/crates/runtime/src/sandbox.rs

@@ -0,0 +1,364 @@
+use std::env;
+use std::fs;
+use std::path::{Path, PathBuf};
+
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
+#[serde(rename_all = "kebab-case")]
+pub enum FilesystemIsolationMode {
+    Off,
+    #[default]
+    WorkspaceOnly,
+    AllowList,
+}
+
+impl FilesystemIsolationMode {
+    #[must_use]
+    pub fn as_str(self) -> &'static str {
+        match self {
+            Self::Off => "off",
+            Self::WorkspaceOnly => "workspace-only",
+            Self::AllowList => "allow-list",
+        }
+    }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
+pub struct SandboxConfig {
+    pub enabled: Option<bool>,
+    pub namespace_restrictions: Option<bool>,
+    pub network_isolation: Option<bool>,
+    pub filesystem_mode: Option<FilesystemIsolationMode>,
+    pub allowed_mounts: Vec<String>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
+pub struct SandboxRequest {
+    pub enabled: bool,
+    pub namespace_restrictions: bool,
+    pub network_isolation: bool,
+    pub filesystem_mode: FilesystemIsolationMode,
+    pub allowed_mounts: Vec<String>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
+pub struct ContainerEnvironment {
+    pub in_container: bool,
+    pub markers: Vec<String>,
+}
+
+#[allow(clippy::struct_excessive_bools)]
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
+pub struct SandboxStatus {
+    pub enabled: bool,
+    pub requested: SandboxRequest,
+    pub supported: bool,
+    pub active: bool,
+    pub namespace_supported: bool,
+    pub namespace_active: bool,
+    pub network_supported: bool,
+    pub network_active: bool,
+    pub filesystem_mode: FilesystemIsolationMode,
+    pub filesystem_active: bool,
+    pub allowed_mounts: Vec<String>,
+    pub in_container: bool,
+    pub container_markers: Vec<String>,
+    pub fallback_reason: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct SandboxDetectionInputs<'a> {
+    pub env_pairs: Vec<(String, String)>,
+    pub dockerenv_exists: bool,
+    pub containerenv_exists: bool,
+    pub proc_1_cgroup: Option<&'a str>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct LinuxSandboxCommand {
+    pub program: String,
+    pub args: Vec<String>,
+    pub env: Vec<(String, String)>,
+}
+
+impl SandboxConfig {
+    #[must_use]
+    pub fn resolve_request(
+        &self,
+        enabled_override: Option<bool>,
+        namespace_override: Option<bool>,
+        network_override: Option<bool>,
+        filesystem_mode_override: Option<FilesystemIsolationMode>,
+        allowed_mounts_override: Option<Vec<String>>,
+    ) -> SandboxRequest {
+        SandboxRequest {
+            enabled: enabled_override.unwrap_or(self.enabled.unwrap_or(true)),
+            namespace_restrictions: namespace_override
+                .unwrap_or(self.namespace_restrictions.unwrap_or(true)),
+            network_isolation: network_override.unwrap_or(self.network_isolation.unwrap_or(false)),
+            filesystem_mode: filesystem_mode_override
+                .or(self.filesystem_mode)
+                .unwrap_or_default(),
+            allowed_mounts: allowed_mounts_override.unwrap_or_else(|| self.allowed_mounts.clone()),
+        }
+    }
+}
+
+#[must_use]
+pub fn detect_container_environment() -> ContainerEnvironment {
+    let proc_1_cgroup = fs::read_to_string("/proc/1/cgroup").ok();
+    detect_container_environment_from(SandboxDetectionInputs {
+        env_pairs: env::vars().collect(),
+        dockerenv_exists: Path::new("/.dockerenv").exists(),
+        containerenv_exists: Path::new("/run/.containerenv").exists(),
+        proc_1_cgroup: proc_1_cgroup.as_deref(),
+    })
+}
+
+#[must_use]
+pub fn detect_container_environment_from(
+    inputs: SandboxDetectionInputs<'_>,
+) -> ContainerEnvironment {
+    let mut markers = Vec::new();
+    if inputs.dockerenv_exists {
+        markers.push("/.dockerenv".to_string());
+    }
+    if inputs.containerenv_exists {
+        markers.push("/run/.containerenv".to_string());
+    }
+    for (key, value) in inputs.env_pairs {
+        let normalized = key.to_ascii_lowercase();
+        if matches!(
+            normalized.as_str(),
+            "container" | "docker" | "podman" | "kubernetes_service_host"
+        ) && !value.is_empty()
+        {
+            markers.push(format!("env:{key}={value}"));
+        }
+    }
+    if let Some(cgroup) = inputs.proc_1_cgroup {
+        for needle in ["docker", "containerd", "kubepods", "podman", "libpod"] {
+            if cgroup.contains(needle) {
+                markers.push(format!("/proc/1/cgroup:{needle}"));
+            }
+        }
+    }
+    markers.sort();
+    markers.dedup();
+    ContainerEnvironment {
+        in_container: !markers.is_empty(),
+        markers,
+    }
+}
+
+#[must_use]
+pub fn resolve_sandbox_status(config: &SandboxConfig, cwd: &Path) -> SandboxStatus {
+    let request = config.resolve_request(None, None, None, None, None);
+    resolve_sandbox_status_for_request(&request, cwd)
+}
+
+#[must_use]
+pub fn resolve_sandbox_status_for_request(request: &SandboxRequest, cwd: &Path) -> SandboxStatus {
+    let container = detect_container_environment();
+    let namespace_supported = cfg!(target_os = "linux") && command_exists("unshare");
+    let network_supported = namespace_supported;
+    let filesystem_active =
+        request.enabled && request.filesystem_mode != FilesystemIsolationMode::Off;
+    let mut fallback_reasons = Vec::new();
+
+    if request.enabled && request.namespace_restrictions && !namespace_supported {
+        fallback_reasons
+            .push("namespace isolation unavailable (requires Linux with `unshare`)".to_string());
+    }
+    if request.enabled && request.network_isolation && !network_supported {
+        fallback_reasons
+            .push("network isolation unavailable (requires Linux with `unshare`)".to_string());
+    }
+    if request.enabled
+        && request.filesystem_mode == FilesystemIsolationMode::AllowList
+        && request.allowed_mounts.is_empty()
+    {
+        fallback_reasons
+            .push("filesystem allow-list requested without configured mounts".to_string());
+    }
+
+    let active = request.enabled
+        && (!request.namespace_restrictions || namespace_supported)
+        && (!request.network_isolation || network_supported);
+
+    let allowed_mounts = normalize_mounts(&request.allowed_mounts, cwd);
+
+    SandboxStatus {
+        enabled: request.enabled,
+        requested: request.clone(),
+        supported: namespace_supported,
+        active,
+        namespace_supported,
+        namespace_active: request.enabled && request.namespace_restrictions && namespace_supported,
+        network_supported,
+        network_active: request.enabled && request.network_isolation && network_supported,
+        filesystem_mode: request.filesystem_mode,
+        filesystem_active,
+        allowed_mounts,
+        in_container: container.in_container,
+        container_markers: container.markers,
+        fallback_reason: (!fallback_reasons.is_empty()).then(|| fallback_reasons.join("; ")),
+    }
+}
+
+#[must_use]
+pub fn build_linux_sandbox_command(
+    command: &str,
+    cwd: &Path,
+    status: &SandboxStatus,
+) -> Option<LinuxSandboxCommand> {
+    if !cfg!(target_os = "linux")
+        || !status.enabled
+        || (!status.namespace_active && !status.network_active)
+    {
+        return None;
+    }
+
+    let mut args = vec![
+        "--user".to_string(),
+        "--map-root-user".to_string(),
+        "--mount".to_string(),
+        "--ipc".to_string(),
+        "--pid".to_string(),
+        "--uts".to_string(),
+        "--fork".to_string(),
+    ];
+    if status.network_active {
+        args.push("--net".to_string());
+    }
+    args.push("sh".to_string());
+    args.push("-lc".to_string());
+    args.push(command.to_string());
+
+    let sandbox_home = cwd.join(".sandbox-home");
+    let sandbox_tmp = cwd.join(".sandbox-tmp");
+    let mut env = vec![
+        ("HOME".to_string(), sandbox_home.display().to_string()),
+        ("TMPDIR".to_string(), sandbox_tmp.display().to_string()),
+        (
+            "CLAWD_SANDBOX_FILESYSTEM_MODE".to_string(),
+            status.filesystem_mode.as_str().to_string(),
+        ),
+        (
+            "CLAWD_SANDBOX_ALLOWED_MOUNTS".to_string(),
+            status.allowed_mounts.join(":"),
+        ),
+    ];
+    if let Ok(path) = env::var("PATH") {
+        env.push(("PATH".to_string(), path));
+    }
+
+    Some(LinuxSandboxCommand {
+        program: "unshare".to_string(),
+        args,
+        env,
+    })
+}
+
+fn normalize_mounts(mounts: &[String], cwd: &Path) -> Vec<String> {
+    let cwd = cwd.to_path_buf();
+    mounts
+        .iter()
+        .map(|mount| {
+            let path = PathBuf::from(mount);
+            if path.is_absolute() {
+                path
+            } else {
+                cwd.join(path)
+            }
+        })
+        .map(|path| path.display().to_string())
+        .collect()
+}
+
+fn command_exists(command: &str) -> bool {
+    env::var_os("PATH")
+        .is_some_and(|paths| env::split_paths(&paths).any(|path| path.join(command).exists()))
+}
+
+#[cfg(test)]
+mod tests {
+    use super::{
+        build_linux_sandbox_command, detect_container_environment_from, FilesystemIsolationMode,
+        SandboxConfig, SandboxDetectionInputs,
+    };
+    use std::path::Path;
+
+    #[test]
+    fn detects_container_markers_from_multiple_sources() {
+        let detected = detect_container_environment_from(SandboxDetectionInputs {
+            env_pairs: vec![("container".to_string(), "docker".to_string())],
+            dockerenv_exists: true,
+            containerenv_exists: false,
+            proc_1_cgroup: Some("12:memory:/docker/abc"),
+        });
+
+        assert!(detected.in_container);
+        assert!(detected
+            .markers
+            .iter()
+            .any(|marker| marker == "/.dockerenv"));
+        assert!(detected
+            .markers
+            .iter()
+            .any(|marker| marker == "env:container=docker"));
+        assert!(detected
+            .markers
+            .iter()
+            .any(|marker| marker == "/proc/1/cgroup:docker"));
+    }
+
+    #[test]
+    fn resolves_request_with_overrides() {
+        let config = SandboxConfig {
+            enabled: Some(true),
+            namespace_restrictions: Some(true),
+            network_isolation: Some(false),
+            filesystem_mode: Some(FilesystemIsolationMode::WorkspaceOnly),
+            allowed_mounts: vec!["logs".to_string()],
+        };
+
+        let request = config.resolve_request(
+            Some(true),
+            Some(false),
+            Some(true),
+            Some(FilesystemIsolationMode::AllowList),
+            Some(vec!["tmp".to_string()]),
+        );
+
+        assert!(request.enabled);
+        assert!(!request.namespace_restrictions);
+        assert!(request.network_isolation);
+        assert_eq!(request.filesystem_mode, FilesystemIsolationMode::AllowList);
+        assert_eq!(request.allowed_mounts, vec!["tmp"]);
+    }
+
+    #[test]
+    fn builds_linux_launcher_with_network_flag_when_requested() {
+        let config = SandboxConfig::default();
+        let status = super::resolve_sandbox_status_for_request(
+            &config.resolve_request(
+                Some(true),
+                Some(true),
+                Some(true),
+                Some(FilesystemIsolationMode::WorkspaceOnly),
+                None,
+            ),
+            Path::new("/workspace"),
+        );
+
+        if let Some(launcher) =
+            build_linux_sandbox_command("printf hi", Path::new("/workspace"), &status)
+        {
+            assert_eq!(launcher.program, "unshare");
+            assert!(launcher.args.iter().any(|arg| arg == "--mount"));
+            assert!(launcher.args.iter().any(|arg| arg == "--net") == status.network_active);
+        }
+    }
+}

+ 2 - 114
rust/crates/runtime/src/session.rs

@@ -19,10 +19,6 @@ pub enum ContentBlock {
     Text {
         text: String,
     },
-    Thinking {
-        text: String,
-        signature: Option<String>,
-    },
     ToolUse {
         id: String,
         name: String,
@@ -43,19 +39,10 @@ pub struct ConversationMessage {
     pub usage: Option<TokenUsage>,
 }
 
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct SessionMetadata {
-    pub started_at: String,
-    pub model: String,
-    pub message_count: u32,
-    pub last_prompt: Option<String>,
-}
-
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub struct Session {
     pub version: u32,
     pub messages: Vec<ConversationMessage>,
-    pub metadata: Option<SessionMetadata>,
 }
 
 #[derive(Debug)]
@@ -95,7 +82,6 @@ impl Session {
         Self {
             version: 1,
             messages: Vec::new(),
-            metadata: None,
         }
     }
 
@@ -125,9 +111,6 @@ impl Session {
                     .collect(),
             ),
         );
-        if let Some(metadata) = &self.metadata {
-            object.insert("metadata".to_string(), metadata.to_json());
-        }
         JsonValue::Object(object)
     }
 
@@ -148,15 +131,7 @@ impl Session {
             .iter()
             .map(ConversationMessage::from_json)
             .collect::<Result<Vec<_>, _>>()?;
-        let metadata = object
-            .get("metadata")
-            .map(SessionMetadata::from_json)
-            .transpose()?;
-        Ok(Self {
-            version,
-            messages,
-            metadata,
-        })
+        Ok(Self { version, messages })
     }
 }
 
@@ -166,41 +141,6 @@ impl Default for Session {
     }
 }
 
-impl SessionMetadata {
-    #[must_use]
-    pub fn to_json(&self) -> JsonValue {
-        let mut object = BTreeMap::new();
-        object.insert(
-            "started_at".to_string(),
-            JsonValue::String(self.started_at.clone()),
-        );
-        object.insert("model".to_string(), JsonValue::String(self.model.clone()));
-        object.insert(
-            "message_count".to_string(),
-            JsonValue::Number(i64::from(self.message_count)),
-        );
-        if let Some(last_prompt) = &self.last_prompt {
-            object.insert(
-                "last_prompt".to_string(),
-                JsonValue::String(last_prompt.clone()),
-            );
-        }
-        JsonValue::Object(object)
-    }
-
-    fn from_json(value: &JsonValue) -> Result<Self, SessionError> {
-        let object = value.as_object().ok_or_else(|| {
-            SessionError::Format("session metadata must be an object".to_string())
-        })?;
-        Ok(Self {
-            started_at: required_string(object, "started_at")?,
-            model: required_string(object, "model")?,
-            message_count: required_u32(object, "message_count")?,
-            last_prompt: optional_string(object, "last_prompt"),
-        })
-    }
-}
-
 impl ConversationMessage {
     #[must_use]
     pub fn user_text(text: impl Into<String>) -> Self {
@@ -317,19 +257,6 @@ impl ContentBlock {
                 object.insert("type".to_string(), JsonValue::String("text".to_string()));
                 object.insert("text".to_string(), JsonValue::String(text.clone()));
             }
-            Self::Thinking { text, signature } => {
-                object.insert(
-                    "type".to_string(),
-                    JsonValue::String("thinking".to_string()),
-                );
-                object.insert("text".to_string(), JsonValue::String(text.clone()));
-                if let Some(signature) = signature {
-                    object.insert(
-                        "signature".to_string(),
-                        JsonValue::String(signature.clone()),
-                    );
-                }
-            }
             Self::ToolUse { id, name, input } => {
                 object.insert(
                     "type".to_string(),
@@ -376,13 +303,6 @@ impl ContentBlock {
             "text" => Ok(Self::Text {
                 text: required_string(object, "text")?,
             }),
-            "thinking" => Ok(Self::Thinking {
-                text: required_string(object, "text")?,
-                signature: object
-                    .get("signature")
-                    .and_then(JsonValue::as_str)
-                    .map(ToOwned::to_owned),
-            }),
             "tool_use" => Ok(Self::ToolUse {
                 id: required_string(object, "id")?,
                 name: required_string(object, "name")?,
@@ -448,13 +368,6 @@ fn required_string(
         .ok_or_else(|| SessionError::Format(format!("missing {key}")))
 }
 
-fn optional_string(object: &BTreeMap<String, JsonValue>, key: &str) -> Option<String> {
-    object
-        .get(key)
-        .and_then(JsonValue::as_str)
-        .map(ToOwned::to_owned)
-}
-
 fn required_u32(object: &BTreeMap<String, JsonValue>, key: &str) -> Result<u32, SessionError> {
     let value = object
         .get(key)
@@ -465,8 +378,7 @@ fn required_u32(object: &BTreeMap<String, JsonValue>, key: &str) -> Result<u32,
 
 #[cfg(test)]
 mod tests {
-    use super::{ContentBlock, ConversationMessage, MessageRole, Session, SessionMetadata};
-    use crate::json::JsonValue;
+    use super::{ContentBlock, ConversationMessage, MessageRole, Session};
     use crate::usage::TokenUsage;
     use std::fs;
     use std::time::{SystemTime, UNIX_EPOCH};
@@ -474,12 +386,6 @@ mod tests {
     #[test]
     fn persists_and_restores_session_json() {
         let mut session = Session::new();
-        session.metadata = Some(SessionMetadata {
-            started_at: "2026-04-01T00:00:00Z".to_string(),
-            model: "claude-sonnet".to_string(),
-            message_count: 3,
-            last_prompt: Some("hello".to_string()),
-        });
         session
             .messages
             .push(ConversationMessage::user_text("hello"));
@@ -522,23 +428,5 @@ mod tests {
             restored.messages[1].usage.expect("usage").total_tokens(),
             17
         );
-        assert_eq!(restored.metadata, session.metadata);
-    }
-
-    #[test]
-    fn loads_legacy_session_without_metadata() {
-        let legacy = r#"{
-  "version": 1,
-  "messages": [
-    {
-      "role": "user",
-      "blocks": [{"type": "text", "text": "hello"}]
-    }
-  ]
-}"#;
-        let restored = Session::from_json(&JsonValue::parse(legacy).expect("legacy json"))
-            .expect("legacy session should parse");
-        assert_eq!(restored.messages.len(), 1);
-        assert!(restored.metadata.is_none());
     }
 }

+ 0 - 1
rust/crates/runtime/src/usage.rs

@@ -300,7 +300,6 @@ mod tests {
                     cache_read_input_tokens: 0,
                 }),
             }],
-            metadata: None,
         };
 
         let tracker = UsageTracker::from_session(&session);

+ 0 - 3
rust/crates/rusty-claude-cli/Cargo.toml

@@ -11,11 +11,8 @@ commands = { path = "../commands" }
 compat-harness = { path = "../compat-harness" }
 crossterm = "0.28"
 pulldown-cmark = "0.13"
-reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
 runtime = { path = "../runtime" }
-serde = { version = "1", features = ["derive"] }
 serde_json = "1"
-sha2 = "0.10"
 syntect = "5"
 tokio = { version = "1", features = ["rt-multi-thread", "time"] }
 tools = { path = "../tools" }

+ 32 - 169
rust/crates/rusty-claude-cli/src/render.rs

@@ -15,17 +15,12 @@ use syntect::util::{as_24_bit_terminal_escaped, LinesWithEndings};
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub struct ColorTheme {
-    enabled: bool,
     heading: Color,
     emphasis: Color,
     strong: Color,
     inline_code: Color,
     link: Color,
     quote: Color,
-    info: Color,
-    warning: Color,
-    success: Color,
-    error: Color,
     spinner_active: Color,
     spinner_done: Color,
     spinner_failed: Color,
@@ -34,17 +29,12 @@ pub struct ColorTheme {
 impl Default for ColorTheme {
     fn default() -> Self {
         Self {
-            enabled: true,
-            heading: Color::Blue,
-            emphasis: Color::Blue,
+            heading: Color::Cyan,
+            emphasis: Color::Magenta,
             strong: Color::Yellow,
             inline_code: Color::Green,
             link: Color::Blue,
             quote: Color::DarkGrey,
-            info: Color::Blue,
-            warning: Color::Yellow,
-            success: Color::Green,
-            error: Color::Red,
             spinner_active: Color::Blue,
             spinner_done: Color::Green,
             spinner_failed: Color::Red,
@@ -52,21 +42,6 @@ impl Default for ColorTheme {
     }
 }
 
-impl ColorTheme {
-    #[must_use]
-    pub fn without_color() -> Self {
-        Self {
-            enabled: false,
-            ..Self::default()
-        }
-    }
-
-    #[must_use]
-    pub fn enabled(&self) -> bool {
-        self.enabled
-    }
-}
-
 #[derive(Debug, Default, Clone, PartialEq, Eq)]
 pub struct Spinner {
     frame_index: usize,
@@ -92,19 +67,12 @@ impl Spinner {
             out,
             SavePosition,
             MoveToColumn(0),
-            Clear(ClearType::CurrentLine)
+            Clear(ClearType::CurrentLine),
+            SetForegroundColor(theme.spinner_active),
+            Print(format!("{frame} {label}")),
+            ResetColor,
+            RestorePosition
         )?;
-        if theme.enabled() {
-            queue!(
-                out,
-                SetForegroundColor(theme.spinner_active),
-                Print(format!("{frame} {label}")),
-                ResetColor,
-                RestorePosition
-            )?;
-        } else {
-            queue!(out, Print(format!("{frame} {label}")), RestorePosition)?;
-        }
         out.flush()
     }
 
@@ -115,17 +83,14 @@ impl Spinner {
         out: &mut impl Write,
     ) -> io::Result<()> {
         self.frame_index = 0;
-        execute!(out, MoveToColumn(0), Clear(ClearType::CurrentLine))?;
-        if theme.enabled() {
-            execute!(
-                out,
-                SetForegroundColor(theme.spinner_done),
-                Print(format!("✔ {label}\n")),
-                ResetColor
-            )?;
-        } else {
-            execute!(out, Print(format!("✔ {label}\n")))?;
-        }
+        execute!(
+            out,
+            MoveToColumn(0),
+            Clear(ClearType::CurrentLine),
+            SetForegroundColor(theme.spinner_done),
+            Print(format!("✔ {label}\n")),
+            ResetColor
+        )?;
         out.flush()
     }
 
@@ -136,17 +101,14 @@ impl Spinner {
         out: &mut impl Write,
     ) -> io::Result<()> {
         self.frame_index = 0;
-        execute!(out, MoveToColumn(0), Clear(ClearType::CurrentLine))?;
-        if theme.enabled() {
-            execute!(
-                out,
-                SetForegroundColor(theme.spinner_failed),
-                Print(format!("✘ {label}\n")),
-                ResetColor
-            )?;
-        } else {
-            execute!(out, Print(format!("✘ {label}\n")))?;
-        }
+        execute!(
+            out,
+            MoveToColumn(0),
+            Clear(ClearType::CurrentLine),
+            SetForegroundColor(theme.spinner_failed),
+            Print(format!("✘ {label}\n")),
+            ResetColor
+        )?;
         out.flush()
     }
 }
@@ -161,9 +123,6 @@ struct RenderState {
 
 impl RenderState {
     fn style_text(&self, text: &str, theme: &ColorTheme) -> String {
-        if !theme.enabled() {
-            return text.to_string();
-        }
         if self.strong > 0 {
             format!("{}", text.bold().with(theme.strong))
         } else if self.emphasis > 0 {
@@ -204,70 +163,11 @@ impl TerminalRenderer {
         Self::default()
     }
 
-    #[must_use]
-    pub fn with_color(enabled: bool) -> Self {
-        if enabled {
-            Self::new()
-        } else {
-            Self {
-                color_theme: ColorTheme::without_color(),
-                ..Self::default()
-            }
-        }
-    }
-
     #[must_use]
     pub fn color_theme(&self) -> &ColorTheme {
         &self.color_theme
     }
 
-    fn paint(&self, text: impl AsRef<str>, color: Color) -> String {
-        let text = text.as_ref();
-        if self.color_theme.enabled() {
-            format!("{}", text.with(color))
-        } else {
-            text.to_string()
-        }
-    }
-
-    fn paint_bold(&self, text: impl AsRef<str>, color: Color) -> String {
-        let text = text.as_ref();
-        if self.color_theme.enabled() {
-            format!("{}", text.bold().with(color))
-        } else {
-            text.to_string()
-        }
-    }
-
-    fn paint_underlined(&self, text: impl AsRef<str>, color: Color) -> String {
-        let text = text.as_ref();
-        if self.color_theme.enabled() {
-            format!("{}", text.underlined().with(color))
-        } else {
-            text.to_string()
-        }
-    }
-
-    #[must_use]
-    pub fn info(&self, text: impl AsRef<str>) -> String {
-        self.paint(text, self.color_theme.info)
-    }
-
-    #[must_use]
-    pub fn warning(&self, text: impl AsRef<str>) -> String {
-        self.paint(text, self.color_theme.warning)
-    }
-
-    #[must_use]
-    pub fn success(&self, text: impl AsRef<str>) -> String {
-        self.paint(text, self.color_theme.success)
-    }
-
-    #[must_use]
-    pub fn error(&self, text: impl AsRef<str>) -> String {
-        self.paint(text, self.color_theme.error)
-    }
-
     #[must_use]
     pub fn render_markdown(&self, markdown: &str) -> String {
         let mut output = String::new();
@@ -335,7 +235,7 @@ impl TerminalRenderer {
                 let _ = write!(
                     output,
                     "{}",
-                    self.paint(format!("`{code}`"), self.color_theme.inline_code)
+                    format!("`{code}`").with(self.color_theme.inline_code)
                 );
             }
             Event::Rule => output.push_str("---\n"),
@@ -352,14 +252,16 @@ impl TerminalRenderer {
                 let _ = write!(
                     output,
                     "{}",
-                    self.paint_underlined(format!("[{dest_url}]"), self.color_theme.link)
+                    format!("[{dest_url}]")
+                        .underlined()
+                        .with(self.color_theme.link)
                 );
             }
             Event::Start(Tag::Image { dest_url, .. }) => {
                 let _ = write!(
                     output,
                     "{}",
-                    self.paint(format!("[image:{dest_url}]"), self.color_theme.link)
+                    format!("[image:{dest_url}]").with(self.color_theme.link)
                 );
             }
             Event::Start(
@@ -392,16 +294,12 @@ impl TerminalRenderer {
             3 => "### ",
             _ => "#### ",
         };
-        let _ = write!(
-            output,
-            "{}",
-            self.paint_bold(prefix, self.color_theme.heading)
-        );
+        let _ = write!(output, "{}", prefix.bold().with(self.color_theme.heading));
     }
 
     fn start_quote(&self, state: &mut RenderState, output: &mut String) {
         state.quote += 1;
-        let _ = write!(output, "{}", self.paint("│ ", self.color_theme.quote));
+        let _ = write!(output, "{}", "│ ".with(self.color_theme.quote));
     }
 
     fn start_item(state: &RenderState, output: &mut String) {
@@ -414,7 +312,7 @@ impl TerminalRenderer {
             let _ = writeln!(
                 output,
                 "{}",
-                self.paint(format!("╭─ {code_language}"), self.color_theme.heading)
+                format!("╭─ {code_language}").with(self.color_theme.heading)
             );
         }
     }
@@ -422,7 +320,7 @@ impl TerminalRenderer {
     fn finish_code_block(&self, code_buffer: &str, code_language: &str, output: &mut String) {
         output.push_str(&self.highlight_code(code_buffer, code_language));
         if !code_language.is_empty() {
-            let _ = write!(output, "{}", self.paint("╰─", self.color_theme.heading));
+            let _ = write!(output, "{}", "╰─".with(self.color_theme.heading));
         }
         output.push_str("\n\n");
     }
@@ -444,10 +342,6 @@ impl TerminalRenderer {
 
     #[must_use]
     pub fn highlight_code(&self, code: &str, language: &str) -> String {
-        if !self.color_theme.enabled() {
-            return code.to_string();
-        }
-
         let syntax = self
             .syntax_set
             .find_syntax_by_token(language)
@@ -476,16 +370,6 @@ impl TerminalRenderer {
         }
         writeln!(out)
     }
-
-    #[must_use]
-    pub fn token_usage_summary(&self, input_tokens: u64, output_tokens: u64) -> String {
-        format!(
-            "{} {} input / {} output",
-            self.info("Token usage:"),
-            input_tokens,
-            output_tokens
-        )
-    }
 }
 
 #[cfg(test)]
@@ -553,25 +437,4 @@ mod tests {
         let output = String::from_utf8_lossy(&out);
         assert!(output.contains("Working"));
     }
-
-    #[test]
-    fn renderer_can_disable_color_output() {
-        let terminal_renderer = TerminalRenderer::with_color(false);
-        let markdown_output = terminal_renderer.render_markdown(
-            "# Heading\n\nThis is **bold** and `code`.\n\n```rust\nfn hi() {}\n```",
-        );
-
-        assert!(!markdown_output.contains('\u{1b}'));
-        assert!(markdown_output.contains("Heading"));
-        assert!(markdown_output.contains("fn hi() {}"));
-    }
-
-    #[test]
-    fn token_usage_summary_uses_plain_text_without_color() {
-        let terminal_renderer = TerminalRenderer::with_color(false);
-        assert_eq!(
-            terminal_renderer.token_usage_summary(12, 34),
-            "Token usage: 12 input / 34 output"
-        );
-    }
 }

+ 0 - 2
rust/crates/tools/Cargo.toml

@@ -6,12 +6,10 @@ license.workspace = true
 publish.workspace = true
 
 [dependencies]
-api = { path = "../api" }
 runtime = { path = "../runtime" }
 reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
 serde = { version = "1", features = ["derive"] }
 serde_json = "1"
-tokio = { version = "1", features = ["rt-multi-thread"] }
 
 [lints]
 workspace = true

+ 43 - 665
rust/crates/tools/src/lib.rs

@@ -3,17 +3,10 @@ use std::path::{Path, PathBuf};
 use std::process::Command;
 use std::time::{Duration, Instant};
 
-use api::{
-    resolve_startup_auth_source, AnthropicClient, ContentBlockDelta, InputContentBlock,
-    InputMessage, MessageRequest, OutputContentBlock, StreamEvent as ApiStreamEvent, ToolChoice,
-    ToolDefinition, ToolResultContentBlock,
-};
 use reqwest::blocking::Client;
 use runtime::{
-    edit_file, execute_bash, glob_search, grep_search, load_system_prompt, read_file, write_file,
-    ApiClient, ApiRequest, AssistantEvent, BashCommandInput, ConfigLoader, ContentBlock,
-    ConversationMessage, ConversationRuntime, GrepSearchInput, MessageRole, PermissionMode,
-    PermissionPolicy, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor,
+    edit_file, execute_bash, glob_search, grep_search, read_file, write_file, BashCommandInput,
+    GrepSearchInput, PermissionMode,
 };
 use serde::{Deserialize, Serialize};
 use serde_json::{json, Value};
@@ -241,8 +234,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
         },
         ToolSpec {
             name: "Agent",
-            description:
-                "Launch and execute a specialized child agent conversation with bounded recursion.",
+            description: "Launch a specialized agent task and persist its handoff metadata.",
             input_schema: json!({
                 "type": "object",
                 "properties": {
@@ -250,8 +242,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
                     "prompt": { "type": "string" },
                     "subagent_type": { "type": "string" },
                     "name": { "type": "string" },
-                    "model": { "type": "string" },
-                    "max_depth": { "type": "integer", "minimum": 0 }
+                    "model": { "type": "string" }
                 },
                 "required": ["description", "prompt"],
                 "additionalProperties": false
@@ -588,7 +579,6 @@ struct AgentInput {
     subagent_type: Option<String>,
     name: Option<String>,
     model: Option<String>,
-    max_depth: Option<usize>,
 }
 
 #[derive(Debug, Deserialize)]
@@ -722,16 +712,6 @@ struct AgentOutput {
     subagent_type: Option<String>,
     model: Option<String>,
     status: String,
-    #[serde(rename = "maxDepth")]
-    max_depth: usize,
-    #[serde(rename = "depth")]
-    depth: usize,
-    #[serde(rename = "result")]
-    result: Option<String>,
-    #[serde(rename = "assistantMessages")]
-    assistant_messages: Vec<String>,
-    #[serde(rename = "toolResults")]
-    tool_results: Vec<AgentToolResult>,
     #[serde(rename = "outputFile")]
     output_file: String,
     #[serde(rename = "manifestFile")]
@@ -740,15 +720,6 @@ struct AgentOutput {
     created_at: String,
 }
 
-#[derive(Debug, Clone, Serialize, Deserialize)]
-struct AgentToolResult {
-    #[serde(rename = "toolName")]
-    tool_name: String,
-    output: String,
-    #[serde(rename = "isError")]
-    is_error: bool,
-}
-
 #[derive(Debug, Serialize)]
 struct ToolSearchOutput {
     matches: Vec<String>,
@@ -1228,9 +1199,10 @@ fn execute_todo_write(input: TodoWriteInput) -> Result<TodoWriteOutput, String>
     validate_todos(&input.todos)?;
     let store_path = todo_store_path()?;
     let old_todos = if store_path.exists() {
-        parse_todo_markdown(
+        serde_json::from_str::<Vec<TodoItem>>(
             &std::fs::read_to_string(&store_path).map_err(|error| error.to_string())?,
-        )?
+        )
+        .map_err(|error| error.to_string())?
     } else {
         Vec::new()
     };
@@ -1248,8 +1220,11 @@ fn execute_todo_write(input: TodoWriteInput) -> Result<TodoWriteOutput, String>
     if let Some(parent) = store_path.parent() {
         std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
     }
-    std::fs::write(&store_path, render_todo_markdown(&persisted))
-        .map_err(|error| error.to_string())?;
+    std::fs::write(
+        &store_path,
+        serde_json::to_string_pretty(&persisted).map_err(|error| error.to_string())?,
+    )
+    .map_err(|error| error.to_string())?;
 
     let verification_nudge_needed = (all_done
         && input.todos.len() >= 3
@@ -1307,58 +1282,7 @@ fn todo_store_path() -> Result<std::path::PathBuf, String> {
         return Ok(std::path::PathBuf::from(path));
     }
     let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
-    Ok(cwd.join(".claude").join("todos.md"))
-}
-
-fn render_todo_markdown(todos: &[TodoItem]) -> String {
-    let mut lines = vec!["# Todo list".to_string(), String::new()];
-    for todo in todos {
-        let marker = match todo.status {
-            TodoStatus::Pending => "[ ]",
-            TodoStatus::InProgress => "[~]",
-            TodoStatus::Completed => "[x]",
-        };
-        lines.push(format!(
-            "- {marker} {} :: {}",
-            todo.content, todo.active_form
-        ));
-    }
-    lines.push(String::new());
-    lines.join("\n")
-}
-
-fn parse_todo_markdown(content: &str) -> Result<Vec<TodoItem>, String> {
-    let mut todos = Vec::new();
-    for line in content.lines() {
-        let trimmed = line.trim();
-        if trimmed.is_empty() || trimmed.starts_with('#') {
-            continue;
-        }
-        let Some(rest) = trimmed.strip_prefix("- [") else {
-            continue;
-        };
-        let mut chars = rest.chars();
-        let status = match chars.next() {
-            Some(' ') => TodoStatus::Pending,
-            Some('~') => TodoStatus::InProgress,
-            Some('x' | 'X') => TodoStatus::Completed,
-            Some(other) => return Err(format!("unsupported todo status marker: {other}")),
-            None => return Err(String::from("malformed todo line")),
-        };
-        let remainder = chars.as_str();
-        let Some(body) = remainder.strip_prefix("] ") else {
-            return Err(String::from("malformed todo line"));
-        };
-        let Some((content, active_form)) = body.split_once(" :: ") else {
-            return Err(String::from("todo line missing active form separator"));
-        };
-        todos.push(TodoItem {
-            content: content.trim().to_string(),
-            active_form: active_form.trim().to_string(),
-            status,
-        });
-    }
-    Ok(todos)
+    Ok(cwd.join(".clawd-todos.json"))
 }
 
 fn resolve_skill_path(skill: &str) -> Result<std::path::PathBuf, String> {
@@ -1407,14 +1331,6 @@ fn execute_agent(input: AgentInput) -> Result<AgentOutput, String> {
         return Err(String::from("prompt must not be empty"));
     }
 
-    let depth = current_agent_depth()?;
-    let max_depth = input.max_depth.unwrap_or(3);
-    if depth >= max_depth {
-        return Err(format!(
-            "Agent max_depth exceeded: current depth {depth} reached limit {max_depth}"
-        ));
-    }
-
     let agent_id = make_agent_id();
     let output_dir = agent_store_dir()?;
     std::fs::create_dir_all(&output_dir).map_err(|error| error.to_string())?;
@@ -1428,31 +1344,35 @@ fn execute_agent(input: AgentInput) -> Result<AgentOutput, String> {
         .filter(|name| !name.is_empty())
         .unwrap_or_else(|| slugify_agent_name(&input.description));
     let created_at = iso8601_now();
-    let model = input.model.clone().or_else(agent_default_model);
 
-    let child_result = with_agent_depth(depth + 1, || {
-        run_child_agent_conversation(&input.prompt, model.clone(), max_depth)
-    })?;
+    let output_contents = format!(
+        "# Agent Task
+
+- id: {}
+- name: {}
+- description: {}
+- subagent_type: {}
+- created_at: {}
+
+## Prompt
+
+{}
+",
+        agent_id, agent_name, input.description, normalized_subagent_type, created_at, input.prompt
+    );
+    std::fs::write(&output_file, output_contents).map_err(|error| error.to_string())?;
 
     let manifest = AgentOutput {
         agent_id,
         name: agent_name,
         description: input.description,
         subagent_type: Some(normalized_subagent_type),
-        model,
-        status: String::from("completed"),
-        max_depth,
-        depth,
-        result: child_result.result.clone(),
-        assistant_messages: child_result.assistant_messages.clone(),
-        tool_results: child_result.tool_results.clone(),
+        model: input.model,
+        status: String::from("queued"),
         output_file: output_file.display().to_string(),
         manifest_file: manifest_file.display().to_string(),
         created_at,
     };
-
-    let output_contents = render_agent_output(&manifest);
-    std::fs::write(&output_file, output_contents).map_err(|error| error.to_string())?;
     std::fs::write(
         &manifest_file,
         serde_json::to_string_pretty(&manifest).map_err(|error| error.to_string())?,
@@ -1462,466 +1382,6 @@ fn execute_agent(input: AgentInput) -> Result<AgentOutput, String> {
     Ok(manifest)
 }
 
-#[derive(Debug, Clone)]
-struct ChildConversationResult {
-    result: Option<String>,
-    assistant_messages: Vec<String>,
-    tool_results: Vec<AgentToolResult>,
-}
-
-fn run_child_agent_conversation(
-    prompt: &str,
-    model: Option<String>,
-    _max_depth: usize,
-) -> Result<ChildConversationResult, String> {
-    let mut runtime = ConversationRuntime::new(
-        Session::new(),
-        build_agent_api_client(model.unwrap_or_else(default_agent_model))?,
-        AgentToolExecutor,
-        agent_permission_policy(),
-        build_agent_system_prompt()?,
-    )
-    .with_max_iterations(16);
-
-    let summary = runtime
-        .run_turn(prompt, None)
-        .map_err(|error| error.to_string())?;
-
-    let assistant_messages = summary
-        .assistant_messages
-        .iter()
-        .filter_map(extract_message_text)
-        .collect::<Vec<_>>();
-    let tool_results = summary
-        .tool_results
-        .iter()
-        .filter_map(extract_agent_tool_result)
-        .collect::<Vec<_>>();
-    let result = assistant_messages.last().cloned();
-
-    Ok(ChildConversationResult {
-        result,
-        assistant_messages,
-        tool_results,
-    })
-}
-
-fn render_agent_output(output: &AgentOutput) -> String {
-    let mut lines = vec![
-        "# Agent Task".to_string(),
-        String::new(),
-        format!("- id: {}", output.agent_id),
-        format!("- name: {}", output.name),
-        format!("- description: {}", output.description),
-        format!(
-            "- subagent_type: {}",
-            output.subagent_type.as_deref().unwrap_or("general-purpose")
-        ),
-        format!("- status: {}", output.status),
-        format!("- depth: {}", output.depth),
-        format!("- max_depth: {}", output.max_depth),
-        format!("- created_at: {}", output.created_at),
-        String::new(),
-        "## Result".to_string(),
-        String::new(),
-        output
-            .result
-            .clone()
-            .unwrap_or_else(|| String::from("<no final assistant text>")),
-    ];
-
-    if !output.tool_results.is_empty() {
-        lines.push(String::new());
-        lines.push("## Tool Results".to_string());
-        lines.push(String::new());
-        lines.extend(output.tool_results.iter().map(|result| {
-            format!(
-                "- {} [{}]: {}",
-                result.tool_name,
-                if result.is_error { "error" } else { "ok" },
-                result.output
-            )
-        }));
-    }
-
-    lines.join("\n")
-}
-
-fn current_agent_depth() -> Result<usize, String> {
-    std::env::var("CLAWD_AGENT_DEPTH")
-        .ok()
-        .map(|value| {
-            value
-                .parse::<usize>()
-                .map_err(|error| format!("invalid CLAWD_AGENT_DEPTH: {error}"))
-        })
-        .transpose()
-        .map(|value| value.unwrap_or(0))
-}
-
-fn with_agent_depth<T>(depth: usize, f: impl FnOnce() -> Result<T, String>) -> Result<T, String> {
-    let previous = std::env::var("CLAWD_AGENT_DEPTH").ok();
-    std::env::set_var("CLAWD_AGENT_DEPTH", depth.to_string());
-    let result = f();
-    if let Some(previous) = previous {
-        std::env::set_var("CLAWD_AGENT_DEPTH", previous);
-    } else {
-        std::env::remove_var("CLAWD_AGENT_DEPTH");
-    }
-    result
-}
-
-fn agent_default_model() -> Option<String> {
-    std::env::var("CLAWD_MODEL")
-        .ok()
-        .filter(|value| !value.trim().is_empty())
-}
-
-fn default_agent_model() -> String {
-    agent_default_model().unwrap_or_else(|| String::from("claude-sonnet-4-20250514"))
-}
-
-fn build_agent_system_prompt() -> Result<Vec<String>, String> {
-    let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
-    let date = std::env::var("CLAWD_CURRENT_DATE").unwrap_or_else(|_| String::from("2026-04-01"));
-    load_system_prompt(cwd, &date, std::env::consts::OS, "unknown")
-        .map_err(|error| error.to_string())
-}
-
-fn agent_permission_policy() -> PermissionPolicy {
-    mvp_tool_specs().into_iter().fold(
-        PermissionPolicy::new(PermissionMode::DangerFullAccess),
-        |policy, spec| policy.with_tool_requirement(spec.name, spec.required_permission),
-    )
-}
-
-struct AgentToolExecutor;
-
-impl ToolExecutor for AgentToolExecutor {
-    fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> {
-        let value = serde_json::from_str(input)
-            .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
-        execute_tool(tool_name, &value).map_err(ToolError::new)
-    }
-}
-
-enum AgentApiClient {
-    Scripted(ScriptedAgentApiClient),
-    Anthropic(AnthropicAgentApiClient),
-}
-
-impl ApiClient for AgentApiClient {
-    fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
-        match self {
-            Self::Scripted(client) => client.stream(request),
-            Self::Anthropic(client) => client.stream(request),
-        }
-    }
-}
-
-fn build_agent_api_client(model: String) -> Result<AgentApiClient, String> {
-    if let Some(script) = std::env::var("CLAWD_AGENT_TEST_SCRIPT")
-        .ok()
-        .filter(|value| !value.trim().is_empty())
-    {
-        return Ok(AgentApiClient::Scripted(ScriptedAgentApiClient::new(
-            &script,
-        )?));
-    }
-
-    Ok(AgentApiClient::Anthropic(AnthropicAgentApiClient::new(
-        model,
-    )?))
-}
-
-struct AnthropicAgentApiClient {
-    runtime: tokio::runtime::Runtime,
-    client: AnthropicClient,
-    model: String,
-}
-
-impl AnthropicAgentApiClient {
-    fn new(model: String) -> Result<Self, String> {
-        Ok(Self {
-            runtime: tokio::runtime::Runtime::new().map_err(|error| error.to_string())?,
-            client: AnthropicClient::from_auth(resolve_agent_auth_source()?),
-            model,
-        })
-    }
-}
-
-impl ApiClient for AnthropicAgentApiClient {
-    fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
-        let message_request = MessageRequest {
-            model: self.model.clone(),
-            max_tokens: 32,
-            messages: convert_agent_messages(&request.messages),
-            system: (!request.system_prompt.is_empty()).then(|| {
-                request.system_prompt.join(
-                    "
-
-",
-                )
-            }),
-            tools: Some(agent_tool_definitions()),
-            tool_choice: Some(ToolChoice::Auto),
-            stream: true,
-            thinking: None,
-        };
-
-        self.runtime.block_on(async {
-            let mut stream = self
-                .client
-                .stream_message(&message_request)
-                .await
-                .map_err(|error| RuntimeError::new(error.to_string()))?;
-            let mut events = Vec::new();
-            let mut pending_tool: Option<(String, String, String)> = None;
-            let mut saw_stop = false;
-
-            while let Some(event) = stream
-                .next_event()
-                .await
-                .map_err(|error| RuntimeError::new(error.to_string()))?
-            {
-                match event {
-                    ApiStreamEvent::MessageStart(start) => {
-                        push_agent_output_blocks(
-                            start.message.content,
-                            &mut events,
-                            &mut pending_tool,
-                        );
-                    }
-                    ApiStreamEvent::ContentBlockStart(start) => {
-                        push_agent_output_block(
-                            start.content_block,
-                            &mut events,
-                            &mut pending_tool,
-                        );
-                    }
-                    ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta {
-                        ContentBlockDelta::TextDelta { text } => {
-                            if !text.is_empty() {
-                                events.push(AssistantEvent::TextDelta(text));
-                            }
-                        }
-                        ContentBlockDelta::InputJsonDelta { partial_json } => {
-                            if let Some((_, _, input)) = &mut pending_tool {
-                                input.push_str(&partial_json);
-                            }
-                        }
-                        ContentBlockDelta::ThinkingDelta { .. }
-                        | ContentBlockDelta::SignatureDelta { .. } => {}
-                    },
-                    ApiStreamEvent::ContentBlockStop(_) => {
-                        if let Some((id, name, input)) = pending_tool.take() {
-                            events.push(AssistantEvent::ToolUse { id, name, input });
-                        }
-                    }
-                    ApiStreamEvent::MessageDelta(delta) => {
-                        events.push(AssistantEvent::Usage(TokenUsage {
-                            input_tokens: delta.usage.input_tokens,
-                            output_tokens: delta.usage.output_tokens,
-                            cache_creation_input_tokens: delta.usage.cache_creation_input_tokens,
-                            cache_read_input_tokens: delta.usage.cache_read_input_tokens,
-                        }));
-                    }
-                    ApiStreamEvent::MessageStop(_) => {
-                        saw_stop = true;
-                        events.push(AssistantEvent::MessageStop);
-                    }
-                }
-            }
-
-            if !saw_stop {
-                events.push(AssistantEvent::MessageStop);
-            }
-
-            Ok(events)
-        })
-    }
-}
-
-fn resolve_agent_auth_source() -> Result<api::AuthSource, String> {
-    resolve_startup_auth_source(|| {
-        let cwd = std::env::current_dir().map_err(api::ApiError::from)?;
-        let config = ConfigLoader::default_for(&cwd).load().map_err(|error| {
-            api::ApiError::Auth(format!("failed to load runtime OAuth config: {error}"))
-        })?;
-        Ok(config.oauth().cloned())
-    })
-    .map_err(|error| error.to_string())
-}
-
-fn agent_tool_definitions() -> Vec<ToolDefinition> {
-    mvp_tool_specs()
-        .into_iter()
-        .map(|spec| ToolDefinition {
-            name: spec.name.to_string(),
-            description: Some(spec.description.to_string()),
-            input_schema: spec.input_schema,
-        })
-        .collect()
-}
-
-fn convert_agent_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
-    messages
-        .iter()
-        .filter_map(|message| {
-            let role = match message.role {
-                MessageRole::System | MessageRole::User | MessageRole::Tool => "user",
-                MessageRole::Assistant => "assistant",
-            };
-            let content = message
-                .blocks
-                .iter()
-                .map(|block| match block {
-                    ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() },
-                    ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse {
-                        id: id.clone(),
-                        name: name.clone(),
-                        input: serde_json::from_str(input)
-                            .unwrap_or_else(|_| serde_json::json!({ "raw": input })),
-                    },
-                    ContentBlock::ToolResult {
-                        tool_use_id,
-                        output,
-                        is_error,
-                        ..
-                    } => InputContentBlock::ToolResult {
-                        tool_use_id: tool_use_id.clone(),
-                        content: vec![ToolResultContentBlock::Text {
-                            text: output.clone(),
-                        }],
-                        is_error: *is_error,
-                    },
-                    ContentBlock::Thinking { .. } => InputContentBlock::Text { text: String::new() },
-                })
-                .collect::<Vec<_>>();
-            (!content.is_empty()).then(|| InputMessage {
-                role: role.to_string(),
-                content,
-            })
-        })
-        .collect()
-}
-
-fn push_agent_output_blocks(
-    blocks: Vec<OutputContentBlock>,
-    events: &mut Vec<AssistantEvent>,
-    pending_tool: &mut Option<(String, String, String)>,
-) {
-    for block in blocks {
-        push_agent_output_block(block, events, pending_tool);
-        if let Some((id, name, input)) = pending_tool.take() {
-            events.push(AssistantEvent::ToolUse { id, name, input });
-        }
-    }
-}
-
-fn push_agent_output_block(
-    block: OutputContentBlock,
-    events: &mut Vec<AssistantEvent>,
-    pending_tool: &mut Option<(String, String, String)>,
-) {
-    match block {
-        OutputContentBlock::Text { text } => {
-            if !text.is_empty() {
-                events.push(AssistantEvent::TextDelta(text));
-            }
-        }
-        OutputContentBlock::ToolUse { id, name, input } => {
-            *pending_tool = Some((id, name, input.to_string()));
-        }
-        OutputContentBlock::Thinking { .. } => {}
-    }
-}
-
-#[derive(Debug)]
-struct ScriptedAgentApiClient {
-    turns: Vec<Vec<ScriptedAgentEvent>>,
-    call_count: usize,
-}
-
-impl ScriptedAgentApiClient {
-    fn new(script: &str) -> Result<Self, String> {
-        let turns = serde_json::from_str(script).map_err(|error| error.to_string())?;
-        Ok(Self {
-            turns,
-            call_count: 0,
-        })
-    }
-}
-
-impl ApiClient for ScriptedAgentApiClient {
-    fn stream(&mut self, _request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
-        if self.call_count >= self.turns.len() {
-            return Err(RuntimeError::new("scripted agent client exhausted"));
-        }
-        let events = self.turns[self.call_count]
-            .iter()
-            .map(ScriptedAgentEvent::to_runtime_event)
-            .chain(std::iter::once(AssistantEvent::MessageStop))
-            .collect();
-        self.call_count += 1;
-        Ok(events)
-    }
-}
-
-#[derive(Debug, Clone, Deserialize)]
-#[serde(tag = "type", rename_all = "snake_case")]
-enum ScriptedAgentEvent {
-    Text {
-        text: String,
-    },
-    ToolUse {
-        id: String,
-        name: String,
-        input: Value,
-    },
-}
-
-impl ScriptedAgentEvent {
-    fn to_runtime_event(&self) -> AssistantEvent {
-        match self {
-            Self::Text { text } => AssistantEvent::TextDelta(text.clone()),
-            Self::ToolUse { id, name, input } => AssistantEvent::ToolUse {
-                id: id.clone(),
-                name: name.clone(),
-                input: input.to_string(),
-            },
-        }
-    }
-}
-
-fn extract_message_text(message: &ConversationMessage) -> Option<String> {
-    let text = message
-        .blocks
-        .iter()
-        .filter_map(|block| match block {
-            ContentBlock::Text { text } => Some(text.as_str()),
-            _ => None,
-        })
-        .collect::<String>();
-    (!text.is_empty()).then_some(text)
-}
-
-fn extract_agent_tool_result(message: &ConversationMessage) -> Option<AgentToolResult> {
-    message.blocks.iter().find_map(|block| match block {
-        ContentBlock::ToolResult {
-            tool_name,
-            output,
-            is_error,
-            ..
-        } => Some(AgentToolResult {
-            tool_name: tool_name.clone(),
-            output: output.clone(),
-            is_error: *is_error,
-        }),
-        _ => None,
-    })
-}
-
 #[allow(clippy::needless_pass_by_value)]
 fn execute_tool_search(input: ToolSearchInput) -> ToolSearchOutput {
     let deferred = deferred_tool_specs();
@@ -2754,7 +2214,8 @@ fn execute_shell_command(
             structured_content: None,
             persisted_output_path: None,
             persisted_output_size: None,
-        });
+            sandbox_status: None,
+});
     }
 
     let mut process = std::process::Command::new(shell);
@@ -2791,6 +2252,7 @@ fn execute_shell_command(
                     structured_content: None,
                     persisted_output_path: None,
                     persisted_output_size: None,
+                    sandbox_status: None,
                 });
             }
             if started.elapsed() >= Duration::from_millis(timeout_ms) {
@@ -2821,7 +2283,8 @@ Command exceeded timeout of {timeout_ms} ms",
                     structured_content: None,
                     persisted_output_path: None,
                     persisted_output_size: None,
-                });
+                    sandbox_status: None,
+});
             }
             std::thread::sleep(Duration::from_millis(10));
         }
@@ -2847,6 +2310,7 @@ Command exceeded timeout of {timeout_ms} ms",
         structured_content: None,
         persisted_output_path: None,
         persisted_output_size: None,
+        sandbox_status: None,
     })
 }
 
@@ -3178,37 +2642,6 @@ mod tests {
         assert!(second_output["verificationNudgeNeeded"].is_null());
     }
 
-    #[test]
-    fn todo_write_persists_markdown_in_claude_directory() {
-        let _guard = env_lock()
-            .lock()
-            .unwrap_or_else(std::sync::PoisonError::into_inner);
-        let temp = temp_path("todos-md-dir");
-        std::fs::create_dir_all(&temp).expect("temp dir");
-        let previous = std::env::current_dir().expect("cwd");
-        std::env::set_current_dir(&temp).expect("set cwd");
-
-        execute_tool(
-            "TodoWrite",
-            &json!({
-                "todos": [
-                    {"content": "Add tool", "activeForm": "Adding tool", "status": "in_progress"},
-                    {"content": "Run tests", "activeForm": "Running tests", "status": "pending"}
-                ]
-            }),
-        )
-        .expect("TodoWrite should succeed");
-
-        let persisted = std::fs::read_to_string(temp.join(".claude").join("todos.md"))
-            .expect("todo markdown exists");
-        std::env::set_current_dir(previous).expect("restore cwd");
-        let _ = std::fs::remove_dir_all(temp);
-
-        assert!(persisted.contains("# Todo list"));
-        assert!(persisted.contains("- [~] Add tool :: Adding tool"));
-        assert!(persisted.contains("- [ ] Run tests :: Running tests"));
-    }
-
     #[test]
     fn todo_write_rejects_invalid_payloads_and_sets_verification_nudge() {
         let _guard = env_lock()
@@ -3334,28 +2767,12 @@ mod tests {
     }
 
     #[test]
-    fn agent_executes_child_conversation_and_persists_results() {
+    fn agent_persists_handoff_metadata() {
         let _guard = env_lock()
             .lock()
             .unwrap_or_else(std::sync::PoisonError::into_inner);
         let dir = temp_path("agent-store");
         std::env::set_var("CLAWD_AGENT_STORE", &dir);
-        std::env::set_var(
-            "CLAWD_AGENT_TEST_SCRIPT",
-            serde_json::to_string(&vec![
-                vec![json!({
-                    "type": "tool_use",
-                    "id": "tool-1",
-                    "name": "StructuredOutput",
-                    "input": {"ok": true, "items": [1, 2, 3]}
-                })],
-                vec![json!({
-                    "type": "text",
-                    "text": "Child agent completed successfully."
-                })],
-            ])
-            .expect("script json"),
-        );
 
         let result = execute_tool(
             "Agent",
@@ -3367,35 +2784,22 @@ mod tests {
             }),
         )
         .expect("Agent should succeed");
-        std::env::remove_var("CLAWD_AGENT_TEST_SCRIPT");
         std::env::remove_var("CLAWD_AGENT_STORE");
 
         let output: serde_json::Value = serde_json::from_str(&result).expect("valid json");
         assert_eq!(output["name"], "ship-audit");
         assert_eq!(output["subagentType"], "Explore");
-        assert_eq!(output["status"], "completed");
-        assert_eq!(output["depth"], 0);
-        assert_eq!(output["maxDepth"], 3);
-        assert_eq!(output["result"], "Child agent completed successfully.");
-        assert_eq!(output["toolResults"][0]["toolName"], "StructuredOutput");
-        assert_eq!(output["toolResults"][0]["isError"], false);
+        assert_eq!(output["status"], "queued");
+        assert!(output["createdAt"].as_str().is_some());
         let manifest_file = output["manifestFile"].as_str().expect("manifest file");
         let output_file = output["outputFile"].as_str().expect("output file");
         let contents = std::fs::read_to_string(output_file).expect("agent file exists");
         let manifest_contents =
             std::fs::read_to_string(manifest_file).expect("manifest file exists");
-        assert!(contents.contains("Child agent completed successfully."));
-        assert!(contents.contains("StructuredOutput [ok]"));
+        assert!(contents.contains("Audit the branch"));
+        assert!(contents.contains("Check tests and outstanding work."));
         assert!(manifest_contents.contains("\"subagentType\": \"Explore\""));
 
-        std::env::set_var(
-            "CLAWD_AGENT_TEST_SCRIPT",
-            serde_json::to_string(&vec![vec![json!({
-                "type": "text",
-                "text": "Normalized alias check."
-            })]])
-            .expect("script json"),
-        );
         let normalized = execute_tool(
             "Agent",
             &json!({
@@ -3405,19 +2809,10 @@ mod tests {
             }),
         )
         .expect("Agent should normalize built-in aliases");
-        std::env::remove_var("CLAWD_AGENT_TEST_SCRIPT");
         let normalized_output: serde_json::Value =
             serde_json::from_str(&normalized).expect("valid json");
         assert_eq!(normalized_output["subagentType"], "Explore");
 
-        std::env::set_var(
-            "CLAWD_AGENT_TEST_SCRIPT",
-            serde_json::to_string(&vec![vec![json!({
-                "type": "text",
-                "text": "Name normalization check."
-            })]])
-            .expect("script json"),
-        );
         let named = execute_tool(
             "Agent",
             &json!({
@@ -3427,14 +2822,13 @@ mod tests {
             }),
         )
         .expect("Agent should normalize explicit names");
-        std::env::remove_var("CLAWD_AGENT_TEST_SCRIPT");
         let named_output: serde_json::Value = serde_json::from_str(&named).expect("valid json");
         assert_eq!(named_output["name"], "ship-audit");
         let _ = std::fs::remove_dir_all(dir);
     }
 
     #[test]
-    fn agent_rejects_blank_required_fields_and_enforces_max_depth() {
+    fn agent_rejects_blank_required_fields() {
         let missing_description = execute_tool(
             "Agent",
             &json!({
@@ -3454,22 +2848,6 @@ mod tests {
         )
         .expect_err("blank prompt should fail");
         assert!(missing_prompt.contains("prompt must not be empty"));
-
-        let _guard = env_lock()
-            .lock()
-            .unwrap_or_else(std::sync::PoisonError::into_inner);
-        std::env::set_var("CLAWD_AGENT_DEPTH", "1");
-        let depth_error = execute_tool(
-            "Agent",
-            &json!({
-                "description": "Nested agent",
-                "prompt": "Do nested work.",
-                "max_depth": 1
-            }),
-        )
-        .expect_err("max depth should fail");
-        std::env::remove_var("CLAWD_AGENT_DEPTH");
-        assert!(depth_error.contains("max_depth exceeded"));
     }
 
     #[test]