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