Skip to main content

batty_cli/shim/
runtime.rs

1//! The shim process: owns a PTY, runs an agent CLI, classifies state,
2//! communicates with the orchestrator via a Channel on fd 3.
3//!
4//! Raw PTY output is also streamed to a log file so tmux display panes can
5//! `tail -F` it and render agent output in real time.
6
7use std::collections::VecDeque;
8use std::fs;
9use std::io::{Read, Write as IoWrite};
10use std::path::{Path, PathBuf};
11use std::process::Command as ProcessCommand;
12use std::sync::mpsc;
13use std::sync::{Arc, Mutex};
14use std::thread;
15use std::time::Duration;
16use std::time::Instant;
17
18use anyhow::{Context, Result};
19use portable_pty::{Child, CommandBuilder, PtySize};
20
21use super::classifier::{self, AgentType, ScreenVerdict};
22use super::common::{self, QueuedMessage};
23use super::protocol::{Channel, Command, Event, ShimState};
24use super::pty_log::PtyLogWriter;
25use crate::prompt::strip_ansi;
26
27// ---------------------------------------------------------------------------
28// Configuration
29// ---------------------------------------------------------------------------
30
31const DEFAULT_ROWS: u16 = 50;
32const DEFAULT_COLS: u16 = 220;
33const SCROLLBACK_LINES: usize = 5000;
34
35/// How often to check for state changes when no PTY output arrives (ms).
36const POLL_INTERVAL_MS: u64 = 250;
37
38/// Minimum time to stay in Working state before allowing transition to Idle (ms).
39/// Prevents false Working→Idle from the message echo appearing before the agent
40/// starts processing. Kept short (300ms) to avoid missing fast responses from
41/// agents like Kiro-cli whose idle prompt disappears quickly during processing.
42const WORKING_DWELL_MS: u64 = 300;
43
44/// Additional quiet period required before Kiro is considered Idle.
45/// Kiro can redraw its idle prompt before the final response bytes land.
46const KIRO_IDLE_SETTLE_MS: u64 = 1200;
47
48/// Max time to wait for agent to show its first prompt (secs).
49const READY_TIMEOUT_SECS: u64 = 120;
50use common::MAX_QUEUE_DEPTH;
51use common::SESSION_STATS_INTERVAL_SECS;
52
53const PROCESS_EXIT_POLL_MS: u64 = 100;
54const PARENT_DEATH_POLL_SECS: u64 = 1;
55const GROUP_TERM_GRACE_SECS: u64 = 2;
56pub(crate) const HANDOFF_FILE_NAME: &str = ".batty-handoff.md";
57const AUTO_COMMIT_MESSAGE: &str = "wip: auto-save before restart [batty]";
58const AUTO_COMMIT_TIMEOUT_SECS: u64 = 5;
59
60/// Capture a work summary (git diff + recent commits) and write it to
61/// a handoff file in the given worktree. Called before an agent restart
62/// so the new session can pick up where the old one left off.
63pub(crate) fn preserve_handoff(
64    worktree: &Path,
65    task: &crate::task::Task,
66    recent_output: Option<&str>,
67) -> Result<()> {
68    let branch_name = git_capture(worktree, &["branch", "--show-current"]).unwrap_or_default();
69    let last_commit = git_capture(worktree, &["rev-parse", "HEAD"]).unwrap_or_default();
70    let changed_files = summarize_changed_files(worktree);
71    let recent_commits = git_capture(worktree, &["log", "--oneline", "-5"]).unwrap_or_default();
72    let tests_run = recent_output
73        .map(extract_test_commands)
74        .unwrap_or_default()
75        .join("\n");
76    let progress_summary = summarize_recent_progress(worktree, task, recent_output);
77    let recent_activity = recent_output
78        .map(summarize_recent_activity)
79        .unwrap_or_default();
80
81    let handoff = format!(
82        "# Carry-Forward Summary\n## Task Spec\nTask #{}: {}\n\n{}\n\n## Work Completed So Far\n### Branch\n{}\n\n### Last Commit\n{}\n\n### Changed Files\n{}\n\n### Tests Run\n{}\n\n### Progress Summary\n{}\n\n### Recent Activity\n{}\n\n### Recent Commits\n{}\n\n## What Remains\n{}\n",
83        task.id,
84        task.title,
85        empty_section_fallback(&task.description),
86        empty_section_fallback(&branch_name),
87        empty_section_fallback(&last_commit),
88        empty_section_fallback(&changed_files),
89        empty_section_fallback(&tests_run),
90        empty_section_fallback(&progress_summary),
91        empty_section_fallback(&recent_activity),
92        empty_section_fallback(&recent_commits),
93        handoff_remaining_work(task)
94    );
95    fs::write(worktree.join(HANDOFF_FILE_NAME), handoff)
96        .with_context(|| format!("failed to write handoff file in {}", worktree.display()))?;
97    Ok(())
98}
99
100fn git_capture(worktree: &Path, args: &[&str]) -> Result<String> {
101    let output = ProcessCommand::new("git")
102        .args(args)
103        .current_dir(worktree)
104        .env_remove("GIT_DIR")
105        .env_remove("GIT_WORK_TREE")
106        .output()
107        .with_context(|| {
108            format!(
109                "failed to run `git {}` in {}",
110                args.join(" "),
111                worktree.display()
112            )
113        })?;
114    if !output.status.success() {
115        anyhow::bail!(
116            "`git {}` failed in {}: {}",
117            args.join(" "),
118            worktree.display(),
119            String::from_utf8_lossy(&output.stderr).trim()
120        );
121    }
122    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
123}
124
125fn empty_section_fallback(content: &str) -> &str {
126    if content.trim().is_empty() {
127        "(none)"
128    } else {
129        content
130    }
131}
132
133fn summarize_recent_activity(output: &str) -> String {
134    let cleaned = strip_ansi(output);
135    let lines: Vec<&str> = cleaned
136        .lines()
137        .map(str::trim_end)
138        .filter(|line| !line.trim().is_empty())
139        .collect();
140    let start = lines.len().saturating_sub(40);
141    lines[start..].join("\n")
142}
143
144fn summarize_recent_progress(
145    worktree: &Path,
146    task: &crate::task::Task,
147    recent_output: Option<&str>,
148) -> String {
149    let mut summary = summarize_progress_from_event_log(worktree, task);
150    if summary.trim().is_empty() {
151        summary = recent_output
152            .map(summarize_recent_activity)
153            .unwrap_or_default();
154    }
155    truncate_to_word_limit(&summary, 500)
156}
157
158fn summarize_progress_from_event_log(worktree: &Path, task: &crate::task::Task) -> String {
159    let events_path = crate::team::team_events_path(worktree);
160    let Ok(events) = crate::team::events::read_events(&events_path) else {
161        return String::new();
162    };
163
164    let task_id = task.id.to_string();
165    let claimed_by = task.claimed_by.as_deref();
166    let mut lines = Vec::new();
167    for event in events.iter().rev() {
168        let matches_task = event.task.as_deref() == Some(task_id.as_str());
169        let matches_role = claimed_by.is_some() && event.role.as_deref() == claimed_by;
170        if !matches_task && !matches_role {
171            continue;
172        }
173
174        let mut line = event.event.clone();
175        if let Some(reason) = event
176            .reason
177            .as_deref()
178            .filter(|reason| !reason.trim().is_empty())
179        {
180            line.push_str(": ");
181            line.push_str(reason.trim());
182        } else if let Some(details) = event
183            .details
184            .as_deref()
185            .filter(|details| !details.trim().is_empty())
186        {
187            line.push_str(": ");
188            line.push_str(details.trim());
189        } else if let Some(error) = event
190            .error
191            .as_deref()
192            .filter(|error| !error.trim().is_empty())
193        {
194            line.push_str(": ");
195            line.push_str(error.trim());
196        }
197        lines.push(line);
198        if lines.len() >= 12 {
199            break;
200        }
201    }
202    lines.reverse();
203    lines.join("\n")
204}
205
206fn truncate_to_word_limit(input: &str, word_limit: usize) -> String {
207    let words: Vec<&str> = input.split_whitespace().collect();
208    if words.len() <= word_limit {
209        return input.trim().to_string();
210    }
211    let truncated = words[..word_limit].join(" ");
212    format!("{truncated} ...")
213}
214
215fn summarize_changed_files(worktree: &Path) -> String {
216    let mut files = Vec::new();
217    for args in [
218        &["diff", "--name-only"] as &[&str],
219        &["diff", "--cached", "--name-only"],
220        &["ls-files", "--others", "--exclude-standard"],
221    ] {
222        let Ok(output) = git_capture(worktree, args) else {
223            continue;
224        };
225        for line in output.lines() {
226            let trimmed = line.trim();
227            if !trimmed.is_empty() && !files.iter().any(|existing| existing == trimmed) {
228                files.push(trimmed.to_string());
229            }
230        }
231    }
232    files.join("\n")
233}
234
235fn handoff_remaining_work(task: &crate::task::Task) -> &str {
236    task.next_action
237        .as_deref()
238        .filter(|next| !next.trim().is_empty())
239        .unwrap_or("Continue from the current worktree state, verify acceptance criteria, and finish the task without redoing completed work.")
240}
241
242fn extract_test_commands(output: &str) -> Vec<String> {
243    let cleaned = strip_ansi(output);
244    let mut commands = Vec::new();
245
246    for line in cleaned.lines() {
247        let trimmed = line.trim();
248        if trimmed.is_empty() {
249            continue;
250        }
251        let lower = trimmed.to_ascii_lowercase();
252        if (lower.contains("cargo test")
253            || lower.contains("cargo nextest")
254            || lower.contains("pytest")
255            || lower.contains("npm test")
256            || lower.contains("pnpm test")
257            || lower.contains("yarn test")
258            || lower.contains("go test")
259            || lower.contains("bundle exec rspec")
260            || lower.contains("mix test"))
261            && !commands.iter().any(|existing| existing == trimmed)
262        {
263            commands.push(trimmed.to_string());
264        }
265    }
266
267    commands
268}
269
270fn format_injected_message(sender: &str, body: &str) -> String {
271    common::format_injected_message(sender, body)
272}
273
274fn shell_single_quote(input: &str) -> String {
275    input.replace('\'', "'\\''")
276}
277
278fn build_supervised_agent_command(command: &str, shim_pid: u32) -> String {
279    let escaped_command = shell_single_quote(command);
280    format!(
281        "shim_pid={shim_pid}; \
282         agent_root_pid=$$; \
283         agent_pgid=$$; \
284         setsid sh -c ' \
285           shim_pid=\"$1\"; \
286           agent_pgid=\"$2\"; \
287           agent_root_pid=\"$3\"; \
288           collect_descendants() {{ \
289             parent_pid=\"$1\"; \
290             for child_pid in $(pgrep -P \"$parent_pid\" 2>/dev/null); do \
291               printf \"%s\\n\" \"$child_pid\"; \
292               collect_descendants \"$child_pid\"; \
293             done; \
294           }}; \
295           while kill -0 \"$shim_pid\" 2>/dev/null; do sleep {PARENT_DEATH_POLL_SECS}; done; \
296           descendant_pids=$(collect_descendants \"$agent_root_pid\"); \
297           kill -TERM -- -\"$agent_pgid\" >/dev/null 2>&1 || true; \
298           for descendant_pid in $descendant_pids; do kill -TERM \"$descendant_pid\" >/dev/null 2>&1 || true; done; \
299           sleep {GROUP_TERM_GRACE_SECS}; \
300           kill -KILL -- -\"$agent_pgid\" >/dev/null 2>&1 || true; \
301           for descendant_pid in $descendant_pids; do kill -KILL \"$descendant_pid\" >/dev/null 2>&1 || true; done \
302         ' _ \"$shim_pid\" \"$agent_pgid\" \"$agent_root_pid\" >/dev/null 2>&1 < /dev/null & \
303         exec bash -lc '{escaped_command}'"
304    )
305}
306
307#[cfg(unix)]
308fn signal_process_group(child: &dyn Child, signal: libc::c_int) -> std::io::Result<()> {
309    let pid = child
310        .process_id()
311        .ok_or_else(|| std::io::Error::other("child process id unavailable"))?;
312    let result = unsafe { libc::killpg(pid as libc::pid_t, signal) };
313    if result == 0 {
314        Ok(())
315    } else {
316        Err(std::io::Error::last_os_error())
317    }
318}
319
320fn terminate_agent_group(
321    child: &mut Box<dyn Child + Send + Sync>,
322    sigterm_grace: Duration,
323) -> std::io::Result<()> {
324    #[cfg(unix)]
325    {
326        signal_process_group(child.as_ref(), libc::SIGTERM)?;
327        let deadline = Instant::now() + sigterm_grace;
328        while Instant::now() <= deadline {
329            if let Ok(Some(_)) = child.try_wait() {
330                return Ok(());
331            }
332            thread::sleep(Duration::from_millis(PROCESS_EXIT_POLL_MS));
333        }
334
335        signal_process_group(child.as_ref(), libc::SIGKILL)?;
336        return Ok(());
337    }
338
339    #[allow(unreachable_code)]
340    child.kill()
341}
342
343fn graceful_shutdown_timeout() -> Duration {
344    let secs = std::env::var("BATTY_GRACEFUL_SHUTDOWN_TIMEOUT_SECS")
345        .ok()
346        .and_then(|value| value.parse::<u64>().ok())
347        .unwrap_or(AUTO_COMMIT_TIMEOUT_SECS);
348    Duration::from_secs(secs)
349}
350
351fn auto_commit_on_restart_enabled() -> bool {
352    std::env::var("BATTY_AUTO_COMMIT_ON_RESTART")
353        .map(|value| !matches!(value.as_str(), "0" | "false" | "FALSE"))
354        .unwrap_or(true)
355}
356
357fn preserve_work_before_kill_with<F>(
358    worktree_path: &Path,
359    timeout: Duration,
360    enabled: bool,
361    commit_fn: F,
362) -> Result<bool>
363where
364    F: FnOnce(PathBuf) -> Result<bool> + Send + 'static,
365{
366    if !enabled {
367        return Ok(false);
368    }
369
370    let (tx, rx) = mpsc::channel();
371    let path = worktree_path.to_path_buf();
372    thread::spawn(move || {
373        let _ = tx.send(commit_fn(path));
374    });
375
376    match rx.recv_timeout(timeout) {
377        Ok(result) => result,
378        Err(mpsc::RecvTimeoutError::Timeout) => Ok(false),
379        Err(mpsc::RecvTimeoutError::Disconnected) => Ok(false),
380    }
381}
382
383pub(crate) fn preserve_work_before_kill(worktree_path: &Path) -> Result<bool> {
384    let timeout = graceful_shutdown_timeout();
385    preserve_work_before_kill_with(
386        worktree_path,
387        timeout,
388        auto_commit_on_restart_enabled(),
389        move |path| {
390            crate::team::git_cmd::auto_commit_if_dirty(&path, AUTO_COMMIT_MESSAGE, timeout)
391                .map_err(anyhow::Error::from)
392        },
393    )
394}
395
396/// Write body bytes to the PTY in small chunks with micro-delays, then
397/// send the Enter sequence. This prevents TUI agents with synchronized
398/// output from losing characters during screen redraw cycles.
399fn pty_write_paced(
400    pty_writer: &Arc<Mutex<Box<dyn std::io::Write + Send>>>,
401    agent_type: AgentType,
402    body: &[u8],
403    enter: &[u8],
404) -> std::io::Result<()> {
405    // Use bracketed paste for TUI agents (Claude, Kiro, Codex).
406    // This is the standard terminal protocol for pasting text — the agent
407    // receives the complete body atomically between \x1b[200~ and \x1b[201~
408    // markers, then we send Enter to submit.
409    // Character-by-character injection loses keystrokes in TUI agents that
410    // use synchronized output, causing "pasted text" indicators without
411    // the Enter being processed.
412    match agent_type {
413        AgentType::Generic => {
414            // Generic/bash: write directly, no paste mode needed
415            let mut writer = pty_writer.lock().unwrap();
416            writer.write_all(body)?;
417            writer.write_all(enter)?;
418            writer.flush()?;
419        }
420        _ => {
421            // TUI agents: bracketed paste + pause + Enter
422            let mut writer = pty_writer.lock().unwrap();
423            writer.write_all(b"\x1b[200~")?;
424            writer.write_all(body)?;
425            writer.write_all(b"\x1b[201~")?;
426            writer.flush()?;
427            drop(writer);
428
429            // Pause to let the TUI process the paste before sending Enter
430            std::thread::sleep(std::time::Duration::from_millis(200));
431
432            let mut writer = pty_writer.lock().unwrap();
433            writer.write_all(enter)?;
434            writer.flush()?;
435        }
436    }
437    Ok(())
438}
439
440/// Returns the Enter key sequence for the given agent type.
441/// Most TUI agents run in raw mode and need \r (CR) for Enter.
442/// Generic/bash uses canonical mode and needs \n (LF).
443fn enter_seq(agent_type: AgentType) -> &'static str {
444    match agent_type {
445        AgentType::Generic => "\n",
446        _ => "\r", // Claude, Codex, Kiro — raw-mode TUIs
447    }
448}
449
450// ---------------------------------------------------------------------------
451// Args (parsed from CLI in main.rs, passed here)
452// ---------------------------------------------------------------------------
453
454#[derive(Debug, Clone)]
455pub struct ShimArgs {
456    pub id: String,
457    pub agent_type: AgentType,
458    pub cmd: String,
459    pub cwd: PathBuf,
460    pub rows: u16,
461    pub cols: u16,
462    /// Optional path for the PTY log file. When set, raw PTY output is
463    /// streamed to this file so tmux display panes can `tail -F` it.
464    pub pty_log_path: Option<PathBuf>,
465    pub graceful_shutdown_timeout_secs: u64,
466    pub auto_commit_on_restart: bool,
467}
468
469impl ShimArgs {
470    fn preserve_work_before_kill(&self, worktree_path: &Path) -> Result<bool> {
471        if !self.auto_commit_on_restart {
472            return Ok(false);
473        }
474
475        let status = ProcessCommand::new("git")
476            .arg("-C")
477            .arg(worktree_path)
478            .args(["status", "--porcelain"])
479            .output()
480            .with_context(|| {
481                format!(
482                    "failed to inspect git status in {}",
483                    worktree_path.display()
484                )
485            })?;
486        if !status.status.success() {
487            anyhow::bail!("git status failed in {}", worktree_path.display());
488        }
489
490        let dirty = String::from_utf8_lossy(&status.stdout)
491            .lines()
492            .any(|line| !line.starts_with("?? .batty/"));
493        if !dirty {
494            return Ok(false);
495        }
496
497        let timeout = Duration::from_secs(self.graceful_shutdown_timeout_secs);
498        run_git_preserve_with_timeout(worktree_path, &["add", "-A"], timeout)?;
499        run_git_preserve_with_timeout(
500            worktree_path,
501            &["commit", "-m", "wip: auto-save before restart [batty]"],
502            timeout,
503        )?;
504        Ok(true)
505    }
506}
507
508fn run_git_preserve_with_timeout(
509    worktree_path: &Path,
510    args: &[&str],
511    timeout: Duration,
512) -> Result<()> {
513    let mut child = ProcessCommand::new("git")
514        .arg("-C")
515        .arg(worktree_path)
516        .args(args)
517        .spawn()
518        .with_context(|| {
519            format!(
520                "failed to launch `git {}` in {}",
521                args.join(" "),
522                worktree_path.display()
523            )
524        })?;
525    let deadline = Instant::now() + timeout;
526    loop {
527        if let Some(status) = child.try_wait()? {
528            if status.success() {
529                return Ok(());
530            }
531            anyhow::bail!(
532                "`git {}` failed in {} with status {}",
533                args.join(" "),
534                worktree_path.display(),
535                status
536            );
537        }
538
539        if Instant::now() >= deadline {
540            let _ = child.kill();
541            let _ = child.wait();
542            anyhow::bail!(
543                "`git {}` timed out after {}s in {}",
544                args.join(" "),
545                timeout.as_secs(),
546                worktree_path.display()
547            );
548        }
549
550        thread::sleep(Duration::from_millis(50));
551    }
552}
553
554// QueuedMessage is imported from super::common
555
556// ---------------------------------------------------------------------------
557// Shared state between PTY reader thread and command handler thread
558// ---------------------------------------------------------------------------
559
560struct ShimInner {
561    parser: vt100::Parser,
562    state: ShimState,
563    state_changed_at: Instant,
564    last_screen_hash: u64,
565    last_pty_output_at: Instant,
566    started_at: Instant,
567    active_session_started_at: Instant,
568    cumulative_output_bytes: u64,
569    pre_injection_content: String,
570    pending_message_id: Option<String>,
571    agent_type: AgentType,
572    /// Messages queued while the agent is in Working state.
573    /// Drained FIFO on Working→Idle transitions.
574    message_queue: VecDeque<QueuedMessage>,
575    /// Number of dialogs auto-dismissed during startup (capped to prevent loops).
576    dialogs_dismissed: u8,
577    /// Last screen content captured while the agent was in Working state.
578    /// Used for response extraction when TUI agents redraw the screen
579    /// before the Working→Idle transition is detected.
580    last_working_screen: String,
581    /// Consecutive failed test fix/retest loops handled inside the shim.
582    test_failure_iterations: u8,
583    /// Whether a ContextApproaching event has already been emitted this session.
584    context_approaching_emitted: bool,
585}
586
587impl ShimInner {
588    fn screen_contents(&self) -> String {
589        self.parser.screen().contents()
590    }
591
592    fn last_n_lines(&self, n: usize) -> String {
593        let content = self.parser.screen().contents();
594        let lines: Vec<&str> = content.lines().collect();
595        let start = lines.len().saturating_sub(n);
596        lines[start..].join("\n")
597    }
598
599    fn cursor_position(&self) -> (u16, u16) {
600        self.parser.screen().cursor_position()
601    }
602}
603
604// ---------------------------------------------------------------------------
605// FNV-1a hash for change detection
606// ---------------------------------------------------------------------------
607
608fn content_hash(s: &str) -> u64 {
609    let mut hash: u64 = 0xcbf29ce484222325;
610    for byte in s.bytes() {
611        hash ^= byte as u64;
612        hash = hash.wrapping_mul(0x100000001b3);
613    }
614    hash
615}
616
617// ---------------------------------------------------------------------------
618// Main shim entry point
619// ---------------------------------------------------------------------------
620
621/// Run the shim. This function does not return until the shim exits.
622/// `channel` is the pre-connected socket to the orchestrator (fd 3 or
623/// from a socketpair).
624pub fn run(args: ShimArgs, channel: Channel) -> Result<()> {
625    let rows = if args.rows > 0 {
626        args.rows
627    } else {
628        DEFAULT_ROWS
629    };
630    let cols = if args.cols > 0 {
631        args.cols
632    } else {
633        DEFAULT_COLS
634    };
635
636    // -- Create PTY --
637    let pty_system = portable_pty::native_pty_system();
638    let pty_pair = pty_system
639        .openpty(PtySize {
640            rows,
641            cols,
642            pixel_width: 0,
643            pixel_height: 0,
644        })
645        .context("failed to create PTY")?;
646
647    // -- Spawn agent CLI on slave side --
648    let shim_pid = std::process::id();
649    let supervised_cmd = build_supervised_agent_command(&args.cmd, shim_pid);
650
651    let mut cmd = CommandBuilder::new("bash");
652    cmd.args(["-lc", &supervised_cmd]);
653    cmd.cwd(&args.cwd);
654    cmd.env_remove("CLAUDECODE"); // prevent nested detection
655    cmd.env("TERM", "xterm-256color");
656    cmd.env("COLORTERM", "truecolor");
657
658    let mut child = pty_pair
659        .slave
660        .spawn_command(cmd)
661        .context("failed to spawn agent CLI")?;
662
663    // Close slave in parent (agent has its own copy)
664    drop(pty_pair.slave);
665
666    let mut pty_reader = pty_pair
667        .master
668        .try_clone_reader()
669        .context("failed to clone PTY reader")?;
670
671    let pty_writer = pty_pair
672        .master
673        .take_writer()
674        .context("failed to take PTY writer")?;
675
676    // -- Shared state --
677    let inner = Arc::new(Mutex::new(ShimInner {
678        parser: vt100::Parser::new(rows, cols, SCROLLBACK_LINES),
679        state: ShimState::Starting,
680        state_changed_at: Instant::now(),
681        last_screen_hash: 0,
682        last_pty_output_at: Instant::now(),
683        started_at: Instant::now(),
684        active_session_started_at: Instant::now(),
685        cumulative_output_bytes: 0,
686        pre_injection_content: String::new(),
687        pending_message_id: None,
688        agent_type: args.agent_type,
689        message_queue: VecDeque::new(),
690        dialogs_dismissed: 0,
691        last_working_screen: String::new(),
692        test_failure_iterations: 0,
693        context_approaching_emitted: false,
694    }));
695
696    // -- PTY log writer (optional) --
697    let pty_log: Option<Mutex<PtyLogWriter>> = args
698        .pty_log_path
699        .as_deref()
700        .map(|p| PtyLogWriter::new(p).context("failed to create PTY log"))
701        .transpose()?
702        .map(Mutex::new);
703    let pty_log = pty_log.map(Arc::new);
704
705    // Wrap PTY writer in Arc<Mutex> so both threads can write
706    let pty_writer = Arc::new(Mutex::new(pty_writer));
707
708    // Channel for sending events (cloned for PTY reader thread)
709    let mut cmd_channel = channel;
710    let mut evt_channel = cmd_channel.try_clone().context("failed to clone channel")?;
711
712    // -- PTY reader thread: reads agent output, feeds vt100, detects state --
713    let inner_pty = Arc::clone(&inner);
714    let log_handle = pty_log.clone();
715    let pty_writer_pty = Arc::clone(&pty_writer);
716    let pty_handle = std::thread::spawn(move || {
717        let mut buf = [0u8; 4096];
718        loop {
719            match pty_reader.read(&mut buf) {
720                Ok(0) => break, // EOF — agent closed PTY
721                Ok(n) => {
722                    // Stream raw bytes to PTY log for tmux display panes
723                    if let Some(ref log) = log_handle {
724                        let _ = log.lock().unwrap().write(&buf[..n]);
725                    }
726
727                    let mut inner = inner_pty.lock().unwrap();
728                    inner.last_pty_output_at = Instant::now();
729                    inner.cumulative_output_bytes =
730                        inner.cumulative_output_bytes.saturating_add(n as u64);
731                    inner.parser.process(&buf[..n]);
732
733                    // Classify when the screen content actually changes.
734                    // The content hash avoids redundant classifications —
735                    // no time-based debounce because it causes the PTY
736                    // reader to block on the next read and miss state
737                    // transitions when the prompt arrives shortly after
738                    // preceding output.
739                    let content = inner.parser.screen().contents();
740                    let hash = content_hash(&content);
741                    if hash == inner.last_screen_hash {
742                        continue; // no visual change
743                    }
744                    inner.last_screen_hash = hash;
745
746                    let verdict = classifier::classify(inner.agent_type, inner.parser.screen());
747                    let old_state = inner.state;
748
749                    // Check for context approaching limit (proactive, once per session).
750                    if !inner.context_approaching_emitted
751                        && common::detect_context_approaching_limit(&content)
752                    {
753                        inner.context_approaching_emitted = true;
754                        drop(inner);
755                        let _ = evt_channel.send(&Event::ContextApproaching {
756                            message: "Screen output contains context-pressure signals".into(),
757                            input_tokens: 0,
758                            output_tokens: 0,
759                        });
760                        continue;
761                    }
762
763                    // Track screen content during Working state for response
764                    // extraction. TUI agents may redraw the screen before the
765                    // Working→Idle transition, wiping the response content.
766                    if old_state == ShimState::Working {
767                        inner.last_working_screen = content.clone();
768                    }
769
770                    // Enforce minimum dwell time in Working state to avoid
771                    // false Working→Idle from the message echo before the
772                    // agent starts processing.
773                    let working_too_short = old_state == ShimState::Working
774                        && inner.state_changed_at.elapsed().as_millis() < WORKING_DWELL_MS as u128;
775                    let new_state = match (old_state, verdict) {
776                        (ShimState::Starting, ScreenVerdict::AgentIdle) => Some(ShimState::Idle),
777                        (ShimState::Idle, ScreenVerdict::AgentIdle) => None,
778                        (ShimState::Working, ScreenVerdict::AgentIdle) if working_too_short => None,
779                        (ShimState::Working, ScreenVerdict::AgentIdle)
780                            if inner.agent_type == AgentType::Kiro =>
781                        {
782                            None
783                        }
784                        (ShimState::Working, ScreenVerdict::AgentIdle) => Some(ShimState::Idle),
785                        (ShimState::Working, ScreenVerdict::AgentWorking) => None,
786                        (_, ScreenVerdict::ContextExhausted) => Some(ShimState::ContextExhausted),
787                        (_, ScreenVerdict::Unknown) => None,
788                        (ShimState::Idle, ScreenVerdict::AgentWorking) => Some(ShimState::Working),
789                        (ShimState::Starting, ScreenVerdict::AgentWorking) => {
790                            Some(ShimState::Working)
791                        }
792                        _ => None,
793                    };
794
795                    if let Some(new) = new_state {
796                        let summary = inner.last_n_lines(5);
797                        inner.state = new;
798                        inner.state_changed_at = Instant::now();
799
800                        let pre_content = inner.pre_injection_content.clone();
801                        let current_content = inner.screen_contents();
802                        let working_screen = inner.last_working_screen.clone();
803                        let msg_id = inner.pending_message_id.take();
804
805                        let response =
806                            completion_response(&pre_content, &current_content, &working_screen);
807
808                        if old_state == ShimState::Working && new == ShimState::Idle {
809                            if let Some(followup) = common::detect_test_failure_followup(
810                                &response,
811                                inner.test_failure_iterations,
812                            ) {
813                                inner.test_failure_iterations = followup.next_iteration_count;
814                                inner.pre_injection_content = inner.screen_contents();
815                                inner.pending_message_id = None;
816                                inner.state = ShimState::Working;
817                                inner.state_changed_at = Instant::now();
818                                inner.active_session_started_at = Instant::now();
819                                let agent_type_for_enter = inner.agent_type;
820                                drop(inner);
821
822                                let enter = enter_seq(agent_type_for_enter);
823                                if let Err(e) = pty_write_paced(
824                                    &pty_writer_pty,
825                                    agent_type_for_enter,
826                                    format_injected_message("batty", &followup.body).as_bytes(),
827                                    enter.as_bytes(),
828                                ) {
829                                    let _ = evt_channel.send(&Event::Error {
830                                        command: "SendMessage".into(),
831                                        reason: format!(
832                                            "PTY write failed for test failure follow-up: {e}"
833                                        ),
834                                    });
835                                } else {
836                                    let _ = evt_channel.send(&Event::Warning {
837                                        message: followup.notice,
838                                        idle_secs: None,
839                                    });
840                                }
841                                continue;
842                            }
843                            inner.test_failure_iterations = 0;
844                        }
845
846                        // On terminal states, drain the queue
847                        let drain_errors =
848                            if new == ShimState::Dead || new == ShimState::ContextExhausted {
849                                drain_queue_errors(&mut inner.message_queue, new)
850                            } else {
851                                Vec::new()
852                            };
853
854                        // On Working→Idle, check for queued messages to inject
855                        let queued_msg = if old_state == ShimState::Working
856                            && new == ShimState::Idle
857                            && !inner.message_queue.is_empty()
858                        {
859                            inner.message_queue.pop_front()
860                        } else {
861                            None
862                        };
863
864                        // If we're injecting a queued message, stay in Working
865                        if let Some(ref msg) = queued_msg {
866                            inner.pre_injection_content = inner.screen_contents();
867                            inner.pending_message_id = msg.message_id.clone();
868                            inner.state = ShimState::Working;
869                            inner.state_changed_at = Instant::now();
870                            inner.active_session_started_at = Instant::now();
871                            inner.test_failure_iterations = 0;
872                        }
873
874                        let queue_depth = inner.message_queue.len();
875                        let agent_type_for_enter = inner.agent_type;
876                        let queued_injected = queued_msg
877                            .as_ref()
878                            .map(|msg| format_injected_message(&msg.from, &msg.body));
879
880                        drop(inner); // release lock before I/O
881
882                        let events = build_transition_events(
883                            old_state,
884                            new,
885                            &summary,
886                            &pre_content,
887                            &current_content,
888                            &working_screen,
889                            msg_id,
890                        );
891
892                        for event in events {
893                            if evt_channel.send(&event).is_err() {
894                                return; // orchestrator disconnected
895                            }
896                        }
897
898                        // Send drain errors for terminal states
899                        for event in drain_errors {
900                            if evt_channel.send(&event).is_err() {
901                                return;
902                            }
903                        }
904
905                        // Inject queued message into PTY
906                        if let Some(msg) = queued_msg {
907                            let enter = enter_seq(agent_type_for_enter);
908                            let injected = queued_injected.as_deref().unwrap_or(msg.body.as_str());
909                            if let Err(e) = pty_write_paced(
910                                &pty_writer_pty,
911                                agent_type_for_enter,
912                                injected.as_bytes(),
913                                enter.as_bytes(),
914                            ) {
915                                let _ = evt_channel.send(&Event::Error {
916                                    command: "SendMessage".into(),
917                                    reason: format!("PTY write failed for queued message: {e}"),
918                                });
919                            }
920
921                            // Emit StateChanged Idle→Working for the queued message
922                            let _ = evt_channel.send(&Event::StateChanged {
923                                from: ShimState::Idle,
924                                to: ShimState::Working,
925                                summary: format!(
926                                    "delivering queued message ({} remaining)",
927                                    queue_depth
928                                ),
929                            });
930                        }
931                    }
932                }
933                Err(_) => break, // PTY error — agent likely exited
934            }
935        }
936
937        // Agent PTY closed — mark as dead
938        let mut inner = inner_pty.lock().unwrap();
939        let last_lines = inner.last_n_lines(10);
940        let old = inner.state;
941        inner.state = ShimState::Dead;
942
943        // Drain any remaining queued messages
944        let drain_errors = drain_queue_errors(&mut inner.message_queue, ShimState::Dead);
945        drop(inner);
946
947        let _ = evt_channel.send(&Event::StateChanged {
948            from: old,
949            to: ShimState::Dead,
950            summary: last_lines.clone(),
951        });
952
953        let _ = evt_channel.send(&Event::Died {
954            exit_code: None,
955            last_lines,
956        });
957
958        for event in drain_errors {
959            let _ = evt_channel.send(&event);
960        }
961    });
962
963    // Kiro can repaint its idle prompt before its final response bytes land.
964    // Poll for a stable idle screen after PTY output has been quiet for long
965    // enough, then emit the Working -> Idle completion transition.
966    let inner_idle = Arc::clone(&inner);
967    let pty_writer_idle = Arc::clone(&pty_writer);
968    let mut idle_channel = cmd_channel.try_clone().context("failed to clone channel")?;
969    std::thread::spawn(move || {
970        loop {
971            std::thread::sleep(std::time::Duration::from_millis(POLL_INTERVAL_MS));
972
973            let mut inner = inner_idle.lock().unwrap();
974            if inner.agent_type != AgentType::Kiro || inner.state != ShimState::Working {
975                continue;
976            }
977            if inner.last_pty_output_at.elapsed().as_millis() < KIRO_IDLE_SETTLE_MS as u128 {
978                continue;
979            }
980            if classifier::classify(inner.agent_type, inner.parser.screen())
981                != ScreenVerdict::AgentIdle
982            {
983                continue;
984            }
985
986            let summary = inner.last_n_lines(5);
987            let pre_content = inner.pre_injection_content.clone();
988            let current_content = inner.screen_contents();
989            let working_screen = inner.last_working_screen.clone();
990            let msg_id = inner.pending_message_id.take();
991            let response = completion_response(&pre_content, &current_content, &working_screen);
992
993            if let Some(followup) =
994                common::detect_test_failure_followup(&response, inner.test_failure_iterations)
995            {
996                inner.test_failure_iterations = followup.next_iteration_count;
997                inner.pre_injection_content = inner.screen_contents();
998                inner.pending_message_id = None;
999                inner.state = ShimState::Working;
1000                inner.state_changed_at = Instant::now();
1001                inner.active_session_started_at = Instant::now();
1002                let agent_type_for_enter = inner.agent_type;
1003                drop(inner);
1004
1005                let enter = enter_seq(agent_type_for_enter);
1006                if let Err(e) = pty_write_paced(
1007                    &pty_writer_idle,
1008                    agent_type_for_enter,
1009                    format_injected_message("batty", &followup.body).as_bytes(),
1010                    enter.as_bytes(),
1011                ) {
1012                    let _ = idle_channel.send(&Event::Error {
1013                        command: "SendMessage".into(),
1014                        reason: format!("PTY write failed for test failure follow-up: {e}"),
1015                    });
1016                } else {
1017                    let _ = idle_channel.send(&Event::Warning {
1018                        message: followup.notice,
1019                        idle_secs: None,
1020                    });
1021                }
1022                continue;
1023            }
1024            inner.test_failure_iterations = 0;
1025
1026            inner.state = ShimState::Idle;
1027            inner.state_changed_at = Instant::now();
1028
1029            let queued_msg = if !inner.message_queue.is_empty() {
1030                inner.message_queue.pop_front()
1031            } else {
1032                None
1033            };
1034
1035            if let Some(ref msg) = queued_msg {
1036                inner.pre_injection_content = inner.screen_contents();
1037                inner.pending_message_id = msg.message_id.clone();
1038                inner.state = ShimState::Working;
1039                inner.state_changed_at = Instant::now();
1040                inner.active_session_started_at = Instant::now();
1041                inner.test_failure_iterations = 0;
1042            }
1043
1044            let queue_depth = inner.message_queue.len();
1045            let agent_type_for_enter = inner.agent_type;
1046            let queued_injected = queued_msg
1047                .as_ref()
1048                .map(|msg| format_injected_message(&msg.from, &msg.body));
1049            drop(inner);
1050
1051            for event in build_transition_events(
1052                ShimState::Working,
1053                ShimState::Idle,
1054                &summary,
1055                &pre_content,
1056                &current_content,
1057                &working_screen,
1058                msg_id,
1059            ) {
1060                if idle_channel.send(&event).is_err() {
1061                    return;
1062                }
1063            }
1064
1065            if let Some(msg) = queued_msg {
1066                let enter = enter_seq(agent_type_for_enter);
1067                let injected = queued_injected.as_deref().unwrap_or(msg.body.as_str());
1068                if let Err(e) = pty_write_paced(
1069                    &pty_writer_idle,
1070                    agent_type_for_enter,
1071                    injected.as_bytes(),
1072                    enter.as_bytes(),
1073                ) {
1074                    let _ = idle_channel.send(&Event::Error {
1075                        command: "SendMessage".into(),
1076                        reason: format!("PTY write failed for queued message: {e}"),
1077                    });
1078                    continue;
1079                }
1080
1081                let _ = idle_channel.send(&Event::StateChanged {
1082                    from: ShimState::Idle,
1083                    to: ShimState::Working,
1084                    summary: format!("delivering queued message ({} remaining)", queue_depth),
1085                });
1086            }
1087        }
1088    });
1089
1090    // -- Periodic screen poll thread: re-classify even when PTY is quiet --
1091    // The PTY reader thread only classifies when new output arrives. If the
1092    // agent finishes and shows the idle prompt but produces no further output,
1093    // the reader blocks on read() and the state stays Working forever.
1094    // This thread polls the screen every 5 seconds to catch that case.
1095    let inner_poll = Arc::clone(&inner);
1096    let mut poll_channel = cmd_channel
1097        .try_clone()
1098        .context("failed to clone channel for poll thread")?;
1099    std::thread::spawn(move || {
1100        loop {
1101            std::thread::sleep(std::time::Duration::from_secs(5));
1102            let mut inner = inner_poll.lock().unwrap();
1103            if inner.state != ShimState::Working {
1104                continue;
1105            }
1106            // Only re-classify if PTY has been quiet for at least 2 seconds
1107            if inner.last_pty_output_at.elapsed().as_secs() < 2 {
1108                continue;
1109            }
1110            let verdict = classifier::classify(inner.agent_type, inner.parser.screen());
1111            if verdict == classifier::ScreenVerdict::AgentIdle {
1112                let summary = inner.last_n_lines(5);
1113                inner.state = ShimState::Idle;
1114                inner.state_changed_at = Instant::now();
1115                drop(inner);
1116
1117                // Emit the transition — the daemon will handle message
1118                // queue draining and completion processing.
1119                let _ = poll_channel.send(&Event::StateChanged {
1120                    from: ShimState::Working,
1121                    to: ShimState::Idle,
1122                    summary,
1123                });
1124            }
1125        }
1126    });
1127
1128    let inner_stats = Arc::clone(&inner);
1129    let mut stats_channel = cmd_channel
1130        .try_clone()
1131        .context("failed to clone channel for stats thread")?;
1132    std::thread::spawn(move || {
1133        loop {
1134            std::thread::sleep(Duration::from_secs(SESSION_STATS_INTERVAL_SECS));
1135            let inner = inner_stats.lock().unwrap();
1136            if inner.state == ShimState::Dead {
1137                return;
1138            }
1139            let output_bytes = inner.cumulative_output_bytes;
1140            let uptime_secs = match inner.state {
1141                ShimState::Working | ShimState::ContextExhausted => {
1142                    inner.active_session_started_at.elapsed().as_secs()
1143                }
1144                _ => inner.started_at.elapsed().as_secs(),
1145            };
1146            drop(inner);
1147
1148            if stats_channel
1149                .send(&Event::SessionStats {
1150                    output_bytes,
1151                    uptime_secs,
1152                    input_tokens: 0,
1153                    output_tokens: 0,
1154                    context_usage_pct: None,
1155                })
1156                .is_err()
1157            {
1158                return;
1159            }
1160        }
1161    });
1162
1163    // -- Main thread: handle commands from orchestrator --
1164    let inner_cmd = Arc::clone(&inner);
1165
1166    // Wait for Ready (Starting → Idle transition) with timeout.
1167    // During startup, auto-dismiss known dialogs (e.g., Claude's trust prompt)
1168    // by sending Enter (\r) to the PTY.
1169    let start = Instant::now();
1170    loop {
1171        let mut inner = inner_cmd.lock().unwrap();
1172        let state = inner.state;
1173        match state {
1174            ShimState::Starting => {
1175                // Auto-dismiss known startup dialogs (trust prompts, etc.)
1176                if inner.dialogs_dismissed < 10 {
1177                    let content = inner.screen_contents();
1178                    if classifier::detect_startup_dialog(&content) {
1179                        let attempt = inner.dialogs_dismissed + 1;
1180                        let enter = enter_seq(inner.agent_type);
1181                        inner.dialogs_dismissed = attempt;
1182                        drop(inner);
1183                        eprintln!(
1184                            "[shim {}] auto-dismissing startup dialog (attempt {attempt})",
1185                            args.id
1186                        );
1187                        let mut writer = pty_writer.lock().unwrap();
1188                        writer.write_all(enter.as_bytes()).ok();
1189                        writer.flush().ok();
1190                        std::thread::sleep(std::time::Duration::from_millis(POLL_INTERVAL_MS));
1191                        continue;
1192                    }
1193                }
1194                drop(inner);
1195
1196                if start.elapsed().as_secs() > READY_TIMEOUT_SECS {
1197                    let last = inner_cmd.lock().unwrap().last_n_lines(10);
1198                    cmd_channel.send(&Event::Error {
1199                        command: "startup".into(),
1200                        reason: format!(
1201                            "agent did not show prompt within {}s. Last lines:\n{}",
1202                            READY_TIMEOUT_SECS, last,
1203                        ),
1204                    })?;
1205                    terminate_agent_group(&mut child, Duration::from_secs(GROUP_TERM_GRACE_SECS))
1206                        .ok();
1207                    return Ok(());
1208                }
1209                thread::sleep(Duration::from_millis(POLL_INTERVAL_MS));
1210            }
1211            ShimState::Dead => {
1212                drop(inner);
1213                return Ok(());
1214            }
1215            ShimState::Idle => {
1216                drop(inner);
1217                cmd_channel.send(&Event::Ready)?;
1218                break;
1219            }
1220            _ => {
1221                // Working or other transitional state during startup —
1222                // agent is still loading/initializing, keep waiting.
1223                drop(inner);
1224                if start.elapsed().as_secs() > READY_TIMEOUT_SECS {
1225                    let last = inner_cmd.lock().unwrap().last_n_lines(10);
1226                    cmd_channel.send(&Event::Error {
1227                        command: "startup".into(),
1228                        reason: format!(
1229                            "agent did not reach idle within {}s (state: {}). Last lines:\n{}",
1230                            READY_TIMEOUT_SECS, state, last,
1231                        ),
1232                    })?;
1233                    terminate_agent_group(&mut child, Duration::from_secs(GROUP_TERM_GRACE_SECS))
1234                        .ok();
1235                    return Ok(());
1236                }
1237                thread::sleep(Duration::from_millis(POLL_INTERVAL_MS));
1238            }
1239        }
1240    }
1241
1242    // -- Command loop --
1243    loop {
1244        let cmd = match cmd_channel.recv::<Command>() {
1245            Ok(Some(c)) => c,
1246            Ok(None) => {
1247                eprintln!(
1248                    "[shim {}] orchestrator disconnected, shutting down",
1249                    args.id
1250                );
1251                terminate_agent_group(&mut child, Duration::from_secs(GROUP_TERM_GRACE_SECS)).ok();
1252                break;
1253            }
1254            Err(e) => {
1255                eprintln!("[shim {}] channel error: {e}", args.id);
1256                terminate_agent_group(&mut child, Duration::from_secs(GROUP_TERM_GRACE_SECS)).ok();
1257                break;
1258            }
1259        };
1260
1261        match cmd {
1262            Command::SendMessage {
1263                from,
1264                body,
1265                message_id,
1266            } => {
1267                let delivery_id = message_id.clone();
1268                let mut inner = inner_cmd.lock().unwrap();
1269                match inner.state {
1270                    ShimState::Idle => {
1271                        inner.pre_injection_content = inner.screen_contents();
1272                        inner.pending_message_id = message_id;
1273                        inner.test_failure_iterations = 0;
1274                        let agent_type = inner.agent_type;
1275                        let enter = enter_seq(agent_type);
1276                        let injected = format_injected_message(&from, &body);
1277                        drop(inner);
1278                        // Write body char-by-char with micro-delays for TUI
1279                        // agents that use synchronized output. Bulk writes
1280                        // get interleaved with screen redraws, losing chars.
1281                        if let Err(e) = pty_write_paced(
1282                            &pty_writer,
1283                            agent_type,
1284                            injected.as_bytes(),
1285                            enter.as_bytes(),
1286                        ) {
1287                            if let Some(id) = delivery_id {
1288                                cmd_channel.send(&Event::DeliveryFailed {
1289                                    id,
1290                                    reason: format!("PTY write failed: {e}"),
1291                                })?;
1292                            }
1293                            cmd_channel.send(&Event::Error {
1294                                command: "SendMessage".into(),
1295                                reason: format!("PTY write failed: {e}"),
1296                            })?;
1297                            // Restore state on failure
1298                            continue;
1299                        }
1300                        let mut inner = inner_cmd.lock().unwrap();
1301
1302                        let old = inner.state;
1303                        inner.state = ShimState::Working;
1304                        inner.state_changed_at = Instant::now();
1305                        inner.active_session_started_at = Instant::now();
1306                        let summary = inner.last_n_lines(3);
1307                        drop(inner);
1308
1309                        if let Some(id) = delivery_id {
1310                            cmd_channel.send(&Event::MessageDelivered { id })?;
1311                        }
1312                        cmd_channel.send(&Event::StateChanged {
1313                            from: old,
1314                            to: ShimState::Working,
1315                            summary,
1316                        })?;
1317                    }
1318                    ShimState::Working => {
1319                        // Queue the message for delivery when agent returns to Idle
1320                        if inner.message_queue.len() >= MAX_QUEUE_DEPTH {
1321                            let dropped = inner.message_queue.pop_front();
1322                            let dropped_id = dropped.as_ref().and_then(|m| m.message_id.clone());
1323                            inner.message_queue.push_back(QueuedMessage {
1324                                from,
1325                                body,
1326                                message_id,
1327                            });
1328                            let depth = inner.message_queue.len();
1329                            drop(inner);
1330
1331                            cmd_channel.send(&Event::Error {
1332                                command: "SendMessage".into(),
1333                                reason: format!(
1334                                    "message queue full ({MAX_QUEUE_DEPTH}), dropped oldest message{}",
1335                                    dropped_id
1336                                        .map(|id| format!(" (id: {id})"))
1337                                        .unwrap_or_default(),
1338                                ),
1339                            })?;
1340                            cmd_channel.send(&Event::Warning {
1341                                message: format!(
1342                                    "message queued while agent working (depth: {depth})"
1343                                ),
1344                                idle_secs: None,
1345                            })?;
1346                        } else {
1347                            inner.message_queue.push_back(QueuedMessage {
1348                                from,
1349                                body,
1350                                message_id,
1351                            });
1352                            let depth = inner.message_queue.len();
1353                            drop(inner);
1354
1355                            cmd_channel.send(&Event::Warning {
1356                                message: format!(
1357                                    "message queued while agent working (depth: {depth})"
1358                                ),
1359                                idle_secs: None,
1360                            })?;
1361                        }
1362                    }
1363                    other => {
1364                        cmd_channel.send(&Event::Error {
1365                            command: "SendMessage".into(),
1366                            reason: format!("agent in {other} state, cannot accept message"),
1367                        })?;
1368                    }
1369                }
1370            }
1371
1372            Command::CaptureScreen { last_n_lines } => {
1373                let inner = inner_cmd.lock().unwrap();
1374                let content = match last_n_lines {
1375                    Some(n) => inner.last_n_lines(n),
1376                    None => inner.screen_contents(),
1377                };
1378                let (row, col) = inner.cursor_position();
1379                drop(inner);
1380                cmd_channel.send(&Event::ScreenCapture {
1381                    content,
1382                    cursor_row: row,
1383                    cursor_col: col,
1384                })?;
1385            }
1386
1387            Command::GetState => {
1388                let inner = inner_cmd.lock().unwrap();
1389                let since = inner.state_changed_at.elapsed().as_secs();
1390                let state = inner.state;
1391                drop(inner);
1392                cmd_channel.send(&Event::State {
1393                    state,
1394                    since_secs: since,
1395                })?;
1396            }
1397
1398            Command::Resize { rows, cols } => {
1399                pty_pair
1400                    .master
1401                    .resize(PtySize {
1402                        rows,
1403                        cols,
1404                        pixel_width: 0,
1405                        pixel_height: 0,
1406                    })
1407                    .ok();
1408                let mut inner = inner_cmd.lock().unwrap();
1409                inner.parser.set_size(rows, cols);
1410            }
1411
1412            Command::Ping => {
1413                cmd_channel.send(&Event::Pong)?;
1414            }
1415
1416            Command::Shutdown {
1417                timeout_secs,
1418                reason,
1419            } => {
1420                eprintln!(
1421                    "[shim {}] shutdown requested ({}, timeout: {}s)",
1422                    args.id,
1423                    reason.label(),
1424                    timeout_secs
1425                );
1426                if let Err(error) = args.preserve_work_before_kill(&args.cwd) {
1427                    eprintln!(
1428                        "[shim {}] auto-save before shutdown failed: {}",
1429                        args.id, error
1430                    );
1431                }
1432                {
1433                    let mut writer = pty_writer.lock().unwrap();
1434                    writer.write_all(b"\x03").ok(); // Ctrl-C
1435                    writer.flush().ok();
1436                }
1437                let deadline = Instant::now() + Duration::from_secs(timeout_secs as u64);
1438                loop {
1439                    if Instant::now() > deadline {
1440                        terminate_agent_group(
1441                            &mut child,
1442                            Duration::from_secs(GROUP_TERM_GRACE_SECS),
1443                        )
1444                        .ok();
1445                        break;
1446                    }
1447                    if let Ok(Some(_)) = child.try_wait() {
1448                        break;
1449                    }
1450                    thread::sleep(Duration::from_millis(PROCESS_EXIT_POLL_MS));
1451                }
1452                break;
1453            }
1454
1455            Command::Kill => {
1456                if let Err(error) = args.preserve_work_before_kill(&args.cwd) {
1457                    eprintln!("[shim {}] auto-save before kill failed: {}", args.id, error);
1458                }
1459                terminate_agent_group(&mut child, Duration::from_secs(GROUP_TERM_GRACE_SECS)).ok();
1460                break;
1461            }
1462        }
1463    }
1464
1465    pty_handle.join().ok();
1466    Ok(())
1467}
1468
1469fn drain_queue_errors(
1470    queue: &mut VecDeque<QueuedMessage>,
1471    terminal_state: ShimState,
1472) -> Vec<Event> {
1473    common::drain_queue_errors(queue, terminal_state)
1474}
1475
1476// ---------------------------------------------------------------------------
1477// Build events for a state transition
1478// ---------------------------------------------------------------------------
1479
1480fn build_transition_events(
1481    from: ShimState,
1482    to: ShimState,
1483    summary: &str,
1484    pre_injection_content: &str,
1485    current_content: &str,
1486    last_working_screen: &str,
1487    message_id: Option<String>,
1488) -> Vec<Event> {
1489    let summary = sanitize_summary(summary);
1490    let mut events = vec![Event::StateChanged {
1491        from,
1492        to,
1493        summary: summary.clone(),
1494    }];
1495
1496    // Working → Idle = completion, but only if a message was actually pending.
1497    // Skip Completion for transitions caused by agent startup/loading (e.g.,
1498    // MCP server init) where no user message was injected.
1499    if from == ShimState::Working && to == ShimState::Idle && !pre_injection_content.is_empty() {
1500        let response =
1501            completion_response(pre_injection_content, current_content, last_working_screen);
1502        events.push(Event::Completion {
1503            message_id,
1504            response,
1505            last_lines: summary.clone(),
1506        });
1507    }
1508
1509    // Any → ContextExhausted
1510    if to == ShimState::ContextExhausted {
1511        events.push(Event::ContextExhausted {
1512            message: "Agent reported context exhaustion".to_string(),
1513            last_lines: summary,
1514        });
1515    }
1516
1517    events
1518}
1519
1520fn completion_response(
1521    pre_injection_content: &str,
1522    current_content: &str,
1523    last_working_screen: &str,
1524) -> String {
1525    // Try diffing against current screen first; if empty (TUI agents redraw
1526    // to idle before we capture), fall back to the last screen seen during
1527    // Working state.
1528    let mut response = extract_response(pre_injection_content, current_content);
1529    if response.is_empty() && !last_working_screen.is_empty() {
1530        response = extract_response(pre_injection_content, last_working_screen);
1531    }
1532    response
1533}
1534
1535fn sanitize_summary(summary: &str) -> String {
1536    let cleaned: Vec<String> = summary
1537        .lines()
1538        .filter_map(|line| {
1539            let trimmed = line.trim();
1540            if trimmed.is_empty() || is_tui_chrome(line) || is_prompt_line(trimmed) {
1541                return None;
1542            }
1543            Some(strip_claude_bullets(trimmed))
1544        })
1545        .collect();
1546
1547    if cleaned.is_empty() {
1548        String::new()
1549    } else {
1550        cleaned.join("\n")
1551    }
1552}
1553
1554/// Extract the agent's response by diffing pre-injection and post-completion
1555/// screen content. Strips known TUI chrome (horizontal rules, status bars,
1556/// prompt lines) so callers get clean response text.
1557fn extract_response(pre: &str, current: &str) -> String {
1558    let pre_lines: Vec<&str> = pre.lines().collect();
1559    let cur_lines: Vec<&str> = current.lines().collect();
1560
1561    let overlap = pre_lines.len().min(cur_lines.len());
1562    let mut diverge_at = 0;
1563    for i in 0..overlap {
1564        if pre_lines[i] != cur_lines[i] {
1565            break;
1566        }
1567        diverge_at = i + 1;
1568    }
1569
1570    let response_lines = &cur_lines[diverge_at..];
1571    if response_lines.is_empty() {
1572        return String::new();
1573    }
1574
1575    // Filter out TUI chrome, then strip trailing empty/prompt lines
1576    let filtered: Vec<&str> = response_lines
1577        .iter()
1578        .filter(|line| !is_tui_chrome(line))
1579        .copied()
1580        .collect();
1581
1582    if filtered.is_empty() {
1583        return String::new();
1584    }
1585
1586    // Strip trailing empty lines and prompt lines
1587    let mut end = filtered.len();
1588    while end > 0 && filtered[end - 1].trim().is_empty() {
1589        end -= 1;
1590    }
1591    while end > 0 && is_prompt_line(filtered[end - 1].trim()) {
1592        end -= 1;
1593    }
1594    while end > 0 && filtered[end - 1].trim().is_empty() {
1595        end -= 1;
1596    }
1597
1598    // Strip leading lines that echo the user's input (❯ followed by text)
1599    let mut start = 0;
1600    while start < end {
1601        let trimmed = filtered[start].trim();
1602        if trimmed.is_empty() {
1603            start += 1;
1604        } else if trimmed.starts_with('\u{276F}')
1605            && !trimmed['\u{276F}'.len_utf8()..].trim().is_empty()
1606        {
1607            // Echoed user input line
1608            start += 1;
1609        } else {
1610            break;
1611        }
1612    }
1613
1614    // Strip Claude's output bullet markers (⏺) from the start of lines
1615    let cleaned: Vec<String> = filtered[start..end]
1616        .iter()
1617        .map(|line| strip_claude_bullets(line))
1618        .collect();
1619
1620    cleaned.join("\n")
1621}
1622
1623/// Strip Claude's ⏺ (U+23FA) output bullet marker from the start of a line.
1624fn strip_claude_bullets(line: &str) -> String {
1625    let trimmed = line.trim_start();
1626    if trimmed.starts_with('\u{23FA}') {
1627        let after = &trimmed['\u{23FA}'.len_utf8()..];
1628        // Preserve original leading whitespace minus the bullet
1629        let leading = line.len() - line.trim_start().len();
1630        format!("{}{}", &" ".repeat(leading), after.trim_start())
1631    } else {
1632        line.to_string()
1633    }
1634}
1635
1636/// Detect TUI chrome lines that should be stripped from responses.
1637/// Matches horizontal rules, status bars, and other decorative elements
1638/// common in Claude, Codex, and Kiro TUI output.
1639fn is_tui_chrome(line: &str) -> bool {
1640    let trimmed = line.trim();
1641    if trimmed.is_empty() {
1642        return false; // keep empty lines (stripped separately)
1643    }
1644
1645    // Horizontal rules: lines made entirely of box-drawing characters
1646    if trimmed.chars().all(|c| {
1647        matches!(
1648            c,
1649            '─' | '━'
1650                | '═'
1651                | '╌'
1652                | '╍'
1653                | '┄'
1654                | '┅'
1655                | '╶'
1656                | '╴'
1657                | '╸'
1658                | '╺'
1659                | '│'
1660                | '┃'
1661                | '╎'
1662                | '╏'
1663                | '┊'
1664                | '┋'
1665        )
1666    }) {
1667        return true;
1668    }
1669
1670    // Claude status bar: ⏵⏵ bypass permissions, shift+tab, model info
1671    if trimmed.contains("\u{23F5}\u{23F5}") || trimmed.contains("bypass permissions") {
1672        return true;
1673    }
1674    if trimmed.contains("shift+tab") && trimmed.len() < 80 {
1675        return true;
1676    }
1677
1678    // Claude cost/token summary line
1679    if trimmed.starts_with('$') && trimmed.contains("token") {
1680        return true;
1681    }
1682
1683    // Braille art (Kiro logo, Codex box drawings) — lines with mostly braille chars
1684    let braille_count = trimmed
1685        .chars()
1686        .filter(|c| ('\u{2800}'..='\u{28FF}').contains(c))
1687        .count();
1688    if braille_count > 5 {
1689        return true;
1690    }
1691
1692    // Kiro welcome/status text
1693    let lower = trimmed.to_lowercase();
1694    if lower.contains("welcome to the new kiro") || lower.contains("/feedback command") {
1695        return true;
1696    }
1697
1698    // Kiro status bar
1699    if lower.starts_with("kiro") && lower.contains('\u{25D4}') {
1700        // "Kiro · auto · ◔ 0%"
1701        return true;
1702    }
1703
1704    // Codex welcome box
1705    if trimmed.starts_with('╭') || trimmed.starts_with('╰') || trimmed.starts_with('│') {
1706        return true;
1707    }
1708
1709    // Codex tips/warnings
1710    if lower.starts_with("tip:") || (trimmed.starts_with('⚠') && lower.contains("limit")) {
1711        return true;
1712    }
1713
1714    // Kiro/Codex prompt placeholders
1715    if lower.contains("ask a question") || lower.contains("describe a task") {
1716        return true;
1717    }
1718
1719    false
1720}
1721
1722fn is_prompt_line(line: &str) -> bool {
1723    line == "\u{276F}"
1724        || line.starts_with("\u{276F} ")
1725        || line == "\u{203A}"
1726        || line.starts_with("\u{203A} ")
1727        || line.ends_with("$ ")
1728        || line.ends_with('$')
1729        || line.ends_with("% ")
1730        || line.ends_with('%')
1731        || line == ">"
1732        || line.starts_with("Kiro>")
1733}
1734
1735// ---------------------------------------------------------------------------
1736// Tests
1737// ---------------------------------------------------------------------------
1738
1739#[cfg(test)]
1740mod tests {
1741    use super::*;
1742
1743    #[test]
1744    fn extract_response_basic() {
1745        let pre = "line1\nline2\n$ ";
1746        let cur = "line1\nline2\nhello world\n$ ";
1747        assert_eq!(extract_response(pre, cur), "hello world");
1748    }
1749
1750    #[test]
1751    fn extract_response_multiline() {
1752        let pre = "$ ";
1753        let cur = "$ echo hi\nhi\n$ ";
1754        let resp = extract_response(pre, cur);
1755        assert!(resp.contains("echo hi"));
1756        assert!(resp.contains("hi"));
1757    }
1758
1759    #[test]
1760    fn extract_response_empty() {
1761        let pre = "$ ";
1762        let cur = "$ ";
1763        assert_eq!(extract_response(pre, cur), "");
1764    }
1765
1766    #[test]
1767    fn content_hash_deterministic() {
1768        assert_eq!(content_hash("hello"), content_hash("hello"));
1769        assert_ne!(content_hash("hello"), content_hash("world"));
1770    }
1771
1772    #[test]
1773    fn shell_single_quote_escapes_embedded_quote() {
1774        assert_eq!(shell_single_quote("fix user's bug"), "fix user'\\''s bug");
1775    }
1776
1777    #[test]
1778    fn supervised_command_contains_watchdog_and_exec() {
1779        let command = build_supervised_agent_command("kiro-cli chat 'hello'", 4242);
1780        assert!(command.contains("shim_pid=4242"));
1781        assert!(command.contains("agent_root_pid=$$"));
1782        assert!(command.contains("agent_pgid=$$"));
1783        assert!(command.contains("setsid sh -c"));
1784        assert!(command.contains("shim_pid=\"$1\""));
1785        assert!(command.contains("agent_pgid=\"$2\""));
1786        assert!(command.contains("agent_root_pid=\"$3\""));
1787        assert!(command.contains("collect_descendants()"));
1788        assert!(command.contains("pgrep -P \"$parent_pid\""));
1789        assert!(command.contains("descendant_pids=$(collect_descendants \"$agent_root_pid\")"));
1790        assert!(command.contains("kill -TERM -- -\"$agent_pgid\""));
1791        assert!(command.contains("kill -TERM \"$descendant_pid\""));
1792        assert!(command.contains("kill -KILL -- -\"$agent_pgid\""));
1793        assert!(command.contains("kill -KILL \"$descendant_pid\""));
1794        assert!(command.contains("' _ \"$shim_pid\" \"$agent_pgid\" \"$agent_root_pid\""));
1795        assert!(command.contains("exec bash -lc 'kiro-cli chat '\\''hello'\\'''"));
1796    }
1797
1798    #[test]
1799    fn is_prompt_line_shell_dollar() {
1800        assert!(is_prompt_line("user@host:~$ "));
1801        assert!(is_prompt_line("$"));
1802    }
1803
1804    #[test]
1805    fn is_prompt_line_claude() {
1806        assert!(is_prompt_line("\u{276F}"));
1807        assert!(is_prompt_line("\u{276F} "));
1808    }
1809
1810    #[test]
1811    fn is_prompt_line_codex() {
1812        assert!(is_prompt_line("\u{203A}"));
1813        assert!(is_prompt_line("\u{203A} "));
1814    }
1815
1816    #[test]
1817    fn is_prompt_line_kiro() {
1818        assert!(is_prompt_line("Kiro>"));
1819        assert!(is_prompt_line(">"));
1820    }
1821
1822    #[test]
1823    fn is_prompt_line_not_prompt() {
1824        assert!(!is_prompt_line("hello world"));
1825        assert!(!is_prompt_line("some output here"));
1826    }
1827
1828    #[test]
1829    fn build_transition_events_working_to_idle() {
1830        let events = build_transition_events(
1831            ShimState::Working,
1832            ShimState::Idle,
1833            "summary",
1834            "pre\n$ ",
1835            "pre\nhello\n$ ",
1836            "",
1837            Some("msg-1".into()),
1838        );
1839        assert_eq!(events.len(), 2);
1840        assert!(matches!(&events[0], Event::StateChanged { .. }));
1841        assert!(matches!(&events[1], Event::Completion { .. }));
1842    }
1843
1844    #[test]
1845    fn completion_response_uses_last_working_screen_fallback() {
1846        let response = completion_response("pre\n$ ", "pre\n$ ", "pre\nfailed tests\n$ ");
1847        assert_eq!(response, "failed tests");
1848    }
1849
1850    #[test]
1851    fn build_transition_events_to_context_exhausted() {
1852        let events = build_transition_events(
1853            ShimState::Working,
1854            ShimState::ContextExhausted,
1855            "summary",
1856            "",
1857            "",
1858            "",
1859            None,
1860        );
1861        // StateChanged + ContextExhausted (no Completion since it's not Idle)
1862        assert_eq!(events.len(), 2);
1863        assert!(matches!(&events[1], Event::ContextExhausted { .. }));
1864    }
1865
1866    #[test]
1867    fn build_transition_events_starting_to_idle() {
1868        let events = build_transition_events(
1869            ShimState::Starting,
1870            ShimState::Idle,
1871            "summary",
1872            "",
1873            "",
1874            "",
1875            None,
1876        );
1877        assert_eq!(events.len(), 1);
1878        assert!(matches!(&events[0], Event::StateChanged { .. }));
1879    }
1880
1881    // -----------------------------------------------------------------------
1882    // Message queue tests
1883    // -----------------------------------------------------------------------
1884
1885    fn make_queued_msg(id: &str, body: &str) -> QueuedMessage {
1886        QueuedMessage {
1887            from: "user".into(),
1888            body: body.into(),
1889            message_id: Some(id.into()),
1890        }
1891    }
1892
1893    #[test]
1894    fn queue_enqueue_basic() {
1895        let mut queue: VecDeque<QueuedMessage> = VecDeque::new();
1896        queue.push_back(make_queued_msg("m1", "hello"));
1897        queue.push_back(make_queued_msg("m2", "world"));
1898        assert_eq!(queue.len(), 2);
1899    }
1900
1901    #[test]
1902    fn queue_fifo_order() {
1903        let mut queue: VecDeque<QueuedMessage> = VecDeque::new();
1904        queue.push_back(make_queued_msg("m1", "first"));
1905        queue.push_back(make_queued_msg("m2", "second"));
1906        queue.push_back(make_queued_msg("m3", "third"));
1907
1908        let msg = queue.pop_front().unwrap();
1909        assert_eq!(msg.message_id.as_deref(), Some("m1"));
1910        assert_eq!(msg.body, "first");
1911
1912        let msg = queue.pop_front().unwrap();
1913        assert_eq!(msg.message_id.as_deref(), Some("m2"));
1914        assert_eq!(msg.body, "second");
1915
1916        let msg = queue.pop_front().unwrap();
1917        assert_eq!(msg.message_id.as_deref(), Some("m3"));
1918        assert_eq!(msg.body, "third");
1919
1920        assert!(queue.is_empty());
1921    }
1922
1923    #[test]
1924    fn queue_overflow_drops_oldest() {
1925        let mut queue: VecDeque<QueuedMessage> = VecDeque::new();
1926
1927        // Fill to MAX_QUEUE_DEPTH
1928        for i in 0..MAX_QUEUE_DEPTH {
1929            queue.push_back(make_queued_msg(&format!("m{i}"), &format!("msg {i}")));
1930        }
1931        assert_eq!(queue.len(), MAX_QUEUE_DEPTH);
1932
1933        // Overflow: drop oldest, add new
1934        assert!(queue.len() >= MAX_QUEUE_DEPTH);
1935        let dropped = queue.pop_front().unwrap();
1936        assert_eq!(dropped.message_id.as_deref(), Some("m0")); // oldest dropped
1937        queue.push_back(make_queued_msg("m_new", "new message"));
1938        assert_eq!(queue.len(), MAX_QUEUE_DEPTH);
1939
1940        // First item should now be m1 (m0 was dropped)
1941        let first = queue.pop_front().unwrap();
1942        assert_eq!(first.message_id.as_deref(), Some("m1"));
1943    }
1944
1945    #[test]
1946    fn drain_queue_errors_empty() {
1947        let mut queue: VecDeque<QueuedMessage> = VecDeque::new();
1948        let events = drain_queue_errors(&mut queue, ShimState::Dead);
1949        assert!(events.is_empty());
1950    }
1951
1952    #[test]
1953    fn drain_queue_errors_with_messages() {
1954        let mut queue: VecDeque<QueuedMessage> = VecDeque::new();
1955        queue.push_back(make_queued_msg("m1", "hello"));
1956        queue.push_back(make_queued_msg("m2", "world"));
1957        queue.push_back(QueuedMessage {
1958            from: "user".into(),
1959            body: "no id".into(),
1960            message_id: None,
1961        });
1962
1963        let events = drain_queue_errors(&mut queue, ShimState::Dead);
1964        assert_eq!(events.len(), 3);
1965        assert!(queue.is_empty());
1966
1967        // All should be Error events
1968        for event in &events {
1969            assert!(matches!(event, Event::Error { .. }));
1970        }
1971
1972        // First error should mention the message_id
1973        if let Event::Error { reason, .. } = &events[0] {
1974            assert!(reason.contains("dead"));
1975            assert!(reason.contains("m1"));
1976        }
1977
1978        // Third error (no message_id) should not contain "(id:"
1979        if let Event::Error { reason, .. } = &events[2] {
1980            assert!(!reason.contains("(id:"));
1981        }
1982    }
1983
1984    #[test]
1985    fn drain_queue_errors_context_exhausted() {
1986        let mut queue: VecDeque<QueuedMessage> = VecDeque::new();
1987        queue.push_back(make_queued_msg("m1", "hello"));
1988
1989        let events = drain_queue_errors(&mut queue, ShimState::ContextExhausted);
1990        assert_eq!(events.len(), 1);
1991        if let Event::Error { reason, .. } = &events[0] {
1992            assert!(reason.contains("context_exhausted"));
1993        }
1994    }
1995
1996    #[test]
1997    fn queued_message_preserves_fields() {
1998        let msg = QueuedMessage {
1999            from: "manager".into(),
2000            body: "do this task".into(),
2001            message_id: Some("msg-42".into()),
2002        };
2003        assert_eq!(msg.from, "manager");
2004        assert_eq!(msg.body, "do this task");
2005        assert_eq!(msg.message_id.as_deref(), Some("msg-42"));
2006    }
2007
2008    #[test]
2009    fn queued_message_none_id() {
2010        let msg = QueuedMessage {
2011            from: "user".into(),
2012            body: "anonymous".into(),
2013            message_id: None,
2014        };
2015        assert!(msg.message_id.is_none());
2016    }
2017
2018    #[test]
2019    fn max_queue_depth_is_16() {
2020        assert_eq!(MAX_QUEUE_DEPTH, 16);
2021    }
2022
2023    #[test]
2024    fn format_injected_message_includes_sender_and_reply_target() {
2025        let formatted = format_injected_message("human", "what is 2+2?");
2026        assert!(formatted.contains("--- Message from human ---"));
2027        assert!(formatted.contains("Reply-To: human"));
2028        assert!(formatted.contains("batty send human"));
2029        assert!(formatted.ends_with("what is 2+2?"));
2030    }
2031
2032    #[test]
2033    fn format_injected_message_uses_sender_as_reply_target() {
2034        let formatted = format_injected_message("manager", "status?");
2035        assert!(formatted.contains("Reply-To: manager"));
2036        assert!(formatted.contains("batty send manager"));
2037    }
2038
2039    #[test]
2040    fn sanitize_summary_strips_tui_chrome_and_prompt_lines() {
2041        let summary = "────────────────────\n❯ \n  ⏵⏵ bypass permissions on\nThe answer is 4\n";
2042        assert_eq!(sanitize_summary(summary), "The answer is 4");
2043    }
2044
2045    #[test]
2046    fn sanitize_summary_keeps_multiline_meaningful_content() {
2047        let summary = "  Root cause: stale resume id\n\n  Fix: retry with fresh start\n";
2048        assert_eq!(
2049            sanitize_summary(summary),
2050            "Root cause: stale resume id\nFix: retry with fresh start"
2051        );
2052    }
2053
2054    // -----------------------------------------------------------------------
2055    // TUI chrome stripping tests
2056    // -----------------------------------------------------------------------
2057
2058    #[test]
2059    fn is_tui_chrome_horizontal_rule() {
2060        assert!(is_tui_chrome("────────────────────────────────────"));
2061        assert!(is_tui_chrome("  ─────────  "));
2062        assert!(is_tui_chrome("━━━━━━━━━━━━━━━━━━━━"));
2063    }
2064
2065    #[test]
2066    fn is_tui_chrome_status_bar() {
2067        assert!(is_tui_chrome(
2068            "  \u{23F5}\u{23F5} bypass permissions on (shift+tab to toggle)"
2069        ));
2070        assert!(is_tui_chrome("  bypass permissions on"));
2071        assert!(is_tui_chrome("  shift+tab"));
2072    }
2073
2074    #[test]
2075    fn is_tui_chrome_cost_line() {
2076        assert!(is_tui_chrome("$0.01 · 2.3k tokens"));
2077    }
2078
2079    #[test]
2080    fn is_tui_chrome_not_content() {
2081        assert!(!is_tui_chrome("Hello, world!"));
2082        assert!(!is_tui_chrome("The answer is 4"));
2083        assert!(!is_tui_chrome("")); // empty lines are not chrome
2084        assert!(!is_tui_chrome("  some output  "));
2085    }
2086
2087    #[test]
2088    fn extract_response_strips_chrome() {
2089        let pre = "idle screen\n\u{276F} ";
2090        let cur = "\u{276F} Hello\n\nThe answer is 42\n\n\
2091                   ────────────────────\n\
2092                   \u{23F5}\u{23F5} bypass permissions on\n\
2093                   \u{276F} ";
2094        let resp = extract_response(pre, cur);
2095        assert!(resp.contains("42"), "should contain the answer: {resp}");
2096        assert!(
2097            !resp.contains("────"),
2098            "should strip horizontal rule: {resp}"
2099        );
2100        assert!(!resp.contains("bypass"), "should strip status bar: {resp}");
2101    }
2102
2103    #[test]
2104    fn extract_response_strips_echoed_input() {
2105        let pre = "\u{276F} ";
2106        let cur = "\u{276F} What is 2+2?\n\n4\n\n\u{276F} ";
2107        let resp = extract_response(pre, cur);
2108        assert!(resp.contains('4'), "should contain answer: {resp}");
2109        assert!(
2110            !resp.contains("What is 2+2"),
2111            "should strip echoed input: {resp}"
2112        );
2113    }
2114
2115    #[test]
2116    fn extract_response_tui_full_rewrite() {
2117        // Simulate Claude TUI where entire screen changes
2118        let pre = "Welcome to Claude\n\n\u{276F} ";
2119        let cur = "\u{276F} Hello\n\nHello! How can I help?\n\n\
2120                   ────────────────────\n\
2121                   \u{276F} ";
2122        let resp = extract_response(pre, cur);
2123        assert!(
2124            resp.contains("Hello! How can I help?"),
2125            "should extract response from TUI rewrite: {resp}"
2126        );
2127    }
2128
2129    #[test]
2130    fn strip_claude_bullets_removes_marker() {
2131        assert_eq!(strip_claude_bullets("\u{23FA} 4"), "4");
2132        assert_eq!(
2133            strip_claude_bullets("  \u{23FA} hello world"),
2134            "  hello world"
2135        );
2136        assert_eq!(strip_claude_bullets("no bullet here"), "no bullet here");
2137        assert_eq!(strip_claude_bullets(""), "");
2138    }
2139
2140    #[test]
2141    fn extract_response_strips_claude_bullets() {
2142        let pre = "\u{276F} ";
2143        let cur = "\u{276F} question\n\n\u{23FA} 42\n\n\u{276F} ";
2144        let resp = extract_response(pre, cur);
2145        assert!(resp.contains("42"), "should contain answer: {resp}");
2146        assert!(
2147            !resp.contains('\u{23FA}'),
2148            "should strip bullet marker: {resp}"
2149        );
2150    }
2151
2152    #[test]
2153    fn preserve_handoff_writes_diff_and_commit_summary() {
2154        let repo = tempfile::tempdir().unwrap();
2155        init_test_git_repo(repo.path());
2156        let events_dir = repo.path().join(".batty").join("team_config");
2157        std::fs::create_dir_all(&events_dir).unwrap();
2158
2159        std::fs::write(repo.path().join("tracked.txt"), "one\n").unwrap();
2160        run_test_git(repo.path(), &["add", "tracked.txt"]);
2161        run_test_git(repo.path(), &["commit", "-m", "initial commit"]);
2162        std::fs::write(repo.path().join("tracked.txt"), "one\ntwo\n").unwrap();
2163
2164        let task_id = "42";
2165        let event_lines = [
2166            serde_json::to_string(&crate::team::events::TeamEvent::task_assigned(
2167                "eng-1", task_id,
2168            ))
2169            .unwrap(),
2170            serde_json::to_string(&crate::team::events::TeamEvent::agent_restarted(
2171                "eng-1",
2172                task_id,
2173                "context_exhausted",
2174                1,
2175            ))
2176            .unwrap(),
2177        ];
2178        std::fs::write(events_dir.join("events.jsonl"), event_lines.join("\n")).unwrap();
2179
2180        let recent_output = "\
2181running cargo test --lib\n\
2182test result: ok\n\
2183editing src/lib.rs\n";
2184        let task = crate::task::Task {
2185            id: 42,
2186            title: "resume widget".to_string(),
2187            status: "in-progress".to_string(),
2188            priority: "high".to_string(),
2189            claimed_by: Some("eng-1".to_string()),
2190            claimed_at: None,
2191            claim_ttl_secs: None,
2192            claim_expires_at: None,
2193            last_progress_at: None,
2194            claim_warning_sent_at: None,
2195            claim_extensions: None,
2196            last_output_bytes: None,
2197            blocked: None,
2198            tags: Vec::new(),
2199            depends_on: Vec::new(),
2200            review_owner: None,
2201            blocked_on: None,
2202            worktree_path: None,
2203            branch: None,
2204            commit: None,
2205            artifacts: Vec::new(),
2206            next_action: Some("Run the Rust tests and finish the restart handoff.".to_string()),
2207            scheduled_for: None,
2208            cron_schedule: None,
2209            cron_last_run: None,
2210            completed: None,
2211            description: "Continue widget implementation.".to_string(),
2212            batty_config: None,
2213            source_path: repo.path().join("task-42.md"),
2214        };
2215        preserve_handoff(repo.path(), &task, Some(recent_output)).unwrap();
2216
2217        let handoff = std::fs::read_to_string(repo.path().join(HANDOFF_FILE_NAME)).unwrap();
2218        assert!(handoff.contains("# Carry-Forward Summary"));
2219        assert!(handoff.contains("## Task Spec"));
2220        assert!(handoff.contains("Task #42: resume widget"));
2221        assert!(handoff.contains("## Work Completed So Far"));
2222        assert!(handoff.contains("### Branch"));
2223        assert!(handoff.contains("master") || handoff.contains("main"));
2224        assert!(handoff.contains("### Last Commit"));
2225        assert!(handoff.contains("### Changed Files"));
2226        assert!(handoff.contains("tracked.txt"));
2227        assert!(handoff.contains("### Tests Run"));
2228        assert!(handoff.contains("cargo test --lib"));
2229        assert!(handoff.contains("### Progress Summary"));
2230        assert!(handoff.contains("task_assigned"));
2231        assert!(handoff.contains("agent_restarted: context_exhausted"));
2232        assert!(handoff.contains("### Recent Activity"));
2233        assert!(handoff.contains("editing src/lib.rs"));
2234        assert!(handoff.contains("### Recent Commits"));
2235        assert!(handoff.contains("initial commit"));
2236        assert!(handoff.contains("## What Remains"));
2237        assert!(handoff.contains("Run the Rust tests and finish the restart handoff."));
2238    }
2239
2240    #[test]
2241    fn preserve_handoff_uses_none_when_repo_has_no_changes_or_commits() {
2242        let repo = tempfile::tempdir().unwrap();
2243        init_test_git_repo(repo.path());
2244
2245        let task = crate::task::Task {
2246            id: 7,
2247            title: "empty repo".to_string(),
2248            status: "in-progress".to_string(),
2249            priority: "low".to_string(),
2250            claimed_by: Some("eng-1".to_string()),
2251            claimed_at: None,
2252            claim_ttl_secs: None,
2253            claim_expires_at: None,
2254            last_progress_at: None,
2255            claim_warning_sent_at: None,
2256            claim_extensions: None,
2257            last_output_bytes: None,
2258            blocked: None,
2259            tags: Vec::new(),
2260            depends_on: Vec::new(),
2261            review_owner: None,
2262            blocked_on: None,
2263            worktree_path: None,
2264            branch: None,
2265            commit: None,
2266            artifacts: Vec::new(),
2267            next_action: None,
2268            scheduled_for: None,
2269            cron_schedule: None,
2270            cron_last_run: None,
2271            completed: None,
2272            description: "No changes yet.".to_string(),
2273            batty_config: None,
2274            source_path: repo.path().join("task-7.md"),
2275        };
2276        preserve_handoff(repo.path(), &task, None).unwrap();
2277
2278        let handoff = std::fs::read_to_string(repo.path().join(HANDOFF_FILE_NAME)).unwrap();
2279        assert!(handoff.contains("### Changed Files\n(none)"));
2280        assert!(handoff.contains("### Tests Run\n(none)"));
2281        assert!(handoff.contains("### Recent Activity\n(none)"));
2282        assert!(handoff.contains("### Recent Commits\n(none)"));
2283        assert!(handoff.contains("## What Remains"));
2284    }
2285
2286    #[test]
2287    fn extract_test_commands_deduplicates_known_test_invocations() {
2288        let output = "\
2289\u{1b}[31mcargo test --lib\u{1b}[0m\n\
2290pytest tests/test_api.py\n\
2291cargo test --lib\n\
2292plain output\n";
2293        let tests = extract_test_commands(output);
2294        assert_eq!(
2295            tests,
2296            vec![
2297                "cargo test --lib".to_string(),
2298                "pytest tests/test_api.py".to_string()
2299            ]
2300        );
2301    }
2302
2303    #[test]
2304    fn preserve_work_before_kill_respects_config_toggle() {
2305        let tmp = tempfile::tempdir().unwrap();
2306        let preserved =
2307            preserve_work_before_kill_with(tmp.path(), Duration::from_millis(10), false, |_path| {
2308                panic!("commit should not run when disabled")
2309            })
2310            .unwrap();
2311
2312        assert!(!preserved);
2313    }
2314
2315    #[test]
2316    fn preserve_work_before_kill_times_out() {
2317        let tmp = tempfile::tempdir().unwrap();
2318        let preserved =
2319            preserve_work_before_kill_with(tmp.path(), Duration::from_millis(10), true, |_path| {
2320                std::thread::sleep(Duration::from_millis(50));
2321                Ok(true)
2322            })
2323            .unwrap();
2324
2325        assert!(!preserved);
2326    }
2327
2328    fn init_test_git_repo(path: &Path) {
2329        run_test_git(path, &["init"]);
2330        run_test_git(path, &["config", "user.name", "Batty Tests"]);
2331        run_test_git(path, &["config", "user.email", "batty-tests@example.com"]);
2332    }
2333
2334    fn run_test_git(path: &Path, args: &[&str]) {
2335        use std::process::Command;
2336        let output = Command::new("git")
2337            .args(args)
2338            .current_dir(path)
2339            .output()
2340            .unwrap();
2341        assert!(
2342            output.status.success(),
2343            "git {} failed: {}",
2344            args.join(" "),
2345            String::from_utf8_lossy(&output.stderr)
2346        );
2347    }
2348}