Skip to main content

bamboo_agent/
claude_code_executor.rs

1//! `ClaudeCodeExecutor`: a [`ChildExecutor`] that drives the official Claude
2//! Code CLI (`claude`) as an external sub-agent engine over its stream-json
3//! wire protocol. See `docs/claude-code-executor.md` for the full protocol
4//! reference (spawn flags, NDJSON frame table, permission relay, shutdown)
5//! this implementation follows.
6//!
7//! MVP scope (issue #441): spawn a **fresh `claude` process per `run()` call**
8//! (one activation = one turn), map its stdout frames onto the same
9//! `AgentEvent`s the real bamboo runtime emits (so the parent's child preview
10//! renders identically — see [`BambooRuntimeExecutor`](crate::subagent_worker::BambooRuntimeExecutor)),
11//! and relay `can_use_tool` permission asks through [`EventSink::host`] when a
12//! host bridge is wired. Mid-turn steering remains out of scope — see the doc
13//! comment on [`ChildExecutor::run`]'s `steer` parameter below.
14//!
15//! Session resume (issue #444): `RunSpec.messages` empty/non-empty is the
16//! discriminant a reactivation ships (`proto.rs:28`). A non-empty shipment
17//! means this activation has prior context, resolved by [`ClaudeCodeExecutor::run`]
18//! in four steps — see its doc comment for the full state-machine and
19//! `docs/claude-code-executor.md` §4 for the on-disk state file shape.
20
21use std::collections::HashMap;
22use std::path::PathBuf;
23use std::process::Stdio;
24use std::sync::Arc;
25use std::time::Duration;
26
27use async_trait::async_trait;
28use chrono::{DateTime, Utc};
29use serde::{Deserialize, Serialize};
30use serde_json::{json, Value};
31use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
32use tokio::process::{Child, Command};
33use tokio::sync::{mpsc, Mutex};
34use tokio::task::JoinHandle;
35use tokio_util::sync::CancellationToken;
36
37use bamboo_agent_core::{AgentEvent, TokenUsage, ToolResult};
38use bamboo_subagent::executor::{ChildExecutor, ChildOutcome, EventSink, HostBridge, SteerInbox};
39use bamboo_subagent::proto::RunSpec;
40
41/// Upper bound on a single stdout NDJSON line. Tool results can be huge (the
42/// protocol doc specifies a 10 MB scanner buffer at `docs/claude-code-executor.md`
43/// §2); enforced incrementally via `fill_buf`/`consume` in [`read_bounded_line`]
44/// so a runaway line is capped in memory as it streams in, not merely rejected
45/// after already having been buffered in full.
46const MAX_STDOUT_LINE_BYTES: usize = 10 * 1024 * 1024;
47
48/// Tail of stderr retained (for the error message on an exit with no `result`
49/// frame) — bounded so a chatty child can't grow this without limit.
50const STDERR_TAIL_BYTES: usize = 16 * 1024;
51
52/// Tool-result content is truncated to this many characters before riding the
53/// `ToolComplete`/`ToolError` event (doc's "truncate" guidance — the full
54/// result already lives in the Claude Code CLI's own transcript on disk).
55const TOOL_RESULT_TRUNCATE_CHARS: usize = 20_000;
56
57/// Phase 2 of shutdown (§5 of the protocol doc): bounded wait for a natural
58/// exit after stdin closes (lets the CLI run its Stop hooks). Shorter than
59/// cc-connect's 120s — this is a subagent activation, not an interactive
60/// session, and the caller (the actor transport) has its own outer timeout.
61const GRACEFUL_EXIT_WAIT: Duration = Duration::from_secs(5);
62/// Phase 3: bounded wait after SIGTERM before escalating to SIGKILL.
63const SIGTERM_WAIT: Duration = Duration::from_secs(2);
64
65/// Issue #443: how long [`decide_and_respond`] waits on [`HostBridge::approval_call`]
66/// before giving up and denying. A host-in-the-loop approver that never comes
67/// back (crashed UI, orphaned session, dropped WS with no error surfaced)
68/// would otherwise hang the CLI turn forever — this bounds the relay the same
69/// way the graceful-shutdown phases bound the process lifecycle.
70const APPROVAL_RELAY_TIMEOUT: Duration = Duration::from_secs(300);
71
72/// Issue #443: fixed env allowlist forwarded from the parent process env to
73/// every spawned `claude` child (on top of any executor-specific
74/// `forward_env` names) — `env_clear()` in [`ClaudeCodeExecutor::build_command`]
75/// strips everything else, including `*_API_KEY` and other ambient secrets.
76/// `LC_*` (locale vars: `LC_ALL`, `LC_CTYPE`, …) is matched by prefix, not
77/// listed here.
78const ENV_ALLOWLIST: &[&str] = &[
79    "HOME", "PATH", "SHELL", "TERM", "LANG", "TMPDIR", "USER", "LOGNAME",
80];
81
82/// Resolve this actor's stable per-child storage dir, exactly like
83/// [`BambooRuntimeExecutor::build`](crate::subagent_worker::BambooRuntimeExecutor::build)
84/// (`subagent_worker.rs:194-202`): `spec.storage_dir` when the parent already
85/// isolated it, else a temp dir keyed by `child_id` — stable across
86/// activations of the SAME child. Both `ExecutorSpec::ClaudeCode` factory
87/// arms (`subagent_worker.rs`, `broker_agent.rs`) call this to give
88/// [`ClaudeCodeExecutor::new`]'s `state_dir` a location the resumed-session
89/// state file (issue #444) survives in.
90pub fn resolve_claude_code_state_dir(storage_dir: &Option<String>, child_id: &str) -> PathBuf {
91    storage_dir
92        .clone()
93        .map(PathBuf::from)
94        .unwrap_or_else(|| std::env::temp_dir().join("bamboo-subagents").join(child_id))
95}
96
97/// State file name inside a child's stable storage dir (issue #444). Holds the
98/// agent-assigned Claude Code session id this actor last saw, so the NEXT
99/// activation of the SAME child can `--resume` it instead of losing context.
100const STATE_FILE_NAME: &str = "claude-code-session.json";
101
102/// Fallback history preamble cap (issue #444 test plan: "~24k chars / last
103/// ~40 messages, oldest dropped first").
104const HISTORY_PREAMBLE_MAX_CHARS: usize = 24_000;
105const HISTORY_PREAMBLE_MAX_MESSAGES: usize = 40;
106
107/// On-disk shape of [`STATE_FILE_NAME`]. `workspace` is recorded so a later
108/// activation on a DIFFERENT workspace (or a different machine — Claude Code
109/// transcripts are machine-local under `~/.claude/projects/<hashed-workdir>/`,
110/// docs §4) treats the persisted id as unusable rather than resuming into the
111/// wrong project.
112#[derive(Debug, Clone, Serialize, Deserialize)]
113struct ClaudeSessionState {
114    session_id: String,
115    workspace: Option<String>,
116    updated_at: DateTime<Utc>,
117}
118
119/// Drives `claude --output-format stream-json --input-format stream-json ...`
120/// as the engine behind one sub-agent run.
121pub struct ClaudeCodeExecutor {
122    /// Executable to spawn. Defaults to `"claude"` (resolved via `PATH`);
123    /// tests override it with a stub script.
124    binary: String,
125    model: Option<String>,
126    permission_mode: Option<String>,
127    /// Working directory for the spawned CLI's file tools. `None` inherits the
128    /// worker process's own cwd (mirrors how [`BambooRuntimeExecutor`](crate::subagent_worker::BambooRuntimeExecutor)
129    /// treats an absent `ProvisionSpec.workspace`).
130    workspace: Option<String>,
131    /// This child's stable per-activation storage dir, used to persist
132    /// [`STATE_FILE_NAME`] across activations (issue #444). Resolved by the
133    /// caller exactly like [`BambooRuntimeExecutor::build`](crate::subagent_worker::BambooRuntimeExecutor::build)
134    /// (`spec.storage_dir` else a temp dir keyed by `child_id`) — NOT inside
135    /// this constructor, so tests can point it at an isolated tempdir. `None`
136    /// disables resume persistence entirely (every activation is fresh).
137    state_dir: Option<PathBuf>,
138    /// Issue #443: `false` (the default) adds `--strict-mcp-config` and
139    /// `--setting-sources project` so the child does NOT load the invoking
140    /// user's `~/.claude` MCP servers/skills/settings. `true` omits both
141    /// flags (the old inherit-everything behavior).
142    inherit_user_config: bool,
143    /// Issue #443: extra env var NAMES forwarded verbatim from the parent
144    /// process env, on top of the fixed [`ENV_ALLOWLIST`].
145    forward_env: Vec<String>,
146    /// Issue #443: bound on [`HostBridge::approval_call`] in
147    /// [`decide_and_respond`]. Always [`APPROVAL_RELAY_TIMEOUT`] outside
148    /// tests; overridable via [`Self::with_relay_timeout_for_test`] so a unit
149    /// test exercising the expiry path doesn't have to wait 300s.
150    relay_timeout: Duration,
151}
152
153impl ClaudeCodeExecutor {
154    #[allow(clippy::too_many_arguments)]
155    pub fn new(
156        binary: Option<String>,
157        model: Option<String>,
158        permission_mode: Option<String>,
159        workspace: Option<String>,
160        state_dir: Option<PathBuf>,
161        inherit_user_config: bool,
162        forward_env: Vec<String>,
163    ) -> Self {
164        Self {
165            binary: binary.unwrap_or_else(|| "claude".to_string()),
166            model,
167            permission_mode,
168            workspace,
169            state_dir,
170            inherit_user_config,
171            forward_env,
172            relay_timeout: APPROVAL_RELAY_TIMEOUT,
173        }
174    }
175
176    /// Test-only override of [`Self::relay_timeout`] — production callers
177    /// always get [`APPROVAL_RELAY_TIMEOUT`]. Lets a unit test exercise the
178    /// timeout-expiry path in milliseconds instead of 300s.
179    #[cfg(test)]
180    fn with_relay_timeout_for_test(mut self, timeout: Duration) -> Self {
181        self.relay_timeout = timeout;
182        self
183    }
184
185    fn state_file_path(&self) -> Option<PathBuf> {
186        self.state_dir.as_ref().map(|dir| dir.join(STATE_FILE_NAME))
187    }
188
189    /// Read and parse the state file, if any. Any failure (missing dir, no
190    /// file, corrupt JSON) is treated as "no usable id" rather than an error
191    /// — resume is a best-effort optimization, never a hard requirement.
192    async fn read_state(&self) -> Option<ClaudeSessionState> {
193        let path = self.state_file_path()?;
194        let bytes = tokio::fs::read(&path).await.ok()?;
195        serde_json::from_slice(&bytes).ok()
196    }
197
198    /// Delete the state file (best-effort). Called before a fresh-session
199    /// activation (`messages` empty) so a subsequent `rerun` never
200    /// accidentally resumes stale context, and before a resume-failure retry
201    /// so a garbage-collected/bad id isn't offered again.
202    async fn delete_state_file(&self) {
203        if let Some(path) = self.state_file_path() {
204            let _ = tokio::fs::remove_file(&path).await;
205        }
206    }
207
208    /// Atomically persist the session id this activation last saw (tmp file +
209    /// rename, in the same dir so the rename is same-filesystem). Called on
210    /// EVERY `system`/`result` frame that carries a session id — a resumed
211    /// session may be assigned a brand-new one, so this always re-captures
212    /// rather than assuming stability.
213    async fn write_state(&self, session_id: &str) {
214        let Some(dir) = &self.state_dir else { return };
215        let path = dir.join(STATE_FILE_NAME);
216        let state = ClaudeSessionState {
217            session_id: session_id.to_string(),
218            workspace: self.workspace.clone(),
219            updated_at: Utc::now(),
220        };
221        let Ok(bytes) = serde_json::to_vec_pretty(&state) else {
222            return;
223        };
224        if tokio::fs::create_dir_all(dir).await.is_err() {
225            return;
226        }
227        // Unique tmp name (pid + session id) so concurrent activations of
228        // different children never collide on the same tmp path.
229        let tmp_path = dir.join(format!("{STATE_FILE_NAME}.{}.tmp", std::process::id()));
230        if tokio::fs::write(&tmp_path, &bytes).await.is_err() {
231            return;
232        }
233        let _ = tokio::fs::rename(&tmp_path, &path).await;
234    }
235
236    /// Step 2 of the activation logic (issue #444): a usable persisted id
237    /// requires a non-empty `messages` shipment (the reactivation
238    /// discriminant) AND a state file whose recorded `workspace` matches this
239    /// executor's current one — a mismatch (different project, or a
240    /// different machine entirely, since Claude Code transcripts are
241    /// machine-local) makes the id unusable and falls through to step 3.
242    async fn resolve_resume_id(&self) -> Option<String> {
243        let state = self.read_state().await?;
244        if state.workspace != self.workspace {
245            tracing::warn!(
246                recorded = ?state.workspace,
247                current = ?self.workspace,
248                "claude code: state file workspace mismatch; falling back to history rehydration"
249            );
250            return None;
251        }
252        Some(state.session_id)
253    }
254
255    /// `resume_id`: when `Some`, append `--resume <id>` (step 2 of the
256    /// activation logic — reattach to a persisted Claude Code session
257    /// instead of spawning fresh).
258    fn build_command(&self, resume_id: Option<&str>) -> Command {
259        let mut cmd = Command::new(&self.binary);
260        cmd.arg("--output-format")
261            .arg("stream-json")
262            .arg("--input-format")
263            .arg("stream-json")
264            .arg("--permission-prompt-tool")
265            .arg("stdio")
266            .arg("--replay-user-messages")
267            .arg("--verbose");
268        // Issue #443 CRITICAL: the CLI's headless stream-json default
269        // permission mode is `auto` (self-approve every tool, never asks) --
270        // NOT `default`. Passing no `--permission-mode` flag therefore means
271        // every actor silently self-approves. Always pass an EXPLICIT mode:
272        // the configured value when set, else `default` -- which actually
273        // engages the local-decide policy in `decide_and_respond` below
274        // ("no host bridge -> deny unless bypassPermissions") instead of
275        // that policy being unreachable dead code.
276        cmd.arg("--permission-mode")
277            .arg(self.permission_mode.as_deref().unwrap_or("default"));
278        if let Some(model) = &self.model {
279            cmd.arg("--model").arg(model);
280        }
281        if let Some(id) = resume_id {
282            cmd.arg("--resume").arg(id);
283        }
284        // Issue #443: isolate from the invoking user's ~/.claude setup by
285        // default -- an e2e run showed 6 MCP servers (incl. desktop
286        // control), every skill, and ~8k cache-creation tokens leaking in
287        // from global config for a single `touch`. `inherit_user_config:
288        // true` opts back into the CLI's normal (inherit-everything)
289        // behavior.
290        if !self.inherit_user_config {
291            cmd.arg("--strict-mcp-config");
292            cmd.arg("--setting-sources").arg("project");
293        }
294        // Issue #443: env allowlist. `env_clear()` plus an explicit forward
295        // list supersedes the old single `env_remove("CLAUDECODE")`
296        // hardening -- a cleared env can no longer carry a leaked CLAUDECODE
297        // (or any other ambient secret, e.g. `*_API_KEY`) from the parent at
298        // all. The `env_remove` call below is kept anyway as executable
299        // documentation of the specific nested-session hazard
300        // (docs/claude-code-executor.md, spawn flags section) and as
301        // defense-in-depth if the allowlist is ever loosened.
302        cmd.env_clear();
303        for (key, value) in std::env::vars() {
304            if ENV_ALLOWLIST.contains(&key.as_str()) || key.starts_with("LC_") {
305                cmd.env(key, value);
306            }
307        }
308        for name in &self.forward_env {
309            if let Ok(value) = std::env::var(name) {
310                cmd.env(name, value);
311            }
312        }
313        // Nested-session detection: Claude Code misbehaves if it inherits its
314        // own env var from an outer session.
315        cmd.env_remove("CLAUDECODE");
316        if let Some(ws) = &self.workspace {
317            cmd.current_dir(ws);
318        }
319        cmd.stdin(Stdio::piped());
320        cmd.stdout(Stdio::piped());
321        cmd.stderr(Stdio::piped());
322        // Safety net: if this future is ever dropped without running our own
323        // shutdown sequence (panic, abort), don't leak the child.
324        cmd.kill_on_drop(true);
325        #[cfg(unix)]
326        {
327            // Own process group so shutdown can SIGTERM/SIGKILL the whole tree
328            // (claude → any MCP servers it spawns), not just the leader.
329            cmd.process_group(0);
330        }
331        cmd
332    }
333
334    /// Dispatch one parsed stdout frame. Returns `Some(outcome)` when the
335    /// frame is turn-terminal (a non-compaction `result`); `None` otherwise —
336    /// including for `control_request`/`control_cancel_request`, which are
337    /// handled here but never end the run themselves.
338    async fn handle_frame(
339        &self,
340        value: Value,
341        events: &EventSink,
342        write_tx: &mpsc::UnboundedSender<Value>,
343        pending: &mut HashMap<String, JoinHandle<()>>,
344        last_text: &mut String,
345    ) -> Option<ChildOutcome> {
346        let frame_type = value.get("type").and_then(Value::as_str).unwrap_or("");
347        match frame_type {
348            "system" => {
349                let session_id = value
350                    .get("session_id")
351                    .and_then(|v| v.as_str())
352                    .unwrap_or("");
353                let model = value.get("model").and_then(|v| v.as_str()).unwrap_or("");
354                tracing::debug!(session_id, model, "claude code: session bootstrap");
355                if !session_id.is_empty() {
356                    self.write_state(session_id).await;
357                }
358                None
359            }
360            "assistant" => {
361                if let Some(blocks) = value.pointer("/message/content").and_then(Value::as_array) {
362                    for block in blocks {
363                        emit_assistant_block(block, events, last_text);
364                    }
365                }
366                None
367            }
368            "user" => {
369                if let Some(blocks) = value.pointer("/message/content").and_then(Value::as_array) {
370                    for block in blocks {
371                        emit_tool_result_block(block, events);
372                    }
373                }
374                None
375            }
376            "result" => {
377                let subtype = value.get("subtype").and_then(Value::as_str).unwrap_or("");
378                if matches!(subtype, "compact" | "compaction") {
379                    // Mid-turn compaction, NOT completion (cc-connect issue #481
380                    // — see docs/claude-code-executor.md §2's `result` row).
381                    tracing::debug!("claude code: mid-turn compaction result, continuing");
382                    return None;
383                }
384                // A resumed session may be assigned a brand-new id — always
385                // re-capture from `result` too, not just `system` (issue #444).
386                if let Some(session_id) = value.get("session_id").and_then(Value::as_str) {
387                    if !session_id.is_empty() {
388                        self.write_state(session_id).await;
389                    }
390                }
391                let final_text = value
392                    .get("result")
393                    .and_then(Value::as_str)
394                    .filter(|s| !s.is_empty())
395                    .map(str::to_string)
396                    .unwrap_or_else(|| last_text.clone());
397                let usage = value
398                    .get("usage")
399                    .map(|u| {
400                        let prompt = u.get("input_tokens").and_then(Value::as_u64).unwrap_or(0);
401                        let completion =
402                            u.get("output_tokens").and_then(Value::as_u64).unwrap_or(0);
403                        TokenUsage {
404                            prompt_tokens: prompt,
405                            completion_tokens: completion,
406                            total_tokens: prompt.saturating_add(completion),
407                        }
408                    })
409                    .unwrap_or_default();
410                events.emit(event_json(AgentEvent::Complete { usage }));
411                Some(ChildOutcome::completed(final_text))
412            }
413            "control_request" => {
414                self.handle_control_request(value, events, write_tx, pending);
415                None
416            }
417            "control_cancel_request" => {
418                let request_id = value
419                    .get("request_id")
420                    .and_then(Value::as_str)
421                    .unwrap_or("");
422                if let Some(handle) = pending.remove(request_id) {
423                    handle.abort();
424                }
425                None
426            }
427            other => {
428                tracing::debug!(frame_type = other, "claude code: unrecognized stdout frame");
429                None
430            }
431        }
432    }
433
434    /// Handle one `control_request` (permission relay §3 of the protocol doc):
435    /// spawns a background task so the read loop keeps consuming stdout while
436    /// a (possibly slow, human-in-the-loop) approval decision is pending. The
437    /// task is tracked in `pending` so a later `control_cancel_request` can
438    /// abort it.
439    fn handle_control_request(
440        &self,
441        value: Value,
442        events: &EventSink,
443        write_tx: &mpsc::UnboundedSender<Value>,
444        pending: &mut HashMap<String, JoinHandle<()>>,
445    ) {
446        let request_id = value
447            .get("request_id")
448            .and_then(Value::as_str)
449            .unwrap_or("")
450            .to_string();
451        let request = value.get("request").cloned().unwrap_or_else(|| json!({}));
452        let subtype = request.get("subtype").and_then(Value::as_str).unwrap_or("");
453        if subtype != "can_use_tool" {
454            // Only the tool-permission ask is understood in the MVP. Deny
455            // rather than ignore — an un-answered control_request otherwise
456            // hangs the CLI turn waiting for a response that never comes.
457            send_control_response(
458                write_tx,
459                &request_id,
460                false,
461                None,
462                Some(format!("unsupported control_request subtype '{subtype}'")),
463            );
464            return;
465        }
466        let tool_name = request
467            .get("tool_name")
468            .and_then(Value::as_str)
469            .unwrap_or("")
470            .to_string();
471        let input = request.get("input").cloned().unwrap_or_else(|| json!({}));
472        let host = events.host().cloned();
473        let permission_mode = self.permission_mode.clone();
474        let relay_timeout = self.relay_timeout;
475        let write_tx = write_tx.clone();
476        let task_request_id = request_id.clone();
477        let handle = tokio::spawn(async move {
478            decide_and_respond(
479                host,
480                permission_mode,
481                relay_timeout,
482                &task_request_id,
483                &tool_name,
484                input,
485                &write_tx,
486            )
487            .await;
488        });
489        pending.insert(request_id, handle);
490    }
491
492    /// Graceful 3-phase close (§5 of the protocol doc): the caller has already
493    /// closed stdin (dropped every writer sender) before calling this — that
494    /// is what lets the CLI's Stop hooks observe EOF and run. From here:
495    /// bounded wait for a natural exit, then SIGTERM the process group, a
496    /// shorter wait, then SIGKILL the process group.
497    async fn shutdown_child(child: &mut Child) {
498        if tokio::time::timeout(GRACEFUL_EXIT_WAIT, child.wait())
499            .await
500            .is_ok()
501        {
502            return;
503        }
504        signal_process_group(child, ProcessSignal::Term);
505        if tokio::time::timeout(SIGTERM_WAIT, child.wait())
506            .await
507            .is_ok()
508        {
509            return;
510        }
511        signal_process_group(child, ProcessSignal::Kill);
512        let _ = child.wait().await;
513    }
514}
515
516impl ClaudeCodeExecutor {
517    /// One `claude` child process activation: spawn (fresh or `--resume
518    /// resume_id`), write `body` as the stdin user turn, read frames until a
519    /// terminal `result` (or EOF/cancel), then run the graceful shutdown.
520    /// Extracted out of [`ChildExecutor::run`] so the resume-failure retry
521    /// (step 4 of the activation logic) doesn't duplicate the whole read
522    /// loop — `run` calls this up to twice for a single activation, sharing
523    /// one `events` sink and `cancel` token across both attempts.
524    ///
525    /// Returns `(outcome, exited_without_result)` — the second element is
526    /// `true` only for the specific "process exited before a terminal
527    /// `result` frame arrived" error path, which is the ONLY case `run`
528    /// treats as retry-eligible.
529    async fn run_once(
530        &self,
531        body: &str,
532        resume_id: Option<&str>,
533        events: &EventSink,
534        cancel: &CancellationToken,
535    ) -> (ChildOutcome, bool) {
536        let mut child = match spawn_with_etxtbsy_retry(|| self.build_command(resume_id)).await {
537            Ok(c) => c,
538            Err(e) => {
539                return (
540                    ChildOutcome::error(format!("spawn '{}': {e}", self.binary)),
541                    false,
542                )
543            }
544        };
545        let Some(stdin) = child.stdin.take() else {
546            return (
547                ChildOutcome::error("claude child has no stdin pipe".to_string()),
548                false,
549            );
550        };
551        let Some(stdout) = child.stdout.take() else {
552            return (
553                ChildOutcome::error("claude child has no stdout pipe".to_string()),
554                false,
555            );
556        };
557        let stderr = child.stderr.take();
558
559        let stderr_tail = Arc::new(Mutex::new(String::new()));
560        let stderr_task = stderr.map(|stderr| {
561            let tail = stderr_tail.clone();
562            tokio::spawn(async move { drain_stderr_tail(stderr, tail).await })
563        });
564
565        let (write_tx, writer_handle) = spawn_stdin_writer(stdin);
566        let assignment_frame = json!({
567            "type": "user",
568            "message": { "role": "user", "content": body },
569        });
570        if write_tx.send(assignment_frame).is_err() {
571            let _ = child.start_kill();
572            return (
573                ChildOutcome::error(
574                    "claude code executor: failed to queue the assignment on stdin".to_string(),
575                ),
576                false,
577            );
578        }
579
580        let mut reader = tokio::io::BufReader::with_capacity(64 * 1024, stdout);
581        let mut pending: HashMap<String, JoinHandle<()>> = HashMap::new();
582        let mut last_text = String::new();
583
584        let (outcome, exited_without_result) = loop {
585            tokio::select! {
586                _ = cancel.cancelled() => {
587                    break (ChildOutcome::cancelled(), false);
588                }
589                line = read_bounded_line(&mut reader, MAX_STDOUT_LINE_BYTES) => {
590                    match line {
591                        Ok(Some(bytes)) => {
592                            if bytes.iter().all(u8::is_ascii_whitespace) {
593                                continue;
594                            }
595                            let value: Value = match serde_json::from_slice(&bytes) {
596                                Ok(v) => v,
597                                Err(e) => {
598                                    tracing::debug!("claude code: unparsable stdout line ({e}); skipping");
599                                    continue;
600                                }
601                            };
602                            if let Some(outcome) = self
603                                .handle_frame(value, events, &write_tx, &mut pending, &mut last_text)
604                                .await
605                            {
606                                break (outcome, false);
607                            }
608                        }
609                        Ok(None) => {
610                            // EOF with no terminal `result` frame — the process
611                            // exited (or closed stdout) unexpectedly.
612                            let code = child.wait().await.ok().and_then(|s| s.code());
613                            let tail = stderr_tail.lock().await.clone();
614                            break (ChildOutcome::error(format!(
615                                "claude exited (code {code:?}) without a result frame; stderr tail: {}",
616                                if tail.is_empty() { "<empty>" } else { tail.trim() }
617                            )), true);
618                        }
619                        Err(e) => {
620                            break (ChildOutcome::error(format!("claude stdout read error: {e}")), false);
621                        }
622                    }
623                }
624            }
625        };
626
627        for (_, handle) in pending.drain() {
628            handle.abort();
629        }
630        // Close stdin (phase 1 of shutdown): drop every sender clone so the
631        // writer task's channel drains and its `ChildStdin` is dropped, then
632        // give it a brief bounded moment to actually finish — an aborted
633        // control-request task's clone is dropped asynchronously, so this is
634        // best-effort, not a hard requirement (the graceful-exit wait below
635        // covers the remaining slack).
636        drop(write_tx);
637        let _ = tokio::time::timeout(Duration::from_millis(500), writer_handle).await;
638        if let Some(stderr_task) = stderr_task {
639            stderr_task.abort();
640        }
641
642        Self::shutdown_child(&mut child).await;
643        (outcome, exited_without_result)
644    }
645}
646
647#[async_trait]
648impl ChildExecutor for ClaudeCodeExecutor {
649    /// Activation logic (issue #444), driven by `spec.messages` — empty means
650    /// first activation, non-empty means a reactivation carrying prior
651    /// context (`RunSpec.messages` doc, `proto.rs:28`):
652    ///
653    /// 1. `messages` empty → fresh session; delete any stale state file (a
654    ///    `rerun` must never accidentally resume).
655    /// 2. `messages` non-empty AND the state file has an id recorded against
656    ///    the SAME `workspace` → spawn with `--resume <id>`, sending just the
657    ///    live assignment (the CLI already has the transcript).
658    /// 3. `messages` non-empty but no usable id (first run on this machine,
659    ///    storage GC'd, workspace changed) → fallback: render the shipped
660    ///    history into a bounded preamble prepended to the assignment.
661    /// 4. If a `--resume` spawn exits without ever producing a `result`
662    ///    frame (bad/GC'd session id — the CLI errors out fast), retry
663    ///    ONCE without `--resume`, using the same fallback rehydration as
664    ///    step 3. No retry loop beyond this single attempt.
665    async fn run(
666        &self,
667        spec: RunSpec,
668        events: EventSink,
669        // Claude Code's stream-json protocol has no mid-turn user-message
670        // injection: a turn is one stdin write followed by a read to `result`
671        // (docs/claude-code-executor.md §5 — "no reliable mid-turn interrupt
672        // over this protocol"). Steering is drained (so an unbounded backlog
673        // can't build up on the sender side) but never acted on — turning a
674        // steer message into a genuinely new turn on the SAME (possibly
675        // resumed) session is left to a future revision.
676        mut steer: SteerInbox,
677        cancel: CancellationToken,
678    ) -> ChildOutcome {
679        // Ignore steer messages (see doc comment on `steer` above) but keep
680        // draining so the sender never sees an unbounded backlog. Spans BOTH
681        // possible spawn attempts below — the inbox belongs to the whole
682        // activation, not to one child process.
683        let steer_drain = tokio::spawn(async move { while steer.recv().await.is_some() {} });
684
685        // Step 1: a fresh activation must never resume stale context.
686        if spec.messages.is_empty() {
687            self.delete_state_file().await;
688        }
689
690        // Step 2: a usable persisted id under the SAME workspace.
691        let resume_id = if spec.messages.is_empty() {
692            None
693        } else {
694            self.resolve_resume_id().await
695        };
696
697        // Step 3: fallback body when there's history but no usable id.
698        let body = build_turn_body(&spec, resume_id.as_deref());
699
700        let used_resume = resume_id.is_some();
701        let (outcome, exited_without_result) = self
702            .run_once(&body, resume_id.as_deref(), &events, &cancel)
703            .await;
704
705        // Step 4: retry-once, ONLY when the failed attempt itself used
706        // `--resume` and died before a `result` frame ever arrived.
707        let outcome = if used_resume && exited_without_result {
708            tracing::warn!(
709                "claude code: --resume spawn exited without a result frame; \
710                 retrying once without --resume"
711            );
712            self.delete_state_file().await;
713            let fallback_body = build_turn_body(&spec, None);
714            self.run_once(&fallback_body, None, &events, &cancel)
715                .await
716                .0
717        } else {
718            outcome
719        };
720
721        steer_drain.abort();
722        outcome
723    }
724}
725
726/// Spawn with a short retry on `ETXTBSY` ("text file busy", raw os error 26
727/// on Linux). On Linux, exec-ing an executable that was written moments ago
728/// can transiently fail when ANOTHER thread in this process still holds a
729/// write fd to it at fork time (the fd is inherited across fork until the
730/// exec). In production the `claude` binary is never freshly written, so
731/// this never fires; in the stub-binary test suite, parallel test threads
732/// each writing their own stub make it a real (observed-on-CI) flake. A few
733/// 10ms retries are a complete cure and harmless otherwise.
734async fn spawn_with_etxtbsy_retry(mut build: impl FnMut() -> Command) -> std::io::Result<Child> {
735    let mut last_err = None;
736    for _ in 0..5 {
737        match build().spawn() {
738            Ok(child) => return Ok(child),
739            Err(e) if e.raw_os_error() == Some(26) => {
740                last_err = Some(e);
741                tokio::time::sleep(Duration::from_millis(10)).await;
742            }
743            Err(e) => return Err(e),
744        }
745    }
746    Err(last_err.expect("retry loop always records an error before exhausting"))
747}
748
749/// Which signal [`signal_process_group`] sends.
750enum ProcessSignal {
751    Term,
752    Kill,
753}
754
755/// Best-effort signal to the whole process group the child leads (its pgid
756/// equals its pid — `build_command` set `process_group(0)` at spawn on unix).
757/// No-op on non-unix targets in this MVP; the final phase there falls back to
758/// killing just the direct child via `Child::start_kill` in `shutdown_child`'s
759/// caller-visible behavior (still bounded — [`Child::wait`] then reaps it).
760#[cfg(unix)]
761fn signal_process_group(child: &Child, signal: ProcessSignal) {
762    if let Some(pid) = child.id() {
763        let signo = match signal {
764            ProcessSignal::Term => libc::SIGTERM,
765            ProcessSignal::Kill => libc::SIGKILL,
766        };
767        // SAFETY: `kill(2)` with a pid_t derived from our own child's pid and
768        // a fixed signal constant; a negative pid targets the whole process
769        // group. Failure (e.g. ESRCH — already exited) is fine to ignore,
770        // this call is best-effort cleanup.
771        unsafe {
772            libc::kill(-(pid as libc::pid_t), signo);
773        }
774    }
775}
776
777#[cfg(not(unix))]
778fn signal_process_group(_child: &Child, _signal: ProcessSignal) {}
779
780/// Serialize `value` to one NDJSON line and write it on the writer task owning
781/// stdin; drops silently if the writer is gone (matches [`EventSink::emit`]'s
782/// "dropped silently if the peer is gone" convention).
783fn spawn_stdin_writer(
784    mut stdin: tokio::process::ChildStdin,
785) -> (mpsc::UnboundedSender<Value>, JoinHandle<()>) {
786    let (tx, mut rx) = mpsc::unbounded_channel::<Value>();
787    let handle = tokio::spawn(async move {
788        while let Some(value) = rx.recv().await {
789            let Ok(mut line) = serde_json::to_vec(&value) else {
790                continue;
791            };
792            line.push(b'\n');
793            if stdin.write_all(&line).await.is_err() {
794                break;
795            }
796            if stdin.flush().await.is_err() {
797                break;
798            }
799        }
800        // `stdin` drops here (once every sender clone is gone and the channel
801        // drains), closing the write half — the EOF the CLI's Stop hooks see.
802    });
803    (tx, handle)
804}
805
806/// Read one NDJSON line, bounded to `max_bytes` (enforced incrementally via
807/// `fill_buf`/`consume`, not after buffering an unbounded amount). Returns
808/// `Ok(None)` on a clean EOF with no trailing partial line.
809async fn read_bounded_line<R>(reader: &mut R, max_bytes: usize) -> std::io::Result<Option<Vec<u8>>>
810where
811    R: tokio::io::AsyncBufRead + Unpin,
812{
813    let mut out = Vec::new();
814    loop {
815        let (found, consumed) = {
816            let available = reader.fill_buf().await?;
817            if available.is_empty() {
818                return Ok(if out.is_empty() { None } else { Some(out) });
819            }
820            match available.iter().position(|&b| b == b'\n') {
821                Some(pos) => {
822                    out.extend_from_slice(&available[..pos]);
823                    (true, pos + 1)
824                }
825                None => {
826                    out.extend_from_slice(available);
827                    (false, available.len())
828                }
829            }
830        };
831        reader.consume(consumed);
832        if found {
833            return Ok(Some(out));
834        }
835        if out.len() > max_bytes {
836            return Err(std::io::Error::new(
837                std::io::ErrorKind::InvalidData,
838                format!("stdout line exceeded {max_bytes} bytes"),
839            ));
840        }
841    }
842}
843
844/// Drain stderr into a bounded tail buffer (oldest bytes dropped once the cap
845/// is exceeded) for the "exited without a result frame" error message.
846async fn drain_stderr_tail(stderr: tokio::process::ChildStderr, tail: Arc<Mutex<String>>) {
847    let mut reader = BufReader::new(stderr);
848    let mut buf = Vec::new();
849    loop {
850        buf.clear();
851        match reader.read_until(b'\n', &mut buf).await {
852            Ok(0) | Err(_) => return,
853            Ok(_) => {
854                let mut t = tail.lock().await;
855                t.push_str(&String::from_utf8_lossy(&buf));
856                if t.len() > STDERR_TAIL_BYTES {
857                    let excess = t.len() - STDERR_TAIL_BYTES;
858                    let cut = t
859                        .char_indices()
860                        .map(|(i, _)| i)
861                        .find(|&i| i >= excess)
862                        .unwrap_or(t.len());
863                    t.drain(..cut);
864                }
865            }
866        }
867    }
868}
869
870/// Emit the `AgentEvent` for one `assistant` message content block (`text` /
871/// `thinking` / `tool_use`); unrecognized block types are ignored.
872fn emit_assistant_block(block: &Value, events: &EventSink, last_text: &mut String) {
873    match block.get("type").and_then(Value::as_str) {
874        Some("text") => {
875            let text = block.get("text").and_then(Value::as_str).unwrap_or("");
876            if !text.is_empty() {
877                last_text.push_str(text);
878                events.emit(event_json(AgentEvent::Token {
879                    content: text.to_string(),
880                }));
881            }
882        }
883        Some("thinking") => {
884            let text = block.get("thinking").and_then(Value::as_str).unwrap_or("");
885            if !text.is_empty() {
886                events.emit(event_json(AgentEvent::ReasoningToken {
887                    content: text.to_string(),
888                }));
889            }
890        }
891        Some("tool_use") => {
892            let tool_call_id = block
893                .get("id")
894                .and_then(Value::as_str)
895                .unwrap_or("")
896                .to_string();
897            let tool_name = block
898                .get("name")
899                .and_then(Value::as_str)
900                .unwrap_or("")
901                .to_string();
902            let arguments = block.get("input").cloned().unwrap_or_else(|| json!({}));
903            events.emit(event_json(AgentEvent::ToolStart {
904                tool_call_id,
905                tool_name,
906                arguments,
907            }));
908        }
909        _ => {}
910    }
911}
912
913/// Emit the `AgentEvent` for one `user` message content block, when it is a
914/// `tool_result` (other block types in an echoed user message are ignored).
915fn emit_tool_result_block(block: &Value, events: &EventSink) {
916    if block.get("type").and_then(Value::as_str) != Some("tool_result") {
917        return;
918    }
919    let tool_call_id = block
920        .get("tool_use_id")
921        .and_then(Value::as_str)
922        .unwrap_or("")
923        .to_string();
924    let is_error = block
925        .get("is_error")
926        .and_then(Value::as_bool)
927        .unwrap_or(false);
928    let text = truncate_chars(
929        &tool_result_text(block.get("content")),
930        TOOL_RESULT_TRUNCATE_CHARS,
931    );
932    let event = if is_error {
933        AgentEvent::ToolError {
934            tool_call_id,
935            error: text,
936        }
937    } else {
938        AgentEvent::ToolComplete {
939            tool_call_id,
940            result: ToolResult::text(true, text),
941        }
942    };
943    events.emit(event_json(event));
944}
945
946/// A `tool_result` block's `content` is either a plain string or an array of
947/// content blocks (Anthropic message shape); flatten either into plain text.
948fn tool_result_text(content: Option<&Value>) -> String {
949    match content {
950        Some(Value::String(s)) => s.clone(),
951        Some(Value::Array(items)) => items
952            .iter()
953            .filter_map(|b| b.get("text").and_then(Value::as_str))
954            .collect::<Vec<_>>()
955            .join("\n"),
956        Some(other) => other.to_string(),
957        None => String::new(),
958    }
959}
960
961/// Step 2/3 of the activation logic: the actual stdin body for one turn.
962/// `resume_id: Some` (or an empty `spec.messages`) means the CLI already has
963/// (or needs no) context, so the plain assignment is sent; otherwise the
964/// fallback history preamble is prepended, clearly delimited from the live
965/// task so the model doesn't confuse rehydrated context with the current ask.
966fn build_turn_body(spec: &RunSpec, resume_id: Option<&str>) -> String {
967    if resume_id.is_some() || spec.messages.is_empty() {
968        return spec.assignment.clone();
969    }
970    match render_history_preamble(&spec.messages, &spec.assignment) {
971        Some(preamble) => format!("{preamble}\n\n## Current task\n\n{}", spec.assignment),
972        None => spec.assignment.clone(),
973    }
974}
975
976/// Render `RunSpec.messages` (serialized domain `Message`s, oldest first,
977/// INCLUDING the assignment's own trailing user message per the wire
978/// contract — `proto.rs:28`) into a bounded fallback preamble. Unknown/
979/// malformed entries (missing `role`/`content`, non-string `content`) are
980/// skipped defensively rather than failing the run. Returns `None` when
981/// there is nothing left to render (e.g. the only shipped message IS the
982/// current assignment, already excluded below to avoid duplicating it).
983fn render_history_preamble(messages: &[Value], assignment: &str) -> Option<String> {
984    let mut entries: Vec<(String, String)> = messages
985        .iter()
986        .filter_map(|m| {
987            let role = m.get("role").and_then(Value::as_str)?.to_string();
988            let content = m.get("content").and_then(Value::as_str)?;
989            if content.is_empty() {
990                return None;
991            }
992            Some((role, content.to_string()))
993        })
994        .collect();
995
996    // The assignment's own user message rides in `messages` too (contract) —
997    // drop it here so the preamble doesn't duplicate the live task below it.
998    if let Some((role, content)) = entries.last() {
999        if role == "user" && content == assignment {
1000            entries.pop();
1001        }
1002    }
1003    if entries.is_empty() {
1004        return None;
1005    }
1006
1007    // Cap by message count, oldest dropped first.
1008    let dropped_by_count = entries.len().saturating_sub(HISTORY_PREAMBLE_MAX_MESSAGES);
1009    if dropped_by_count > 0 {
1010        entries.drain(0..dropped_by_count);
1011    }
1012
1013    let mut rendered: Vec<String> = entries
1014        .iter()
1015        .map(|(role, content)| format!("**{role}**: {content}"))
1016        .collect();
1017
1018    // Cap by char budget, oldest rendered entry dropped first; if even the
1019    // single most-recent entry alone exceeds the budget, truncate it in place
1020    // (never silently drop the entire preamble).
1021    let mut dropped_by_chars = 0usize;
1022    while rendered.len() > 1
1023        && rendered
1024            .iter()
1025            .map(|s| s.chars().count() + 2)
1026            .sum::<usize>()
1027            > HISTORY_PREAMBLE_MAX_CHARS
1028    {
1029        rendered.remove(0);
1030        dropped_by_chars += 1;
1031    }
1032    if let [only] = rendered.as_mut_slice() {
1033        if only.chars().count() > HISTORY_PREAMBLE_MAX_CHARS {
1034            *only = truncate_chars(only, HISTORY_PREAMBLE_MAX_CHARS);
1035        }
1036    }
1037
1038    let mut out = String::from("## Prior conversation (rehydrated)\n\n");
1039    if dropped_by_count > 0 || dropped_by_chars > 0 {
1040        out.push_str(&format!(
1041            "_[truncated: {} earlier message(s) omitted]_\n\n",
1042            dropped_by_count + dropped_by_chars
1043        ));
1044    }
1045    out.push_str(&rendered.join("\n\n"));
1046    Some(out)
1047}
1048
1049fn truncate_chars(s: &str, max_chars: usize) -> String {
1050    if s.chars().count() <= max_chars {
1051        return s.to_string();
1052    }
1053    let head: String = s.chars().take(max_chars).collect();
1054    let dropped = s.chars().count() - max_chars;
1055    format!("{head}\n… [truncated, {dropped} more chars]")
1056}
1057
1058/// Decide a `can_use_tool` permission ask and write the `control_response`
1059/// (§3 of the protocol doc). Runs off the read loop (spawned by the caller)
1060/// so a slow human-in-the-loop decision doesn't block consuming other frames.
1061async fn decide_and_respond(
1062    host: Option<HostBridge>,
1063    permission_mode: Option<String>,
1064    relay_timeout: Duration,
1065    request_id: &str,
1066    tool_name: &str,
1067    input: Value,
1068    write_tx: &mpsc::UnboundedSender<Value>,
1069) {
1070    if tool_name == "AskUserQuestion" {
1071        // Structured interactive questions need bamboo's QuestionDialog path,
1072        // not the permission path (docs/claude-code-executor.md §3) — not
1073        // wired yet. Deny promptly rather than hang the CLI turn.
1074        send_control_response(
1075            write_tx,
1076            request_id,
1077            false,
1078            None,
1079            Some(
1080                "interactive questions are not supported by the Claude Code executor yet"
1081                    .to_string(),
1082            ),
1083        );
1084        return;
1085    }
1086
1087    let (allow, deny_message) = if let Some(host) = host {
1088        let body = json!({ "tool_name": tool_name, "input": input });
1089        // Issue #443: bound the relay so a host-in-the-loop approver that
1090        // never replies (crashed UI, orphaned session) can't hang this turn
1091        // forever — `approval_call`'s own error path (the reply oneshot
1092        // DROPPED) is already handled by the `Err(e)` arm below; this timeout
1093        // covers the complementary case where the sender is held open but
1094        // never sent.
1095        match tokio::time::timeout(relay_timeout, host.approval_call(body)).await {
1096            Ok(Ok(reply)) => {
1097                let approved = reply
1098                    .get("approved")
1099                    .and_then(Value::as_bool)
1100                    .unwrap_or(false);
1101                let msg = (!approved).then(|| "denied by host approver".to_string());
1102                (approved, msg)
1103            }
1104            Ok(Err(e)) => (false, Some(format!("approval relay failed: {e}"))),
1105            Err(_) => (
1106                false,
1107                Some(format!(
1108                    "approval relay timed out after {}s; denying",
1109                    relay_timeout.as_secs()
1110                )),
1111            ),
1112        }
1113    } else if permission_mode.as_deref() == Some("bypassPermissions") {
1114        (true, None)
1115    } else {
1116        (
1117            false,
1118            Some(
1119                "permission relay unavailable; run with bypassPermissions or attach a host bridge"
1120                    .to_string(),
1121            ),
1122        )
1123    };
1124    let updated_input = allow.then_some(input);
1125    send_control_response(write_tx, request_id, allow, updated_input, deny_message);
1126}
1127
1128/// Write one `control_response` frame (§3 of the protocol doc).
1129fn send_control_response(
1130    write_tx: &mpsc::UnboundedSender<Value>,
1131    request_id: &str,
1132    allow: bool,
1133    updated_input: Option<Value>,
1134    deny_message: Option<String>,
1135) {
1136    let response = if allow {
1137        json!({
1138            "behavior": "allow",
1139            "updatedInput": updated_input.unwrap_or_else(|| json!({})),
1140        })
1141    } else {
1142        json!({
1143            "behavior": "deny",
1144            "message": deny_message.unwrap_or_default(),
1145        })
1146    };
1147    let frame = json!({
1148        "type": "control_response",
1149        "response": {
1150            "subtype": "success",
1151            "request_id": request_id,
1152            "response": response,
1153        },
1154    });
1155    let _ = write_tx.send(frame);
1156}
1157
1158/// Serialize an `AgentEvent` for [`EventSink::emit`]. Mirrors how
1159/// [`BambooRuntimeExecutor`](crate::subagent_worker::BambooRuntimeExecutor)
1160/// forwards real engine events verbatim — this executor maps the Claude Code
1161/// wire protocol onto the SAME event enum rather than hand-rolled JSON, so the
1162/// parent's child preview renders identically regardless of which engine ran.
1163fn event_json(event: AgentEvent) -> Value {
1164    serde_json::to_value(event).unwrap_or_else(|_| json!({}))
1165}
1166
1167#[cfg(test)]
1168mod tests {
1169    use super::*;
1170    use std::io::Write as _;
1171    use std::os::unix::fs::PermissionsExt;
1172    use std::path::PathBuf;
1173
1174    use bamboo_subagent::executor::EventSink;
1175    use bamboo_subagent::proto::TerminalStatus;
1176
1177    /// Write an executable `sh` stub at `dir/claude` with `body` as its
1178    /// script content, and return the path. Tests point `ClaudeCodeExecutor`'s
1179    /// `binary` override at this instead of a real `claude` install.
1180    fn write_stub(dir: &std::path::Path, body: &str) -> PathBuf {
1181        let path = dir.join("claude");
1182        let mut f = std::fs::File::create(&path).unwrap();
1183        writeln!(f, "#!/bin/sh").unwrap();
1184        f.write_all(body.as_bytes()).unwrap();
1185        let mut perms = std::fs::metadata(&path).unwrap().permissions();
1186        perms.set_mode(0o755);
1187        std::fs::set_permissions(&path, perms).unwrap();
1188        path
1189    }
1190
1191    /// No state dir — matches the MVP's fresh-session-every-time behavior
1192    /// (used by tests that don't exercise resume at all).
1193    fn executor(binary: PathBuf) -> ClaudeCodeExecutor {
1194        executor_with_state(binary, None, None)
1195    }
1196
1197    fn executor_with_state(
1198        binary: PathBuf,
1199        state_dir: Option<PathBuf>,
1200        workspace: Option<String>,
1201    ) -> ClaudeCodeExecutor {
1202        ClaudeCodeExecutor::new(
1203            Some(binary.to_string_lossy().into_owned()),
1204            None,
1205            None,
1206            workspace,
1207            state_dir,
1208            false,
1209            Vec::new(),
1210        )
1211    }
1212
1213    fn run_spec(assignment: &str) -> RunSpec {
1214        RunSpec {
1215            assignment: assignment.to_string(),
1216            reasoning_effort: None,
1217            messages: Vec::new(),
1218        }
1219    }
1220
1221    fn run_spec_with_messages(assignment: &str, messages: Vec<Value>) -> RunSpec {
1222        RunSpec {
1223            assignment: assignment.to_string(),
1224            reasoning_effort: None,
1225            messages,
1226        }
1227    }
1228
1229    fn msg(role: &str, content: &str) -> Value {
1230        json!({ "role": role, "content": content })
1231    }
1232
1233    fn read_argv(dir: &std::path::Path, name: &str) -> String {
1234        std::fs::read_to_string(dir.join(name)).unwrap_or_default()
1235    }
1236
1237    /// Extract the JSON `message.content` string a stub captured from its
1238    /// first stdin line (the `{"type":"user","message":{...}}` assignment
1239    /// frame `spawn_stdin_writer` writes).
1240    fn stdin_body(dir: &std::path::Path, name: &str) -> String {
1241        let raw = std::fs::read_to_string(dir.join(name)).unwrap();
1242        let value: Value = serde_json::from_str(raw.trim()).unwrap();
1243        value["message"]["content"].as_str().unwrap().to_string()
1244    }
1245
1246    #[tokio::test]
1247    async fn happy_path_streams_events_then_completes() {
1248        let dir = tempfile::tempdir().unwrap();
1249        let bin = write_stub(
1250            dir.path(),
1251            r#"
1252read -r _assignment
1253echo '{"type":"system","session_id":"s1","model":"stub-model"}'
1254echo '{"type":"assistant","message":{"content":[{"type":"text","text":"Working on it"},{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"echo hi"}}]}}'
1255echo '{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"hi","is_error":false}]}}'
1256echo '{"type":"result","subtype":"success","result":"done: hi","usage":{"input_tokens":10,"output_tokens":5}}'
1257"#,
1258        );
1259        let (sink, mut rx) = EventSink::channel();
1260        let outcome = executor(bin)
1261            .run(
1262                run_spec("say hi"),
1263                sink,
1264                SteerInbox::disconnected(),
1265                CancellationToken::new(),
1266            )
1267            .await;
1268
1269        assert_eq!(outcome.status, TerminalStatus::Completed);
1270        assert_eq!(outcome.result.as_deref(), Some("done: hi"));
1271
1272        let mut events = Vec::new();
1273        while let Ok(e) = rx.try_recv() {
1274            events.push(e);
1275        }
1276        let types: Vec<&str> = events
1277            .iter()
1278            .map(|e| e["type"].as_str().unwrap_or(""))
1279            .collect();
1280        assert_eq!(
1281            types,
1282            vec!["token", "tool_start", "tool_complete", "complete"]
1283        );
1284        assert_eq!(events[0]["content"], "Working on it");
1285        assert_eq!(events[1]["tool_name"], "Bash");
1286        assert_eq!(events[2]["result"]["result"], "hi");
1287        assert_eq!(events[3]["usage"]["prompt_tokens"], 10);
1288        assert_eq!(events[3]["usage"]["completion_tokens"], 5);
1289    }
1290
1291    #[tokio::test]
1292    async fn compaction_result_does_not_complete_the_run() {
1293        let dir = tempfile::tempdir().unwrap();
1294        let bin = write_stub(
1295            dir.path(),
1296            r#"
1297read -r _assignment
1298echo '{"type":"result","subtype":"compact","result":"mid-turn compaction, ignore"}'
1299echo '{"type":"assistant","message":{"content":[{"type":"text","text":"back after compaction"}]}}'
1300echo '{"type":"result","subtype":"success","result":"final answer"}'
1301"#,
1302        );
1303        let (sink, _rx) = EventSink::channel();
1304        let outcome = executor(bin)
1305            .run(
1306                run_spec("do the thing"),
1307                sink,
1308                SteerInbox::disconnected(),
1309                CancellationToken::new(),
1310            )
1311            .await;
1312        assert_eq!(outcome.status, TerminalStatus::Completed);
1313        assert_eq!(outcome.result.as_deref(), Some("final answer"));
1314    }
1315
1316    #[tokio::test]
1317    async fn control_request_with_no_host_denies_and_run_continues() {
1318        let dir = tempfile::tempdir().unwrap();
1319        let bin = write_stub(
1320            dir.path(),
1321            r#"
1322DIR="$(cd "$(dirname "$0")" && pwd)"
1323read -r _assignment
1324echo '{"type":"control_request","request_id":"r1","request":{"subtype":"can_use_tool","tool_name":"Bash","input":{"command":"rm -rf /"}}}'
1325read -r control_response_line
1326printf '%s\n' "$control_response_line" > "$DIR/control_response.json"
1327echo '{"type":"result","subtype":"success","result":"continued after deny"}'
1328"#,
1329        );
1330        let (sink, _rx) = EventSink::channel(); // no host bridge attached
1331        let outcome = executor(bin.clone())
1332            .run(
1333                run_spec("do something dangerous"),
1334                sink,
1335                SteerInbox::disconnected(),
1336                CancellationToken::new(),
1337            )
1338            .await;
1339        assert_eq!(outcome.status, TerminalStatus::Completed);
1340        assert_eq!(outcome.result.as_deref(), Some("continued after deny"));
1341
1342        let written = std::fs::read_to_string(dir.path().join("control_response.json")).unwrap();
1343        let value: Value = serde_json::from_str(written.trim()).unwrap();
1344        assert_eq!(value["response"]["request_id"], "r1");
1345        assert_eq!(value["response"]["response"]["behavior"], "deny");
1346        assert!(value["response"]["response"]["message"]
1347            .as_str()
1348            .unwrap()
1349            .contains("permission relay unavailable"));
1350    }
1351
1352    #[tokio::test]
1353    async fn control_request_with_host_bridge_relays_and_allows() {
1354        let dir = tempfile::tempdir().unwrap();
1355        let bin = write_stub(
1356            dir.path(),
1357            r#"
1358DIR="$(cd "$(dirname "$0")" && pwd)"
1359read -r _assignment
1360echo '{"type":"control_request","request_id":"r1","request":{"subtype":"can_use_tool","tool_name":"Write","input":{"file_path":"/tmp/x"}}}'
1361read -r control_response_line
1362printf '%s\n' "$control_response_line" > "$DIR/control_response.json"
1363echo '{"type":"result","subtype":"success","result":"wrote file"}'
1364"#,
1365        );
1366        let (bridge, mut req_rx) = HostBridge::channel();
1367        let approver = tokio::spawn(async move {
1368            let req = req_rx.recv().await.expect("a host approval request");
1369            assert_eq!(req.body["tool_name"], "Write");
1370            let _ = req.reply.send(json!({ "approved": true }));
1371        });
1372        let (sink, _rx) = EventSink::channel();
1373        let sink = sink.with_host_bridge(bridge);
1374        let outcome = executor(bin)
1375            .run(
1376                run_spec("write a file"),
1377                sink,
1378                SteerInbox::disconnected(),
1379                CancellationToken::new(),
1380            )
1381            .await;
1382        approver.await.unwrap();
1383        assert_eq!(outcome.status, TerminalStatus::Completed);
1384
1385        let written = std::fs::read_to_string(dir.path().join("control_response.json")).unwrap();
1386        let value: Value = serde_json::from_str(written.trim()).unwrap();
1387        assert_eq!(value["response"]["response"]["behavior"], "allow");
1388        assert_eq!(
1389            value["response"]["response"]["updatedInput"]["file_path"],
1390            "/tmp/x"
1391        );
1392    }
1393
1394    #[tokio::test]
1395    async fn control_request_approval_relay_times_out_and_denies() {
1396        let dir = tempfile::tempdir().unwrap();
1397        let bin = write_stub(
1398            dir.path(),
1399            r#"
1400DIR="$(cd "$(dirname "$0")" && pwd)"
1401read -r _assignment
1402echo '{"type":"control_request","request_id":"r1","request":{"subtype":"can_use_tool","tool_name":"Bash","input":{"command":"echo hi"}}}'
1403read -r control_response_line
1404printf '%s\n' "$control_response_line" > "$DIR/control_response.json"
1405echo '{"type":"result","subtype":"success","result":"continued after timeout"}'
1406"#,
1407        );
1408        let (bridge, mut req_rx) = HostBridge::channel();
1409        // Hold the request (and its reply oneshot::Sender) alive without ever
1410        // replying — exercises the "sender held open but never sent" path,
1411        // distinct from `approval_call`'s already-handled "reply dropped"
1412        // error (`control_request_with_no_host_denies_and_run_continues`
1413        // covers the no-bridge-at-all case; this is the bridge-present,
1414        // never-answers case).
1415        let held = tokio::spawn(async move { req_rx.recv().await });
1416        let (sink, _rx) = EventSink::channel();
1417        let sink = sink.with_host_bridge(bridge);
1418        let outcome = executor(bin)
1419            .with_relay_timeout_for_test(Duration::from_millis(50))
1420            .run(
1421                run_spec("do something"),
1422                sink,
1423                SteerInbox::disconnected(),
1424                CancellationToken::new(),
1425            )
1426            .await;
1427        assert_eq!(outcome.status, TerminalStatus::Completed);
1428        assert_eq!(outcome.result.as_deref(), Some("continued after timeout"));
1429
1430        let written = std::fs::read_to_string(dir.path().join("control_response.json")).unwrap();
1431        let value: Value = serde_json::from_str(written.trim()).unwrap();
1432        assert_eq!(value["response"]["response"]["behavior"], "deny");
1433        let msg = value["response"]["response"]["message"].as_str().unwrap();
1434        assert!(msg.contains("timed out"), "unexpected deny message: {msg}");
1435        assert!(msg.contains("denying"), "unexpected deny message: {msg}");
1436
1437        // Keep the reply sender alive until here (past the run's completion)
1438        // so the timeout path — not a dropped-sender race — is what fired.
1439        let _req = held.await.unwrap();
1440    }
1441
1442    #[tokio::test]
1443    async fn env_allowlist_blocks_canary_secret_but_forwards_listed_var() {
1444        let dir = tempfile::tempdir().unwrap();
1445        let bin = write_stub(
1446            dir.path(),
1447            r#"
1448DIR="$(cd "$(dirname "$0")" && pwd)"
1449env > "$DIR/env-dump.txt"
1450read -r _assignment
1451echo '{"type":"result","subtype":"success","result":"ok"}'
1452"#,
1453        );
1454        // A secret-shaped var that must NOT reach the child (not on the fixed
1455        // allowlist, not in `forward_env`), and a var that MUST reach it
1456        // (explicitly named in `forward_env` — the billing opt-in escape
1457        // hatch, e.g. for ANTHROPIC_API_KEY).
1458        std::env::set_var("FAKE_SECRET_API_KEY", "leaked-if-broken");
1459        std::env::set_var("BAMBOO_TEST_FORWARD_ME", "forwarded-value");
1460
1461        let exec = ClaudeCodeExecutor::new(
1462            Some(bin.to_string_lossy().into_owned()),
1463            None,
1464            None,
1465            None,
1466            None,
1467            false,
1468            vec!["BAMBOO_TEST_FORWARD_ME".to_string()],
1469        );
1470        let (sink, _rx) = EventSink::channel();
1471        let outcome = exec
1472            .run(
1473                run_spec("hi"),
1474                sink,
1475                SteerInbox::disconnected(),
1476                CancellationToken::new(),
1477            )
1478            .await;
1479
1480        std::env::remove_var("FAKE_SECRET_API_KEY");
1481        std::env::remove_var("BAMBOO_TEST_FORWARD_ME");
1482
1483        assert_eq!(outcome.status, TerminalStatus::Completed);
1484        let dump = std::fs::read_to_string(dir.path().join("env-dump.txt")).unwrap();
1485        assert!(
1486            !dump.contains("FAKE_SECRET_API_KEY"),
1487            "canary secret leaked into the child env:\n{dump}"
1488        );
1489        assert!(
1490            dump.contains("BAMBOO_TEST_FORWARD_ME=forwarded-value"),
1491            "forward_env-listed var missing from the child env:\n{dump}"
1492        );
1493        // Sanity: PATH (fixed allowlist) must still be present — otherwise
1494        // the stub couldn't have run `env`/`cat`-family commands at all, and
1495        // this test would be vacuously passing.
1496        assert!(dump.contains("PATH="), "PATH missing from the child env");
1497    }
1498
1499    #[tokio::test]
1500    async fn cancel_kills_the_child_process_group() {
1501        let dir = tempfile::tempdir().unwrap();
1502        let bin = write_stub(
1503            dir.path(),
1504            r#"
1505read -r _assignment
1506echo '{"type":"system","session_id":"s1"}'
1507sleep 30
1508echo '{"type":"result","subtype":"success","result":"too late"}'
1509"#,
1510        );
1511        let (sink, _rx) = EventSink::channel();
1512        let cancel = CancellationToken::new();
1513        let cancel_clone = cancel.clone();
1514        let run = tokio::spawn(async move {
1515            executor(bin)
1516                .run(
1517                    run_spec("a long task"),
1518                    sink,
1519                    SteerInbox::disconnected(),
1520                    cancel_clone,
1521                )
1522                .await
1523        });
1524        tokio::time::sleep(Duration::from_millis(200)).await;
1525        cancel.cancel();
1526
1527        let outcome = tokio::time::timeout(Duration::from_secs(15), run)
1528            .await
1529            .expect("run finished within the shutdown bound")
1530            .unwrap();
1531        assert_eq!(outcome.status, TerminalStatus::Cancelled);
1532    }
1533
1534    #[tokio::test]
1535    async fn oversized_single_stdout_line_parses() {
1536        let dir = tempfile::tempdir().unwrap();
1537        // Build a >100KB single-line `assistant` frame plus a `result` frame,
1538        // written from a small python-free shell using `yes`/`head` to avoid
1539        // depending on any interpreter beyond POSIX sh + coreutils.
1540        let big_text = "x".repeat(150_000);
1541        let script = format!(
1542            r#"
1543read -r _assignment
1544echo '{{"type":"assistant","message":{{"content":[{{"type":"text","text":"{big_text}"}}]}}}}'
1545echo '{{"type":"result","subtype":"success","result":"ok"}}'
1546"#
1547        );
1548        let bin = write_stub(dir.path(), &script);
1549        let (sink, mut rx) = EventSink::channel();
1550        let outcome = executor(bin)
1551            .run(
1552                run_spec("emit a huge line"),
1553                sink,
1554                SteerInbox::disconnected(),
1555                CancellationToken::new(),
1556            )
1557            .await;
1558        assert_eq!(outcome.status, TerminalStatus::Completed);
1559        assert_eq!(outcome.result.as_deref(), Some("ok"));
1560
1561        let mut saw_big_token = false;
1562        while let Ok(e) = rx.try_recv() {
1563            if e["type"] == "token" {
1564                assert_eq!(e["content"].as_str().unwrap().len(), 150_000);
1565                saw_big_token = true;
1566            }
1567        }
1568        assert!(saw_big_token, "expected the oversized token event");
1569    }
1570
1571    #[tokio::test]
1572    async fn missing_binary_errors_without_hanging() {
1573        let (sink, _rx) = EventSink::channel();
1574        let outcome = ClaudeCodeExecutor::new(
1575            Some("/nonexistent/definitely-not-claude".into()),
1576            None,
1577            None,
1578            None,
1579            None,
1580            false,
1581            Vec::new(),
1582        )
1583        .run(
1584            run_spec("hi"),
1585            sink,
1586            SteerInbox::disconnected(),
1587            CancellationToken::new(),
1588        )
1589        .await;
1590        assert_eq!(outcome.status, TerminalStatus::Error);
1591        assert!(outcome.error.unwrap().contains("spawn"));
1592    }
1593
1594    #[test]
1595    fn truncate_chars_caps_and_reports_dropped_count() {
1596        let long = "a".repeat(50);
1597        let out = truncate_chars(&long, 10);
1598        assert!(out.starts_with(&"a".repeat(10)));
1599        assert!(out.contains("40 more chars"));
1600        assert_eq!(truncate_chars("short", 10), "short");
1601    }
1602
1603    #[test]
1604    fn tool_result_text_flattens_string_and_block_array() {
1605        assert_eq!(tool_result_text(Some(&json!("plain"))), "plain".to_string());
1606        assert_eq!(
1607            tool_result_text(Some(
1608                &json!([{"type":"text","text":"a"},{"type":"text","text":"b"}])
1609            )),
1610            "a\nb".to_string()
1611        );
1612        assert_eq!(tool_result_text(None), "".to_string());
1613    }
1614
1615    // ---- issue #444: session resume across activations ----
1616
1617    /// Stub that: (1) echoes its full argv into `argv-<N>.txt` (N = 1-based
1618    /// invocation counter, tracked via a `count` file so the SAME stub binary
1619    /// can be reused across sequential `run()` calls on one executor, just
1620    /// like the real `claude` binary is one binary reused across activations)
1621    /// and (2) echoes its first stdin line into `stdin-<N>.txt`, then emits a
1622    /// `system`+`result` pair whose `session_id` depends on N.
1623    const MULTI_RUN_STUB: &str = r#"
1624DIR="$(cd "$(dirname "$0")" && pwd)"
1625N=$(cat "$DIR/count" 2>/dev/null || echo 0)
1626N=$((N+1))
1627echo "$N" > "$DIR/count"
1628printf '%s\n' "$@" > "$DIR/argv-$N.txt"
1629read -r line
1630printf '%s\n' "$line" > "$DIR/stdin-$N.txt"
1631if [ "$N" = "1" ]; then
1632  echo '{"type":"system","session_id":"s-1"}'
1633  echo '{"type":"result","subtype":"success","result":"first"}'
1634elif [ "$N" = "2" ]; then
1635  echo '{"type":"system","session_id":"s-2"}'
1636  echo '{"type":"result","subtype":"success","result":"second"}'
1637else
1638  echo '{"type":"result","subtype":"success","result":"third"}'
1639fi
1640"#;
1641
1642    #[tokio::test]
1643    async fn resume_state_written_reused_and_cleared_across_activations() {
1644        let bin_dir = tempfile::tempdir().unwrap();
1645        let state_dir = tempfile::tempdir().unwrap();
1646        let bin = write_stub(bin_dir.path(), MULTI_RUN_STUB);
1647        let exec = executor_with_state(bin, Some(state_dir.path().to_path_buf()), None);
1648        let state_path = state_dir.path().join("claude-code-session.json");
1649
1650        // Run 1 (messages empty): fresh session, no --resume; state file
1651        // written from the `system` frame's session_id.
1652        let (sink, _rx) = EventSink::channel();
1653        let outcome = exec
1654            .run(
1655                run_spec("task one"),
1656                sink,
1657                SteerInbox::disconnected(),
1658                CancellationToken::new(),
1659            )
1660            .await;
1661        assert_eq!(outcome.status, TerminalStatus::Completed);
1662        assert!(!read_argv(bin_dir.path(), "argv-1.txt").contains("--resume"));
1663        let state: Value =
1664            serde_json::from_str(&std::fs::read_to_string(&state_path).unwrap()).unwrap();
1665        assert_eq!(state["session_id"], "s-1");
1666
1667        // Run 2 (messages non-empty, same executor/state dir): resumes s-1;
1668        // stub assigns a NEW id s-2, which rewrites the state file. The
1669        // resumed turn sends only the live assignment (the CLI already has
1670        // the transcript) — no rehydrated preamble.
1671        let (sink, _rx) = EventSink::channel();
1672        let messages = vec![msg("user", "task one"), msg("assistant", "did it")];
1673        let outcome = exec
1674            .run(
1675                run_spec_with_messages("task two", messages),
1676                sink,
1677                SteerInbox::disconnected(),
1678                CancellationToken::new(),
1679            )
1680            .await;
1681        assert_eq!(outcome.status, TerminalStatus::Completed);
1682        let argv2 = read_argv(bin_dir.path(), "argv-2.txt");
1683        assert!(argv2.contains("--resume"));
1684        assert!(argv2.contains("s-1"));
1685        let state: Value =
1686            serde_json::from_str(&std::fs::read_to_string(&state_path).unwrap()).unwrap();
1687        assert_eq!(state["session_id"], "s-2");
1688        assert_eq!(stdin_body(bin_dir.path(), "stdin-2.txt"), "task two");
1689
1690        // Run 3 (messages empty again): the stale state is deleted BEFORE
1691        // spawn (no accidental resume on a plain rerun), and this stub
1692        // invocation reports no session id at all — so the file stays gone.
1693        let (sink, _rx) = EventSink::channel();
1694        let outcome = exec
1695            .run(
1696                run_spec("task three"),
1697                sink,
1698                SteerInbox::disconnected(),
1699                CancellationToken::new(),
1700            )
1701            .await;
1702        assert_eq!(outcome.status, TerminalStatus::Completed);
1703        assert!(!read_argv(bin_dir.path(), "argv-3.txt").contains("--resume"));
1704        assert!(!state_path.exists());
1705    }
1706
1707    #[tokio::test]
1708    async fn fallback_rehydration_renders_preamble_without_state() {
1709        let bin_dir = tempfile::tempdir().unwrap();
1710        let state_dir = tempfile::tempdir().unwrap();
1711        let bin = write_stub(
1712            bin_dir.path(),
1713            r#"
1714DIR="$(cd "$(dirname "$0")" && pwd)"
1715printf '%s\n' "$@" > "$DIR/argv.txt"
1716read -r line
1717printf '%s\n' "$line" > "$DIR/stdin.txt"
1718echo '{"type":"result","subtype":"success","result":"ok"}'
1719"#,
1720        );
1721        let exec = executor_with_state(bin, Some(state_dir.path().to_path_buf()), None);
1722        let messages = vec![
1723            msg("user", "please do X"),
1724            msg("assistant", "sure, doing X"),
1725            // Trailing user message duplicating the assignment — must be
1726            // excluded from the rendered preamble.
1727            msg("user", "continue"),
1728        ];
1729        let (sink, _rx) = EventSink::channel();
1730        let outcome = exec
1731            .run(
1732                run_spec_with_messages("continue", messages),
1733                sink,
1734                SteerInbox::disconnected(),
1735                CancellationToken::new(),
1736            )
1737            .await;
1738        assert_eq!(outcome.status, TerminalStatus::Completed);
1739        assert!(!read_argv(bin_dir.path(), "argv.txt").contains("--resume"));
1740        let body = stdin_body(bin_dir.path(), "stdin.txt");
1741        assert!(body.contains("## Prior conversation (rehydrated)"));
1742        assert!(body.contains("please do X"));
1743        assert!(body.contains("## Current task"));
1744        assert!(!body.contains("truncated"));
1745        // "continue" must appear exactly once (under "Current task"), not
1746        // duplicated by an un-deduplicated trailing history entry.
1747        assert_eq!(body.matches("continue").count(), 1);
1748    }
1749
1750    #[tokio::test]
1751    async fn fallback_rehydration_truncates_over_message_cap() {
1752        let bin_dir = tempfile::tempdir().unwrap();
1753        let state_dir = tempfile::tempdir().unwrap();
1754        let bin = write_stub(
1755            bin_dir.path(),
1756            r#"
1757DIR="$(cd "$(dirname "$0")" && pwd)"
1758read -r line
1759printf '%s\n' "$line" > "$DIR/stdin.txt"
1760echo '{"type":"result","subtype":"success","result":"ok"}'
1761"#,
1762        );
1763        let exec = executor_with_state(bin, Some(state_dir.path().to_path_buf()), None);
1764        let mut messages: Vec<Value> = (0..50)
1765            .map(|i| {
1766                let role = if i % 2 == 0 { "user" } else { "assistant" };
1767                msg(role, &format!("message {i}"))
1768            })
1769            .collect();
1770        messages.push(msg("user", "final ask"));
1771        let (sink, _rx) = EventSink::channel();
1772        let outcome = exec
1773            .run(
1774                run_spec_with_messages("final ask", messages),
1775                sink,
1776                SteerInbox::disconnected(),
1777                CancellationToken::new(),
1778            )
1779            .await;
1780        assert_eq!(outcome.status, TerminalStatus::Completed);
1781        let body = stdin_body(bin_dir.path(), "stdin.txt");
1782        assert!(body.contains("truncated"));
1783        // Oldest (message-count cap) dropped first; most recent retained.
1784        assert!(!body.contains("message 0"));
1785        assert!(body.contains("message 49"));
1786    }
1787
1788    #[tokio::test]
1789    async fn fallback_rehydration_truncates_oversized_single_message() {
1790        let bin_dir = tempfile::tempdir().unwrap();
1791        let state_dir = tempfile::tempdir().unwrap();
1792        let bin = write_stub(
1793            bin_dir.path(),
1794            r#"
1795DIR="$(cd "$(dirname "$0")" && pwd)"
1796read -r line
1797printf '%s\n' "$line" > "$DIR/stdin.txt"
1798echo '{"type":"result","subtype":"success","result":"ok"}'
1799"#,
1800        );
1801        let exec = executor_with_state(bin, Some(state_dir.path().to_path_buf()), None);
1802        let huge = "x".repeat(30_000);
1803        let messages = vec![msg("user", &huge), msg("user", "current ask")];
1804        let (sink, _rx) = EventSink::channel();
1805        let outcome = exec
1806            .run(
1807                run_spec_with_messages("current ask", messages),
1808                sink,
1809                SteerInbox::disconnected(),
1810                CancellationToken::new(),
1811            )
1812            .await;
1813        assert_eq!(outcome.status, TerminalStatus::Completed);
1814        let body = stdin_body(bin_dir.path(), "stdin.txt");
1815        assert!(body.contains("truncated"));
1816        assert!(body.len() < huge.len());
1817    }
1818
1819    #[tokio::test]
1820    async fn workspace_mismatch_state_treated_as_unusable_falls_back() {
1821        let bin_dir = tempfile::tempdir().unwrap();
1822        let state_dir = tempfile::tempdir().unwrap();
1823        // A real directory — `build_command` does `cmd.current_dir(workspace)`,
1824        // which fails the spawn outright if it doesn't exist on disk.
1825        let workspace_dir = tempfile::tempdir().unwrap();
1826        std::fs::write(
1827            state_dir.path().join("claude-code-session.json"),
1828            serde_json::to_vec(&json!({
1829                "session_id": "stale-id",
1830                "workspace": "/some/other/workspace",
1831                "updated_at": chrono::Utc::now(),
1832            }))
1833            .unwrap(),
1834        )
1835        .unwrap();
1836        let bin = write_stub(
1837            bin_dir.path(),
1838            r#"
1839DIR="$(cd "$(dirname "$0")" && pwd)"
1840printf '%s\n' "$@" > "$DIR/argv.txt"
1841read -r _line
1842echo '{"type":"result","subtype":"success","result":"ok"}'
1843"#,
1844        );
1845        let exec = executor_with_state(
1846            bin,
1847            Some(state_dir.path().to_path_buf()),
1848            Some(workspace_dir.path().to_string_lossy().into_owned()),
1849        );
1850        let messages = vec![msg("user", "hi"), msg("user", "go")];
1851        let (sink, _rx) = EventSink::channel();
1852        let outcome = exec
1853            .run(
1854                run_spec_with_messages("go", messages),
1855                sink,
1856                SteerInbox::disconnected(),
1857                CancellationToken::new(),
1858            )
1859            .await;
1860        assert_eq!(outcome.status, TerminalStatus::Completed);
1861        let argv = read_argv(bin_dir.path(), "argv.txt");
1862        assert!(!argv.contains("--resume"));
1863        assert!(!argv.contains("stale-id"));
1864    }
1865
1866    #[tokio::test]
1867    async fn resume_failure_retries_once_with_fallback_history() {
1868        let bin_dir = tempfile::tempdir().unwrap();
1869        let state_dir = tempfile::tempdir().unwrap();
1870        std::fs::write(
1871            state_dir.path().join("claude-code-session.json"),
1872            serde_json::to_vec(&json!({
1873                "session_id": "dead-id",
1874                "workspace": null,
1875                "updated_at": chrono::Utc::now(),
1876            }))
1877            .unwrap(),
1878        )
1879        .unwrap();
1880        let bin = write_stub(
1881            bin_dir.path(),
1882            r#"
1883DIR="$(cd "$(dirname "$0")" && pwd)"
1884N=$(cat "$DIR/count" 2>/dev/null || echo 0)
1885N=$((N+1))
1886echo "$N" > "$DIR/count"
1887printf '%s\n' "$@" > "$DIR/argv-$N.txt"
1888case "$*" in
1889  *--resume*)
1890    exit 1
1891    ;;
1892  *)
1893    read -r line
1894    printf '%s\n' "$line" > "$DIR/stdin-$N.txt"
1895    echo '{"type":"system","session_id":"s-fresh"}'
1896    echo '{"type":"result","subtype":"success","result":"recovered"}'
1897    ;;
1898esac
1899"#,
1900        );
1901        let exec = executor_with_state(bin, Some(state_dir.path().to_path_buf()), None);
1902        let messages = vec![
1903            msg("user", "earlier context"),
1904            msg("user", "please continue"),
1905        ];
1906        let (sink, _rx) = EventSink::channel();
1907        let outcome = exec
1908            .run(
1909                run_spec_with_messages("please continue", messages),
1910                sink,
1911                SteerInbox::disconnected(),
1912                CancellationToken::new(),
1913            )
1914            .await;
1915        assert_eq!(outcome.status, TerminalStatus::Completed);
1916        assert_eq!(outcome.result.as_deref(), Some("recovered"));
1917        assert_eq!(read_argv(bin_dir.path(), "count").trim(), "2");
1918
1919        let argv1 = read_argv(bin_dir.path(), "argv-1.txt");
1920        assert!(argv1.contains("--resume"));
1921        assert!(argv1.contains("dead-id"));
1922        let argv2 = read_argv(bin_dir.path(), "argv-2.txt");
1923        assert!(!argv2.contains("--resume"));
1924        let body2 = stdin_body(bin_dir.path(), "stdin-2.txt");
1925        assert!(body2.contains("## Prior conversation (rehydrated)"));
1926        assert!(body2.contains("earlier context"));
1927
1928        // The retry rewrites the state file from the fallback attempt's own
1929        // `system` frame, parses cleanly (atomic tmp+rename), and no leftover
1930        // tmp files remain in the state dir.
1931        let state: Value = serde_json::from_str(
1932            &std::fs::read_to_string(state_dir.path().join("claude-code-session.json")).unwrap(),
1933        )
1934        .unwrap();
1935        assert_eq!(state["session_id"], "s-fresh");
1936        let leftover_tmp = std::fs::read_dir(state_dir.path())
1937            .unwrap()
1938            .filter_map(|e| e.ok())
1939            .any(|e| e.file_name().to_string_lossy().contains(".tmp"));
1940        assert!(!leftover_tmp);
1941    }
1942}