Skip to main content

batty_cli/team/
watcher.rs

1//! Disk-based session monitoring — polls agent output via tmux capture-pane.
2//!
3//! Detects agent completion, crashes, and staleness by periodically capturing
4//! pane output and checking for state changes. For Codex and Claude, this also
5//! tails their on-disk session JSONL data to reduce false classifications from
6//! stale pane text.
7
8use anyhow::Result;
9use serde_json::Value;
10use std::fs::{self, File};
11use std::io::{BufRead, BufReader, Seek, SeekFrom};
12use std::path::{Path, PathBuf};
13use std::time::Instant;
14
15use crate::tmux;
16
17/// State of a watched agent session.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum WatcherState {
20    /// Agent is actively producing output.
21    Active,
22    /// Agent CLI has started and is showing its input prompt, ready for messages.
23    /// Transitional state between pane creation and first Idle classification.
24    Ready,
25    /// No agent running in pane (idle / waiting for assignment).
26    Idle,
27    /// The tmux pane no longer exists or its process has exited.
28    PaneDead,
29    /// Agent reported that its conversation/session is too large to continue.
30    ContextExhausted,
31}
32
33pub struct SessionWatcher {
34    pub pane_id: String,
35    #[allow(dead_code)] // Useful for diagnostics; currently the map key is used instead.
36    pub member_name: String,
37    pub state: WatcherState,
38    completion_observed: bool,
39    last_output_hash: u64,
40    last_capture: String,
41    /// Timestamp of the last time pane output changed (hash differed from previous poll).
42    last_output_changed_at: Instant,
43    tracker: Option<SessionTracker>,
44    /// Whether the agent prompt has been observed at least once since creation
45    /// or last `activate()`. False means the pane may not be ready for messages.
46    ready_confirmed: bool,
47}
48
49#[derive(Debug, Clone, Default, PartialEq, Eq)]
50pub struct CodexQualitySignals {
51    pub last_response_chars: Option<usize>,
52    pub shortening_streak: u32,
53    pub repeated_output_streak: u32,
54    pub shrinking_responses: bool,
55    pub repeated_identical_outputs: bool,
56    pub tool_failure_message: Option<String>,
57}
58
59#[derive(Debug, Clone)]
60pub enum SessionTrackerConfig {
61    Codex { cwd: PathBuf },
62    Claude { cwd: PathBuf },
63}
64
65enum SessionTracker {
66    Codex(CodexSessionTracker),
67    Claude(ClaudeSessionTracker),
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71enum TrackerKind {
72    None,
73    Codex,
74    Claude,
75}
76
77struct CodexSessionTracker {
78    sessions_root: PathBuf,
79    cwd: PathBuf,
80    session_id: Option<String>,
81    session_file: Option<PathBuf>,
82    offset: u64,
83    quality: CodexQualitySignals,
84    last_response_hash: Option<u64>,
85}
86
87struct ClaudeSessionTracker {
88    projects_root: PathBuf,
89    cwd: PathBuf,
90    session_id: Option<String>,
91    session_file: Option<PathBuf>,
92    offset: u64,
93    last_state: TrackerState,
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97enum TrackerState {
98    Active,
99    Idle,
100    Completed,
101    Unknown,
102}
103
104impl SessionWatcher {
105    pub fn new(
106        pane_id: &str,
107        member_name: &str,
108        _stale_secs: u64,
109        tracker: Option<SessionTrackerConfig>,
110    ) -> Self {
111        Self {
112            pane_id: pane_id.to_string(),
113            member_name: member_name.to_string(),
114            state: WatcherState::Idle,
115            completion_observed: false,
116            last_output_hash: 0,
117            last_capture: String::new(),
118            last_output_changed_at: Instant::now(),
119            ready_confirmed: false,
120            tracker: tracker.map(|tracker| match tracker {
121                SessionTrackerConfig::Codex { cwd } => SessionTracker::Codex(CodexSessionTracker {
122                    sessions_root: default_codex_sessions_root(),
123                    cwd,
124                    session_id: None,
125                    session_file: None,
126                    offset: 0,
127                    quality: CodexQualitySignals::default(),
128                    last_response_hash: None,
129                }),
130                SessionTrackerConfig::Claude { cwd } => {
131                    SessionTracker::Claude(ClaudeSessionTracker {
132                        projects_root: default_claude_projects_root(),
133                        cwd,
134                        session_id: None,
135                        session_file: None,
136                        offset: 0,
137                        last_state: TrackerState::Unknown,
138                    })
139                }
140            }),
141        }
142    }
143
144    /// Poll the pane and update state.
145    pub fn poll(&mut self) -> Result<WatcherState> {
146        // Check if pane still exists
147        if !tmux::pane_exists(&self.pane_id) {
148            self.state = WatcherState::PaneDead;
149            return Ok(self.state);
150        }
151
152        // Check if pane process died
153        if tmux::pane_dead(&self.pane_id).unwrap_or(false) {
154            self.state = WatcherState::PaneDead;
155            return Ok(self.state);
156        }
157
158        // If idle or ready, peek at the pane to detect if the agent started working.
159        // This lets the watcher self-heal without requiring explicit activation
160        // from the daemon whenever a nudge, standup, or external input arrives.
161        if matches!(self.state, WatcherState::Idle | WatcherState::Ready) {
162            let capture = match tmux::capture_pane(&self.pane_id) {
163                Ok(capture) => capture,
164                Err(_) => {
165                    self.state = WatcherState::PaneDead;
166                    return Ok(self.state);
167                }
168            };
169            if detect_context_exhausted(&capture) {
170                self.last_capture = capture;
171                self.state = WatcherState::ContextExhausted;
172                return Ok(self.state);
173            }
174            let screen_state = classify_capture_state(&capture);
175            // When we first see the agent prompt, confirm readiness.
176            if screen_state == ScreenState::Idle && !self.ready_confirmed {
177                self.ready_confirmed = true;
178                self.last_capture = capture;
179                self.state = WatcherState::Ready;
180                return Ok(self.state);
181            }
182            let tracker_state = self.poll_tracker().unwrap_or(TrackerState::Unknown);
183            self.completion_observed = tracker_state == TrackerState::Completed;
184            let tracker_kind = self.tracker_kind();
185            if !capture.is_empty() {
186                self.last_capture = capture;
187                let next_state =
188                    next_state_after_capture(tracker_kind, screen_state, tracker_state, self.state);
189                if next_state != WatcherState::Idle || self.ready_confirmed {
190                    self.last_output_hash = simple_hash(&self.last_capture);
191                    self.last_output_changed_at = Instant::now();
192                    self.state = next_state;
193                }
194            }
195            return Ok(self.state);
196        }
197
198        // Capture current pane content
199        let capture = match tmux::capture_pane(&self.pane_id) {
200            Ok(capture) => capture,
201            Err(_) => {
202                self.state = WatcherState::PaneDead;
203                return Ok(self.state);
204            }
205        };
206        if detect_context_exhausted(&capture) {
207            self.last_capture = capture;
208            self.state = WatcherState::ContextExhausted;
209            return Ok(self.state);
210        }
211        let hash = simple_hash(&capture);
212        let screen_state = classify_capture_state(&capture);
213        let tracker_state = self.poll_tracker().unwrap_or(TrackerState::Unknown);
214        self.completion_observed = tracker_state == TrackerState::Completed;
215        let tracker_kind = self.tracker_kind();
216
217        if hash != self.last_output_hash {
218            self.last_output_hash = hash;
219            self.last_output_changed_at = Instant::now();
220            self.last_capture = capture;
221            self.state =
222                next_state_after_capture(tracker_kind, screen_state, tracker_state, self.state);
223        } else {
224            self.last_capture = capture;
225            self.state =
226                next_state_after_capture(tracker_kind, screen_state, tracker_state, self.state);
227        }
228
229        Ok(self.state)
230    }
231
232    /// Whether this agent's pane has been confirmed ready for message delivery.
233    ///
234    /// Returns `true` once the agent prompt has been observed at least once since
235    /// the watcher was created or last activated. This prevents injecting messages
236    /// into panes where the agent CLI hasn't finished starting.
237    pub fn is_ready_for_delivery(&self) -> bool {
238        self.ready_confirmed
239    }
240
241    /// Externally confirm that the agent pane is ready (e.g. from a delivery
242    /// readiness check that observed the prompt). Updates state to Ready if it
243    /// was still in the initial Idle state before first readiness confirmation.
244    pub fn confirm_ready(&mut self) {
245        let was_unconfirmed = !self.ready_confirmed;
246        self.ready_confirmed = true;
247        if was_unconfirmed && self.state == WatcherState::Idle {
248            self.state = WatcherState::Ready;
249        }
250    }
251
252    /// Mark this watcher as actively working.
253    pub fn activate(&mut self) {
254        self.state = WatcherState::Active;
255        self.completion_observed = false;
256        self.last_output_hash = 0;
257        self.last_output_changed_at = Instant::now();
258        // A message was just injected so the pane was confirmed ready.
259        self.ready_confirmed = true;
260        if let Some(tracker) = self.tracker.as_mut() {
261            match tracker {
262                SessionTracker::Codex(codex) => {
263                    codex.session_file = None;
264                    codex.offset = 0;
265                    codex.quality = CodexQualitySignals::default();
266                    codex.last_response_hash = None;
267                }
268                SessionTracker::Claude(claude) => {
269                    claude.session_file = None;
270                    claude.offset = 0;
271                    claude.last_state = TrackerState::Unknown;
272                }
273            }
274        }
275    }
276
277    pub fn set_session_id(&mut self, session_id: Option<String>) {
278        if let Some(tracker) = self.tracker.as_mut() {
279            match tracker {
280                SessionTracker::Codex(codex) => {
281                    if codex.session_id == session_id {
282                        return;
283                    }
284                    self.completion_observed = false;
285                    codex.session_id = session_id;
286                    codex.session_file = None;
287                    codex.offset = 0;
288                    codex.quality = CodexQualitySignals::default();
289                    codex.last_response_hash = None;
290                }
291                SessionTracker::Claude(claude) => {
292                    if claude.session_id == session_id {
293                        return;
294                    }
295                    self.completion_observed = false;
296                    claude.session_id = session_id;
297                    claude.session_file = None;
298                    claude.offset = 0;
299                    claude.last_state = TrackerState::Unknown;
300                }
301            }
302        }
303    }
304
305    /// Mark this watcher as idle.
306    pub fn deactivate(&mut self) {
307        self.state = WatcherState::Idle;
308        self.completion_observed = false;
309    }
310
311    /// Seconds since the last time pane output changed.
312    pub fn secs_since_last_output_change(&self) -> u64 {
313        self.last_output_changed_at.elapsed().as_secs()
314    }
315
316    /// Get the last captured pane output.
317    #[allow(dead_code)] // Standup/reporting helpers can read the full capture directly when needed.
318    pub fn last_output(&self) -> &str {
319        &self.last_capture
320    }
321
322    /// Get the last N lines of captured output.
323    pub fn last_lines(&self, n: usize) -> String {
324        let lines: Vec<&str> = self.last_capture.lines().collect();
325        let start = lines.len().saturating_sub(n);
326        lines[start..].join("\n")
327    }
328
329    pub fn current_session_id(&self) -> Option<String> {
330        match self.tracker.as_ref() {
331            Some(SessionTracker::Codex(codex)) => session_file_id(codex.session_file.as_ref()),
332            Some(SessionTracker::Claude(claude)) => session_file_id(claude.session_file.as_ref()),
333            None => None,
334        }
335    }
336
337    pub fn current_session_size_bytes(&self) -> Option<u64> {
338        let path = match self.tracker.as_ref() {
339            Some(SessionTracker::Codex(codex)) => codex.session_file.as_ref(),
340            Some(SessionTracker::Claude(claude)) => claude.session_file.as_ref(),
341            None => None,
342        }?;
343        fs::metadata(path).ok().map(|metadata| metadata.len())
344    }
345
346    pub fn codex_quality_signals(&self) -> Option<CodexQualitySignals> {
347        match self.tracker.as_ref() {
348            Some(SessionTracker::Codex(codex)) => Some(codex.quality.clone()),
349            _ => None,
350        }
351    }
352
353    pub fn take_completion_event(&mut self) -> bool {
354        let observed = self.completion_observed;
355        self.completion_observed = false;
356        observed
357    }
358
359    fn poll_tracker(&mut self) -> Result<TrackerState> {
360        let current_state = self.state;
361        let Some(tracker) = self.tracker.as_mut() else {
362            return Ok(TrackerState::Unknown);
363        };
364
365        match tracker {
366            SessionTracker::Codex(codex) => {
367                if codex.session_file.is_none() {
368                    codex.session_file = discover_codex_session_file(
369                        &codex.sessions_root,
370                        &codex.cwd,
371                        codex.session_id.as_deref(),
372                    )?;
373                    if let Some(session_file) = codex.session_file.as_ref() {
374                        codex.offset = current_file_len(session_file)?;
375                    }
376                    codex.quality = CodexQualitySignals::default();
377                    codex.last_response_hash = None;
378                    return Ok(TrackerState::Unknown);
379                }
380
381                let Some(session_file) = codex.session_file.clone() else {
382                    return Ok(TrackerState::Unknown);
383                };
384
385                if !session_file.exists() {
386                    codex.session_file = None;
387                    codex.offset = 0;
388                    codex.quality = CodexQualitySignals::default();
389                    codex.last_response_hash = None;
390                    return Ok(TrackerState::Unknown);
391                }
392
393                let state = poll_codex_session_file(
394                    &session_file,
395                    &mut codex.offset,
396                    &mut codex.quality,
397                    &mut codex.last_response_hash,
398                )?;
399
400                // When idle with no new events, check if a newer session file
401                // appeared (agent started a new task). Re-discover so the
402                // tracker picks up the latest session.
403                if state == TrackerState::Unknown
404                    && matches!(current_state, WatcherState::Idle | WatcherState::Ready)
405                {
406                    if let Some(latest) = discover_codex_session_file(
407                        &codex.sessions_root,
408                        &codex.cwd,
409                        codex.session_id.as_deref(),
410                    )? {
411                        if latest != session_file {
412                            codex.session_file = Some(latest.clone());
413                            codex.offset = 0;
414                            codex.quality = CodexQualitySignals::default();
415                            codex.last_response_hash = None;
416                            return poll_codex_session_file(
417                                &latest,
418                                &mut codex.offset,
419                                &mut codex.quality,
420                                &mut codex.last_response_hash,
421                            );
422                        }
423                    }
424                }
425
426                Ok(state)
427            }
428            SessionTracker::Claude(claude) => poll_claude_session(claude),
429        }
430    }
431
432    fn tracker_kind(&self) -> TrackerKind {
433        match self.tracker {
434            Some(SessionTracker::Codex(_)) => TrackerKind::Codex,
435            Some(SessionTracker::Claude(_)) => TrackerKind::Claude,
436            None => TrackerKind::None,
437        }
438    }
439}
440
441/// Check if the captured pane output shows an idle prompt.
442///
443/// This covers Claude's `❯` prompt, a shell prompt, and Codex's `›` composer.
444///
445/// Claude Code always renders `❯` at the bottom of the screen — even while
446/// actively working.  The reliable differentiator is the status bar at the
447/// very bottom: when Claude is processing, it appends `· esc to interrupt`.
448/// If we detect that indicator we return `false` immediately.
449pub fn is_at_agent_prompt(capture: &str) -> bool {
450    // Use 12 non-empty lines to account for Claude's separators and status
451    // bar pushing the prompt further up than a tight tail window.
452    let trimmed = recent_non_empty_lines(capture, 12);
453
454    // Claude Code shows "esc to interrupt" in the current bottom status bar
455    // while working. Restrict this check to the raw bottom window so older
456    // non-empty lines higher in the transcript do not pin the watcher active.
457    for line in &recent_lines(capture, 6) {
458        if is_live_interrupt_footer(line) {
459            return false;
460        }
461    }
462
463    for line in &trimmed {
464        let l = line.trim();
465        // Claude Code idle prompt
466        if starts_with_agent_prompt(l, '❯') {
467            return true;
468        }
469        // Codex idle composer prompt
470        if starts_with_agent_prompt(l, '›') {
471            return true;
472        }
473        // Kiro idle prompt
474        if looks_like_kiro_prompt(l) {
475            return true;
476        }
477        // Fell back to shell
478        if l.ends_with("$ ") || l == "$" {
479            return true;
480        }
481    }
482    false
483}
484
485fn starts_with_agent_prompt(line: &str, prompt: char) -> bool {
486    let Some(rest) = line.strip_prefix(prompt) else {
487        return false;
488    };
489    rest.is_empty()
490        || rest
491            .chars()
492            .next()
493            .map(char::is_whitespace)
494            .unwrap_or(false)
495}
496
497fn looks_like_kiro_prompt(line: &str) -> bool {
498    matches!(line, "Kiro>" | "kiro>" | "Kiro >" | "kiro >" | ">")
499}
500
501#[derive(Debug, Clone, Copy, PartialEq, Eq)]
502enum ScreenState {
503    Active,
504    Idle,
505    ContextExhausted,
506    Unknown,
507}
508
509fn recent_non_empty_lines(capture: &str, limit: usize) -> Vec<&str> {
510    capture
511        .lines()
512        .rev()
513        .filter(|l| !l.trim().is_empty())
514        .take(limit)
515        .collect()
516}
517
518fn recent_lines(capture: &str, limit: usize) -> Vec<&str> {
519    capture.lines().rev().take(limit).collect()
520}
521
522fn classify_capture_state(capture: &str) -> ScreenState {
523    let trimmed = recent_non_empty_lines(capture, 12);
524
525    if recent_lines(capture, 6)
526        .iter()
527        .any(|line| is_live_interrupt_footer(line))
528    {
529        return ScreenState::Active;
530    }
531
532    if capture_contains_context_exhaustion(capture) {
533        return ScreenState::ContextExhausted;
534    }
535
536    if is_at_agent_prompt(capture) {
537        return ScreenState::Idle;
538    }
539
540    if trimmed
541        .iter()
542        .any(|line| looks_like_claude_spinner_status(line))
543    {
544        return ScreenState::Active;
545    }
546
547    if trimmed
548        .iter()
549        .any(|line| looks_like_kiro_spinner_status(line))
550    {
551        return ScreenState::Active;
552    }
553
554    ScreenState::Unknown
555}
556
557fn detect_context_exhausted(capture: &str) -> bool {
558    capture_contains_context_exhaustion(capture)
559}
560
561fn looks_like_claude_spinner_status(line: &str) -> bool {
562    let trimmed = line.trim();
563    let Some(first) = trimmed.chars().next() else {
564        return false;
565    };
566    matches!(first, '·' | '✢' | '✳' | '✶' | '✻' | '✽')
567        && (trimmed.contains('…') || trimmed.contains("(thinking"))
568}
569
570fn looks_like_kiro_spinner_status(line: &str) -> bool {
571    let trimmed = line.trim().to_ascii_lowercase();
572    (trimmed.contains("kiro") || trimmed.contains("agent"))
573        && (trimmed.contains("thinking")
574            || trimmed.contains("planning")
575            || trimmed.contains("applying")
576            || trimmed.contains("working"))
577}
578
579fn is_live_interrupt_footer(line: &str) -> bool {
580    let trimmed = line.trim();
581    trimmed.contains("esc to interrupt")
582        || trimmed.contains("esc to inter")
583        || trimmed.contains("esc to in…")
584        || trimmed.contains("esc to in...")
585}
586
587fn capture_contains_context_exhaustion(capture: &str) -> bool {
588    let lowered = capture.to_ascii_lowercase();
589    lowered.contains("context window exceeded")
590        || lowered.contains("context window is full")
591        || lowered.contains("conversation is too long")
592        || lowered.contains("maximum context length")
593        || lowered.contains("context limit reached")
594        || lowered.contains("truncated due to context limit")
595        || lowered.contains("input exceeds the model")
596        || lowered.contains("prompt is too long")
597}
598
599fn next_state_after_capture(
600    tracker_kind: TrackerKind,
601    screen_state: ScreenState,
602    tracker_state: TrackerState,
603    previous_state: WatcherState,
604) -> WatcherState {
605    if screen_state == ScreenState::ContextExhausted {
606        return WatcherState::ContextExhausted;
607    }
608
609    if tracker_kind == TrackerKind::Claude {
610        match screen_state {
611            // Claude's live pane state is more reliable than session logs when
612            // multiple matching JSONL files exist. A visible spinner or
613            // interrupt bar means working; a clean prompt with neither means
614            // idle, even if an old session file still looks active.
615            ScreenState::Active => return WatcherState::Active,
616            ScreenState::Idle => return WatcherState::Idle,
617            ScreenState::ContextExhausted => return WatcherState::ContextExhausted,
618            ScreenState::Unknown => {}
619        }
620    }
621
622    match tracker_state {
623        TrackerState::Active => return WatcherState::Active,
624        TrackerState::Idle | TrackerState::Completed => return WatcherState::Idle,
625        TrackerState::Unknown => {}
626    }
627
628    match screen_state {
629        ScreenState::Active => WatcherState::Active,
630        ScreenState::Idle => WatcherState::Idle,
631        ScreenState::ContextExhausted => WatcherState::ContextExhausted,
632        ScreenState::Unknown => previous_state,
633    }
634}
635
636fn simple_hash(s: &str) -> u64 {
637    // FNV-1a style hash, good enough for change detection
638    let mut hash: u64 = 0xcbf29ce484222325;
639    for byte in s.bytes() {
640        hash ^= byte as u64;
641        hash = hash.wrapping_mul(0x100000001b3);
642    }
643    hash
644}
645
646fn default_codex_sessions_root() -> PathBuf {
647    std::env::var_os("HOME")
648        .map(PathBuf::from)
649        .unwrap_or_else(|| PathBuf::from("/"))
650        .join(".codex")
651        .join("sessions")
652}
653
654fn default_claude_projects_root() -> PathBuf {
655    std::env::var_os("HOME")
656        .map(PathBuf::from)
657        .unwrap_or_else(|| PathBuf::from("/"))
658        .join(".claude")
659        .join("projects")
660}
661
662fn current_file_len(path: &Path) -> Result<u64> {
663    Ok(fs::metadata(path)?.len())
664}
665
666fn session_file_id(path: Option<&PathBuf>) -> Option<String> {
667    path.and_then(|path| {
668        path.file_stem()
669            .and_then(|stem| stem.to_str())
670            .map(|stem| stem.to_string())
671    })
672}
673
674fn discover_codex_session_file(
675    sessions_root: &Path,
676    cwd: &Path,
677    session_id: Option<&str>,
678) -> Result<Option<PathBuf>> {
679    if !sessions_root.exists() {
680        return Ok(None);
681    }
682
683    if let Some(session_id) = session_id {
684        for year in read_dir_paths(sessions_root)? {
685            for month in read_dir_paths(&year)? {
686                for day in read_dir_paths(&month)? {
687                    let entry = day.join(format!("{session_id}.jsonl"));
688                    if entry.is_file()
689                        && session_meta_cwd(&entry)?.as_deref() == Some(cwd.as_os_str())
690                    {
691                        return Ok(Some(entry));
692                    }
693                }
694            }
695        }
696        return Ok(None);
697    }
698
699    let mut newest: Option<(std::time::SystemTime, PathBuf)> = None;
700    for year in read_dir_paths(sessions_root)? {
701        for month in read_dir_paths(&year)? {
702            for day in read_dir_paths(&month)? {
703                for entry in read_dir_paths(&day)? {
704                    if entry.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {
705                        continue;
706                    }
707                    if session_meta_cwd(&entry)?.as_deref() != Some(cwd.as_os_str()) {
708                        continue;
709                    }
710                    let modified = fs::metadata(&entry)
711                        .and_then(|meta| meta.modified())
712                        .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
713                    match &newest {
714                        Some((current, _)) if modified <= *current => {}
715                        _ => newest = Some((modified, entry)),
716                    }
717                }
718            }
719        }
720    }
721
722    Ok(newest.map(|(_, path)| path))
723}
724
725fn discover_claude_session_file(
726    projects_root: &Path,
727    cwd: &Path,
728    session_id: Option<&str>,
729) -> Result<Option<PathBuf>> {
730    if !projects_root.exists() {
731        return Ok(None);
732    }
733
734    let preferred_dir = projects_root.join(cwd.to_string_lossy().replace('/', "-"));
735    if let Some(session_id) = session_id {
736        let exact = preferred_dir.join(format!("{session_id}.jsonl"));
737        if exact.is_file() {
738            return Ok(Some(exact));
739        }
740        return Ok(None);
741    }
742
743    if preferred_dir.is_dir() {
744        let mut newest: Option<(std::time::SystemTime, PathBuf)> = None;
745        for entry in read_dir_paths(&preferred_dir)? {
746            if entry.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {
747                continue;
748            }
749            let modified = fs::metadata(&entry)
750                .and_then(|meta| meta.modified())
751                .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
752            match &newest {
753                Some((current, _)) if modified <= *current => {}
754                _ => newest = Some((modified, entry)),
755            }
756        }
757        if newest.is_some() {
758            return Ok(newest.map(|(_, path)| path));
759        }
760    }
761
762    let mut newest: Option<(std::time::SystemTime, PathBuf)> = None;
763    for project_dir in read_dir_paths(projects_root)? {
764        if !project_dir.is_dir() {
765            continue;
766        }
767        for entry in read_dir_paths(&project_dir)? {
768            if entry.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {
769                continue;
770            }
771            if session_file_cwd(&entry)?.as_deref() != Some(cwd.as_os_str()) {
772                continue;
773            }
774            let modified = fs::metadata(&entry)
775                .and_then(|meta| meta.modified())
776                .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
777            match &newest {
778                Some((current, _)) if modified <= *current => {}
779                _ => newest = Some((modified, entry)),
780            }
781        }
782    }
783
784    Ok(newest.map(|(_, path)| path))
785}
786
787fn read_dir_paths(dir: &Path) -> Result<Vec<PathBuf>> {
788    let mut paths = Vec::new();
789    for entry in fs::read_dir(dir)? {
790        let entry = entry?;
791        paths.push(entry.path());
792    }
793    Ok(paths)
794}
795
796fn session_meta_cwd(path: &Path) -> Result<Option<std::ffi::OsString>> {
797    let file = File::open(path)?;
798    let reader = BufReader::new(file);
799    for line in reader.lines() {
800        let line = line?;
801        if line.trim().is_empty() {
802            continue;
803        }
804        let Ok(entry) = serde_json::from_str::<Value>(&line) else {
805            continue;
806        };
807        if entry.get("type").and_then(Value::as_str) != Some("session_meta") {
808            continue;
809        }
810        return Ok(entry
811            .get("payload")
812            .and_then(|payload| payload.get("cwd"))
813            .and_then(Value::as_str)
814            .map(std::ffi::OsString::from));
815    }
816    Ok(None)
817}
818
819fn session_file_cwd(path: &Path) -> Result<Option<std::ffi::OsString>> {
820    let file = File::open(path)?;
821    let reader = BufReader::new(file);
822    for line in reader.lines() {
823        let line = line?;
824        if line.trim().is_empty() {
825            continue;
826        }
827        let Ok(entry) = serde_json::from_str::<Value>(&line) else {
828            continue;
829        };
830        if let Some(cwd) = entry.get("cwd").and_then(Value::as_str) {
831            return Ok(Some(std::ffi::OsString::from(cwd)));
832        }
833    }
834    Ok(None)
835}
836
837fn poll_codex_session_file(
838    path: &Path,
839    offset: &mut u64,
840    quality: &mut CodexQualitySignals,
841    last_response_hash: &mut Option<u64>,
842) -> Result<TrackerState> {
843    let file_len = fs::metadata(path)?.len();
844    if file_len < *offset {
845        *offset = 0;
846    }
847
848    let file = File::open(path)?;
849    let mut reader = BufReader::new(file);
850    reader.seek(SeekFrom::Start(*offset))?;
851
852    let mut completed = false;
853    let mut had_new_events = false;
854    loop {
855        let line_start = reader.stream_position()?;
856        let mut line = String::new();
857        let bytes = reader.read_line(&mut line)?;
858        if bytes == 0 {
859            break;
860        }
861        if !line.ends_with('\n') {
862            reader.seek(SeekFrom::Start(line_start))?;
863            break;
864        }
865
866        had_new_events = true;
867
868        if let Ok(entry) = serde_json::from_str::<Value>(&line) {
869            update_codex_quality_signals(&entry, quality, last_response_hash);
870            if entry.get("type").and_then(Value::as_str) == Some("event_msg")
871                && entry
872                    .get("payload")
873                    .and_then(|payload| payload.get("type"))
874                    .and_then(Value::as_str)
875                    == Some("task_complete")
876            {
877                completed = true;
878            }
879        }
880
881        *offset = reader.stream_position()?;
882    }
883
884    if completed {
885        Ok(TrackerState::Completed)
886    } else if had_new_events {
887        Ok(TrackerState::Active)
888    } else {
889        Ok(TrackerState::Unknown)
890    }
891}
892
893fn update_codex_quality_signals(
894    entry: &Value,
895    quality: &mut CodexQualitySignals,
896    last_response_hash: &mut Option<u64>,
897) {
898    if let Some(text) = codex_assistant_output_text(entry) {
899        let normalized = normalize_codex_response_text(&text);
900        let response_chars = normalized.chars().count();
901        let previous_len = quality.last_response_chars;
902        if let Some(previous_len) = previous_len {
903            if response_chars < previous_len {
904                quality.shortening_streak += 1;
905            } else {
906                quality.shortening_streak = 0;
907            }
908        } else {
909            quality.shortening_streak = 0;
910        }
911
912        let response_hash = simple_hash(&normalized);
913        if Some(response_hash) == *last_response_hash && !normalized.is_empty() {
914            quality.repeated_output_streak += 1;
915        } else {
916            quality.repeated_output_streak = 1;
917        }
918
919        quality.shrinking_responses = quality.shortening_streak >= 2;
920        quality.repeated_identical_outputs = quality.repeated_output_streak >= 3;
921        *last_response_hash = Some(response_hash);
922        quality.last_response_chars = Some(response_chars);
923    }
924
925    if let Some(tool_failure_message) = codex_tool_failure_message(entry) {
926        quality.tool_failure_message = Some(tool_failure_message);
927    }
928}
929
930fn codex_assistant_output_text(entry: &Value) -> Option<String> {
931    let payload = entry.get("payload")?;
932    if entry.get("type").and_then(Value::as_str) != Some("response_item") {
933        return None;
934    }
935    if payload.get("type").and_then(Value::as_str) != Some("message") {
936        return None;
937    }
938    if payload.get("role").and_then(Value::as_str) != Some("assistant") {
939        return None;
940    }
941
942    let mut text = String::new();
943    for item in payload.get("content")?.as_array()? {
944        if item.get("type").and_then(Value::as_str) == Some("output_text") {
945            if let Some(chunk) = item.get("text").and_then(Value::as_str) {
946                text.push_str(chunk);
947            }
948        }
949    }
950
951    if text.trim().is_empty() {
952        None
953    } else {
954        Some(text)
955    }
956}
957
958fn codex_tool_failure_message(entry: &Value) -> Option<String> {
959    if entry.get("type").and_then(Value::as_str) != Some("response_item") {
960        return None;
961    }
962    let payload = entry.get("payload")?;
963    if payload.get("type").and_then(Value::as_str) != Some("function_call_output") {
964        return None;
965    }
966
967    let output = payload.get("output").and_then(Value::as_str)?.trim();
968    if !looks_like_codex_tool_failure(output) {
969        return None;
970    }
971
972    Some(first_non_empty_line(output).unwrap_or(output).to_string())
973}
974
975fn normalize_codex_response_text(text: &str) -> String {
976    text.split_whitespace().collect::<Vec<_>>().join(" ")
977}
978
979fn looks_like_codex_tool_failure(output: &str) -> bool {
980    let lowered = output.to_ascii_lowercase();
981    lowered.contains("sandboxdenied")
982        || lowered.contains("timed out")
983        || lowered.contains("failed to run")
984        || lowered.contains("exec_command failed")
985        || (lowered.contains("failed") && lowered.contains("exit code"))
986        || (lowered.contains("error") && lowered.contains("process exited"))
987}
988
989fn first_non_empty_line(text: &str) -> Option<&str> {
990    text.lines().map(str::trim).find(|line| !line.is_empty())
991}
992
993fn poll_claude_session(tracker: &mut ClaudeSessionTracker) -> Result<TrackerState> {
994    if tracker.session_file.is_none() {
995        tracker.session_file = discover_claude_session_file(
996            &tracker.projects_root,
997            &tracker.cwd,
998            tracker.session_id.as_deref(),
999        )?;
1000        if let Some(session_file) = tracker.session_file.clone() {
1001            // Bind at EOF so historical entries from an older or unrelated
1002            // Claude session do not override the live pane state on first poll.
1003            tracker.offset = current_file_len(&session_file)?;
1004            tracker.last_state = TrackerState::Unknown;
1005        }
1006        return Ok(tracker.last_state);
1007    }
1008
1009    maybe_rebind_claude_session_file(tracker)?;
1010
1011    let Some(session_file) = tracker.session_file.clone() else {
1012        return Ok(TrackerState::Unknown);
1013    };
1014
1015    if !session_file.exists() {
1016        tracker.session_file = None;
1017        tracker.offset = 0;
1018        tracker.last_state = TrackerState::Unknown;
1019        return Ok(TrackerState::Unknown);
1020    }
1021
1022    let (state, offset) = parse_claude_session_file(&session_file, tracker.offset)?;
1023    tracker.offset = offset;
1024    if state != TrackerState::Unknown {
1025        tracker.last_state = state;
1026    }
1027    Ok(tracker.last_state)
1028}
1029
1030fn maybe_rebind_claude_session_file(tracker: &mut ClaudeSessionTracker) -> Result<()> {
1031    let Some(current_file) = tracker.session_file.clone() else {
1032        return Ok(());
1033    };
1034
1035    let Some(newest_file) =
1036        discover_claude_session_file(&tracker.projects_root, &tracker.cwd, None)?
1037    else {
1038        return Ok(());
1039    };
1040
1041    if newest_file == current_file {
1042        return Ok(());
1043    }
1044
1045    let current_modified = fs::metadata(&current_file)
1046        .and_then(|meta| meta.modified())
1047        .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
1048    let newest_modified = fs::metadata(&newest_file)
1049        .and_then(|meta| meta.modified())
1050        .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
1051
1052    if newest_modified <= current_modified {
1053        return Ok(());
1054    }
1055
1056    tracker.session_file = Some(newest_file.clone());
1057    tracker.session_id = session_file_id(Some(&newest_file));
1058    tracker.offset = current_file_len(&newest_file)?;
1059    tracker.last_state = TrackerState::Unknown;
1060    Ok(())
1061}
1062
1063fn parse_claude_session_file(path: &Path, start_offset: u64) -> Result<(TrackerState, u64)> {
1064    let file_len = fs::metadata(path)?.len();
1065    let mut offset = if file_len < start_offset {
1066        0
1067    } else {
1068        start_offset
1069    };
1070
1071    let file = File::open(path)?;
1072    let mut reader = BufReader::new(file);
1073    reader.seek(SeekFrom::Start(offset))?;
1074
1075    let mut state = TrackerState::Unknown;
1076    loop {
1077        let line_start = reader.stream_position()?;
1078        let mut line = String::new();
1079        let bytes = reader.read_line(&mut line)?;
1080        if bytes == 0 {
1081            break;
1082        }
1083        if !line.ends_with('\n') {
1084            reader.seek(SeekFrom::Start(line_start))?;
1085            break;
1086        }
1087
1088        if let Ok(entry) = serde_json::from_str::<Value>(&line) {
1089            let line_state = classify_claude_log_entry(&entry);
1090            if line_state != TrackerState::Unknown {
1091                state = line_state;
1092            }
1093        }
1094
1095        offset = reader.stream_position()?;
1096    }
1097
1098    Ok((state, offset))
1099}
1100
1101fn classify_claude_log_entry(entry: &Value) -> TrackerState {
1102    match entry.get("type").and_then(Value::as_str) {
1103        Some("assistant") => {
1104            let stop_reason = entry
1105                .get("message")
1106                .and_then(|message| message.get("stop_reason"))
1107                .and_then(Value::as_str);
1108            match stop_reason {
1109                Some("tool_use") => TrackerState::Active,
1110                Some("end_turn") => TrackerState::Idle,
1111                _ => TrackerState::Unknown,
1112            }
1113        }
1114        Some("progress") => TrackerState::Active,
1115        Some("user") => {
1116            if entry
1117                .get("toolUseResult")
1118                .and_then(Value::as_object)
1119                .is_some()
1120            {
1121                return TrackerState::Active;
1122            }
1123            if let Some(content) = entry
1124                .get("message")
1125                .and_then(|message| message.get("content"))
1126            {
1127                if let Some(text) = content.as_str() {
1128                    if text == "[Request interrupted by user]" {
1129                        return TrackerState::Idle;
1130                    }
1131                    return TrackerState::Active;
1132                }
1133                if let Some(items) = content.as_array() {
1134                    for item in items {
1135                        if item.get("tool_use_id").is_some() {
1136                            return TrackerState::Active;
1137                        }
1138                        if item.get("type").and_then(Value::as_str) == Some("text")
1139                            && item.get("text").and_then(Value::as_str)
1140                                == Some("[Request interrupted by user]")
1141                        {
1142                            return TrackerState::Idle;
1143                        }
1144                    }
1145                    return TrackerState::Active;
1146                }
1147            }
1148            TrackerState::Unknown
1149        }
1150        _ => TrackerState::Unknown,
1151    }
1152}
1153
1154#[cfg(test)]
1155mod tests {
1156    use super::*;
1157    use serial_test::serial;
1158    use std::io::Write;
1159
1160    #[test]
1161    fn simple_hash_differs_for_different_input() {
1162        assert_ne!(simple_hash("hello"), simple_hash("world"));
1163        assert_eq!(simple_hash("same"), simple_hash("same"));
1164    }
1165
1166    #[test]
1167    fn new_watcher_starts_idle() {
1168        let w = SessionWatcher::new("%0", "eng-1-1", 300, None);
1169        assert_eq!(w.state, WatcherState::Idle);
1170    }
1171
1172    #[test]
1173    fn activate_sets_active() {
1174        let mut w = SessionWatcher::new("%0", "eng-1-1", 300, None);
1175        w.activate();
1176        assert_eq!(w.state, WatcherState::Active);
1177    }
1178
1179    #[test]
1180    fn deactivate_sets_idle() {
1181        let mut w = SessionWatcher::new("%0", "eng-1-1", 300, None);
1182        w.activate();
1183        w.deactivate();
1184        assert_eq!(w.state, WatcherState::Idle);
1185    }
1186
1187    #[test]
1188    fn last_lines_returns_tail() {
1189        let mut w = SessionWatcher::new("%0", "eng-1-1", 300, None);
1190        w.last_capture = "line1\nline2\nline3\nline4\nline5".to_string();
1191        assert_eq!(w.last_lines(3), "line3\nline4\nline5");
1192        assert_eq!(w.last_lines(10), "line1\nline2\nline3\nline4\nline5");
1193    }
1194
1195    #[test]
1196    fn detects_claude_code_prompt() {
1197        let capture = "⏺ Done.\n\n❯ \n\n  bypass permissions\n";
1198        assert!(is_at_agent_prompt(capture));
1199    }
1200
1201    #[test]
1202    fn detects_shell_prompt() {
1203        let capture = "some output\n$ \n";
1204        assert!(is_at_agent_prompt(capture));
1205    }
1206
1207    #[test]
1208    fn detects_codex_prompt() {
1209        let capture =
1210            "› Improve documentation in @filename\n\n  gpt-5.4 high · 84% left · ~/repo\n";
1211        assert!(is_at_agent_prompt(capture));
1212    }
1213
1214    #[test]
1215    fn detects_kiro_prompt() {
1216        let capture = "Kiro>\n";
1217        assert!(is_at_agent_prompt(capture));
1218        assert_eq!(classify_capture_state(capture), ScreenState::Idle);
1219    }
1220
1221    #[test]
1222    fn no_prompt_when_working() {
1223        let capture = "⏺ Bash(python -m pytest)\n  ⎿  running tests...\n";
1224        assert!(!is_at_agent_prompt(capture));
1225    }
1226
1227    #[test]
1228    fn claude_working_not_idle_despite_prompt_visible() {
1229        // Claude Code always renders ❯ at the bottom — but shows
1230        // "esc to interrupt" in the status bar while processing.
1231        let capture = concat!(
1232            "✻ Slithering… (4m 12s)\n",
1233            "  ⎿  Tip: Use /btw to ask a quick side question\n",
1234            "────────────────────────────\n",
1235            "❯ \n",
1236            "────────────────────────────\n",
1237            "  ⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt\n",
1238        );
1239        assert!(!is_at_agent_prompt(capture));
1240    }
1241
1242    #[test]
1243    fn claude_working_not_idle_when_interrupt_footer_is_truncated() {
1244        let capture = concat!(
1245            "✢ Cascading… (48s · ↓ 130 tokens · thought for 17s)\n",
1246            "  ⎿  Tip: Use /btw to ask a quick side question\n",
1247            "────────────────────────────\n",
1248            "❯ \n",
1249            "────────────────────────────\n",
1250            "  ⏵⏵ bypass permissions on (shift+tab to cycle) · esc to in…\n",
1251        );
1252        assert!(!is_at_agent_prompt(capture));
1253        assert_eq!(classify_capture_state(capture), ScreenState::Active);
1254    }
1255
1256    #[test]
1257    fn claude_idle_detected_without_esc_to_interrupt() {
1258        let capture = concat!(
1259            "⏺ Done.\n",
1260            "────────────────────────────\n",
1261            "❯ \n",
1262            "────────────────────────────\n",
1263            "  ⏵⏵ bypass permissions on (shift+tab to cycle)\n",
1264        );
1265        assert!(is_at_agent_prompt(capture));
1266        assert_eq!(classify_capture_state(capture), ScreenState::Idle);
1267    }
1268
1269    #[test]
1270    fn claude_context_window_message_marks_capture_exhausted() {
1271        let capture = concat!(
1272            "Claude cannot continue: conversation is too long.\n",
1273            "Start a new conversation or clear earlier context.\n",
1274            "❯ \n",
1275        );
1276        assert_eq!(
1277            classify_capture_state(capture),
1278            ScreenState::ContextExhausted
1279        );
1280    }
1281
1282    #[test]
1283    fn codex_context_limit_message_marks_capture_exhausted() {
1284        let capture = concat!(
1285            "Request truncated due to context limit.\n",
1286            "Please start a fresh session with a smaller prompt.\n",
1287            "› \n",
1288        );
1289        assert_eq!(
1290            classify_capture_state(capture),
1291            ScreenState::ContextExhausted
1292        );
1293    }
1294
1295    #[test]
1296    fn kiro_context_limit_message_marks_capture_exhausted() {
1297        let capture = concat!(
1298            "Kiro cannot continue because the conversation is too long.\n",
1299            "Please start a fresh session.\n",
1300            "Kiro>\n",
1301        );
1302        assert_eq!(
1303            classify_capture_state(capture),
1304            ScreenState::ContextExhausted
1305        );
1306    }
1307
1308    #[test]
1309    fn ambiguous_context_wording_does_not_mark_capture_exhausted() {
1310        let capture = concat!(
1311            "We should reduce context window usage in the next refactor.\n",
1312            "That note is informational only.\n",
1313        );
1314        assert_eq!(classify_capture_state(capture), ScreenState::Unknown);
1315    }
1316
1317    #[test]
1318    fn claude_pasted_text_prompt_counts_as_idle() {
1319        let capture = concat!(
1320            "✻ Crunched for 54s\n",
1321            "────────────────────────────────────────────────────────\n",
1322            "❯\u{00a0}[Pasted text #2 +40 lines]\n",
1323            "  --- Message from human ---\n",
1324            "  Provide me report of latest development\n",
1325            "  --- end message ---\n",
1326            "  To reply, run: batty send human \"<your response>\"\n",
1327            "────────────────────────────────────────────────────────\n",
1328            "  ⏵⏵ bypass permissions on (shift+tab to cycle)\n",
1329        );
1330        assert!(is_at_agent_prompt(capture));
1331        assert_eq!(classify_capture_state(capture), ScreenState::Idle);
1332    }
1333
1334    #[test]
1335    fn claude_interrupted_prompt_not_idle() {
1336        let capture = concat!(
1337            "■ Conversation interrupted - tell the model what to do differently.\n",
1338            "  Something went wrong? Hit `/feedback` to report the issue.\n",
1339            "\n",
1340            "Interrupted · What should Claude do instead?\n",
1341            "❯ \n",
1342            "  ⏵⏵ bypass permissions on (shift+tab to cycle)\n",
1343        );
1344        assert!(is_at_agent_prompt(capture));
1345        assert_eq!(classify_capture_state(capture), ScreenState::Idle);
1346    }
1347
1348    #[test]
1349    fn claude_historical_interruption_does_not_poison_idle_prompt() {
1350        let capture = concat!(
1351            "Interrupted · What should Claude do instead?\n",
1352            "Lots of old output here\n",
1353            "\n\n\n\n\n\n\n\n\n\n",
1354            "────────────────────────────\n",
1355            "❯ \n",
1356            "────────────────────────────\n",
1357            "  ⏵⏵ bypass permissions on (shift+tab to cycle)\n",
1358        );
1359        assert!(is_at_agent_prompt(capture));
1360        assert_eq!(classify_capture_state(capture), ScreenState::Idle);
1361    }
1362
1363    #[test]
1364    fn claude_recent_interruption_without_esc_still_counts_as_idle() {
1365        let capture = concat!(
1366            "--- Message from manager ---\n",
1367            "No worries about the interrupted background task.\n",
1368            "--- end message ---\n",
1369            "To reply, run: batty send manager \"<your response>\"\n",
1370            "  ⎿  Interrupted · What should Claude do instead?\n",
1371            "\n",
1372            "⏺ Background command stopped\n",
1373            "────────────────────────────────────────────────────────\n",
1374            "❯ \n",
1375            "────────────────────────────────────────────────────────\n",
1376            "  ⏵⏵ bypass permissions on (shift+tab to cycle)\n",
1377        );
1378        assert!(is_at_agent_prompt(capture));
1379        assert_eq!(classify_capture_state(capture), ScreenState::Idle);
1380    }
1381
1382    #[test]
1383    fn stale_esc_line_above_latest_prompt_does_not_pin_active() {
1384        let capture = concat!(
1385            "⏺ Bash(tmux capture-pane -t batty-mafia-adversarial-research:0.5 -p 2>/dev/null | tail -30)\n",
1386            "  ⎿  • Working (5s • esc to interrupt)\n",
1387            "     • Messages to be submitted after next tool call (press\n",
1388            "     … +9 lines (ctrl+o to expand)\n",
1389            "\n",
1390            "⏺ Good, the message is queued and will be processed. Let me wait a bit and check back.\n",
1391            "\n",
1392            "⏺ Bash(sleep 30 && tmux capture-pane -t batty-mafia-adversarial-research:0.5 -p 2>/dev/null | tail -30)\n",
1393            "  ⎿  Interrupted · What should Claude do instead?\n",
1394            "\n",
1395            "───────────────────────────────────────────────────────────────────────────────────────────────────────────\n",
1396            "❯\u{00a0}\n",
1397            "───────────────────────────────────────────────────────────────────────────────────────────────────────────\n",
1398            "  ⏵⏵ bypass permissions on (shift+tab to cycle)\n",
1399        );
1400        assert!(is_at_agent_prompt(capture));
1401        assert_eq!(classify_capture_state(capture), ScreenState::Idle);
1402    }
1403
1404    #[test]
1405    fn claude_prompt_deep_in_output_still_detected_as_idle() {
1406        // Regression: Claude Code's idle layout can push the ❯ prompt past
1407        // a narrow line window when tool output, separator lines, and the
1408        // status bar fill the bottom of the pane. The old 5-line window
1409        // missed this and returned Unknown, which fell through to a stale
1410        // tracker state and blocked message delivery.
1411        let capture = concat!(
1412            "⏺ Task merged to main.\n",
1413            "\n",
1414            "  ┌──────────┬──────────────────────┬──────────┐\n",
1415            "  │ Engineer │       Assignment       │  Status  │\n",
1416            "  ├──────────┼──────────────────────┼──────────┤\n",
1417            "  │ eng-1-1  │ Add features           │ Assigned │\n",
1418            "  └──────────┴──────────────────────┴──────────┘\n",
1419            "\n",
1420            "✻ Sautéed for 1m 56s\n",
1421            "\n",
1422            "────────────────────────────────────────────────────────\n",
1423            "❯ \n",
1424            "────────────────────────────────────────────────────────\n",
1425            "  ⏵⏵ bypass permissions on (shift+tab to cycle)\n",
1426        );
1427        assert!(is_at_agent_prompt(capture));
1428        assert_eq!(classify_capture_state(capture), ScreenState::Idle);
1429    }
1430
1431    #[test]
1432    fn claude_spinner_status_marks_capture_active() {
1433        let capture = concat!(
1434            "✶ Envisioning… (thinking with high effort)\n",
1435            "────────────────────────────\n",
1436            "❯ \n",
1437            "────────────────────────────\n",
1438            "  ⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt\n",
1439        );
1440        assert_eq!(classify_capture_state(capture), ScreenState::Active);
1441    }
1442
1443    #[test]
1444    fn kiro_spinner_status_marks_capture_active() {
1445        let capture = concat!(
1446            "Kiro Agent: thinking through the implementation plan\n",
1447            "Reviewing files and preparing edits\n",
1448        );
1449        assert_eq!(classify_capture_state(capture), ScreenState::Active);
1450    }
1451
1452    #[test]
1453    fn claude_truncated_interrupt_footer_marks_capture_active() {
1454        let capture = concat!(
1455            "✻ Baked for 4m 30s\n",
1456            "────────────────────────────\n",
1457            "❯ \n",
1458            "────────────────────────────\n",
1459            "  ⏵⏵ bypass permissions on (shift+tab to cycle) · esc to in…\n",
1460        );
1461        assert_eq!(classify_capture_state(capture), ScreenState::Active);
1462    }
1463
1464    #[test]
1465    fn codex_prompt_keeps_active_state_until_completion_event() {
1466        assert_eq!(
1467            next_state_after_capture(
1468                TrackerKind::Codex,
1469                ScreenState::Idle,
1470                TrackerState::Unknown,
1471                WatcherState::Idle,
1472            ),
1473            WatcherState::Idle
1474        );
1475        assert_eq!(
1476            next_state_after_capture(
1477                TrackerKind::Codex,
1478                ScreenState::Idle,
1479                TrackerState::Active,
1480                WatcherState::Idle,
1481            ),
1482            WatcherState::Active
1483        );
1484        assert_eq!(
1485            next_state_after_capture(
1486                TrackerKind::Codex,
1487                ScreenState::Idle,
1488                TrackerState::Idle,
1489                WatcherState::Active,
1490            ),
1491            WatcherState::Idle
1492        );
1493        assert_eq!(
1494            next_state_after_capture(
1495                TrackerKind::Codex,
1496                ScreenState::Unknown,
1497                TrackerState::Unknown,
1498                WatcherState::Active,
1499            ),
1500            WatcherState::Active
1501        );
1502        assert_eq!(
1503            next_state_after_capture(
1504                TrackerKind::Codex,
1505                ScreenState::Active,
1506                TrackerState::Unknown,
1507                WatcherState::Idle,
1508            ),
1509            WatcherState::Active
1510        );
1511        assert_eq!(
1512            next_state_after_capture(
1513                TrackerKind::Codex,
1514                ScreenState::Idle,
1515                TrackerState::Completed,
1516                WatcherState::Active,
1517            ),
1518            WatcherState::Idle
1519        );
1520        assert_eq!(
1521            next_state_after_capture(
1522                TrackerKind::Codex,
1523                ScreenState::ContextExhausted,
1524                TrackerState::Unknown,
1525                WatcherState::Active,
1526            ),
1527            WatcherState::ContextExhausted
1528        );
1529    }
1530
1531    #[test]
1532    fn claude_idle_prompt_beats_stale_file_activity() {
1533        assert_eq!(
1534            next_state_after_capture(
1535                TrackerKind::Claude,
1536                ScreenState::Idle,
1537                TrackerState::Active,
1538                WatcherState::Active,
1539            ),
1540            WatcherState::Idle
1541        );
1542    }
1543
1544    #[test]
1545    fn claude_spinner_beats_idle_file_state() {
1546        assert_eq!(
1547            next_state_after_capture(
1548                TrackerKind::Claude,
1549                ScreenState::Active,
1550                TrackerState::Idle,
1551                WatcherState::Idle,
1552            ),
1553            WatcherState::Active
1554        );
1555    }
1556
1557    #[test]
1558    #[serial]
1559    #[cfg_attr(not(feature = "integration"), ignore)]
1560    fn idle_poll_consumes_non_empty_capture() {
1561        let session = "batty-test-watcher-idle-poll";
1562        let _ = crate::tmux::kill_session(session);
1563
1564        crate::tmux::create_session(
1565            session,
1566            "bash",
1567            &[
1568                "-lc".to_string(),
1569                "printf 'watcher-idle-poll\\n'; sleep 3".to_string(),
1570            ],
1571            "/tmp",
1572        )
1573        .unwrap();
1574        std::thread::sleep(std::time::Duration::from_millis(300));
1575
1576        let pane_id = crate::tmux::pane_id(session).unwrap();
1577        let mut watcher = SessionWatcher::new(&pane_id, "eng-1-1", 300, None);
1578
1579        assert_eq!(watcher.poll().unwrap(), WatcherState::Idle);
1580        assert!(!watcher.last_output().is_empty());
1581
1582        crate::tmux::kill_session(session).unwrap();
1583    }
1584
1585    #[test]
1586    #[serial]
1587    #[cfg_attr(not(feature = "integration"), ignore)]
1588    fn active_poll_updates_state_when_capture_changes() {
1589        let session = "batty-test-watcher-active-change";
1590        let _ = crate::tmux::kill_session(session);
1591
1592        crate::tmux::create_session(
1593            session,
1594            "bash",
1595            &[
1596                "-lc".to_string(),
1597                "printf 'watcher-active-change\\n'; sleep 3".to_string(),
1598            ],
1599            "/tmp",
1600        )
1601        .unwrap();
1602        std::thread::sleep(std::time::Duration::from_millis(300));
1603
1604        let pane_id = crate::tmux::pane_id(session).unwrap();
1605        let mut watcher = SessionWatcher::new(&pane_id, "eng-1-1", 300, None);
1606        watcher.state = WatcherState::Active;
1607
1608        assert_eq!(watcher.poll().unwrap(), WatcherState::Active);
1609        assert_ne!(watcher.last_output_hash, 0);
1610        assert!(!watcher.last_output().is_empty());
1611
1612        crate::tmux::kill_session(session).unwrap();
1613    }
1614
1615    #[test]
1616    #[serial]
1617    #[cfg_attr(not(feature = "integration"), ignore)]
1618    fn idle_poll_detects_context_exhaustion() {
1619        let session = format!("batty-test-watcher-context-exhaust-{}", std::process::id());
1620        let _ = crate::tmux::kill_session(&session);
1621
1622        crate::tmux::create_session(&session, "cat", &[], "/tmp").unwrap();
1623        let pane_id = crate::tmux::pane_id(&session).unwrap();
1624        std::thread::sleep(std::time::Duration::from_millis(100));
1625        crate::tmux::send_keys(&pane_id, "Conversation is too long to continue.", true).unwrap();
1626        std::thread::sleep(std::time::Duration::from_millis(150));
1627
1628        let mut watcher = SessionWatcher::new(&pane_id, "eng-1-1", 300, None);
1629
1630        assert_eq!(watcher.poll().unwrap(), WatcherState::ContextExhausted);
1631        assert!(watcher.last_output().contains("Conversation is too long"));
1632
1633        crate::tmux::kill_session(&session).unwrap();
1634    }
1635
1636    #[test]
1637    #[serial]
1638    #[cfg_attr(not(feature = "integration"), ignore)]
1639    fn active_poll_keeps_previous_state_when_capture_is_unchanged() {
1640        let session = "batty-test-watcher-unchanged";
1641        let _ = crate::tmux::kill_session(session);
1642
1643        crate::tmux::create_session(
1644            session,
1645            "bash",
1646            &[
1647                "-lc".to_string(),
1648                "printf 'watcher-unchanged\\n'; sleep 3".to_string(),
1649            ],
1650            "/tmp",
1651        )
1652        .unwrap();
1653        std::thread::sleep(std::time::Duration::from_millis(300));
1654
1655        let pane_id = crate::tmux::pane_id(session).unwrap();
1656        let capture = crate::tmux::capture_pane(&pane_id).unwrap();
1657        let mut watcher = SessionWatcher::new(&pane_id, "eng-1-1", 0, None);
1658        watcher.state = WatcherState::Active;
1659        watcher.last_capture = capture.clone();
1660        watcher.last_output_hash = simple_hash(&capture);
1661
1662        assert_eq!(watcher.poll().unwrap(), WatcherState::Active);
1663
1664        crate::tmux::kill_session(session).unwrap();
1665    }
1666
1667    #[test]
1668    fn missing_pane_poll_reports_pane_dead() {
1669        let mut watcher = SessionWatcher::new("%999999", "eng-1-1", 300, None);
1670        assert_eq!(watcher.poll().unwrap(), WatcherState::PaneDead);
1671    }
1672
1673    #[test]
1674    #[serial]
1675    #[cfg_attr(not(feature = "integration"), ignore)]
1676    fn pane_dead_poll_reports_pane_dead() {
1677        let session = format!("batty-test-watcher-pane-dead-{}", std::process::id());
1678        let _ = crate::tmux::kill_session(&session);
1679
1680        crate::tmux::create_session(&session, "bash", &[], "/tmp").unwrap();
1681        crate::tmux::create_window(&session, "keeper", "sleep", &["30".to_string()], "/tmp")
1682            .unwrap();
1683        let pane_id = crate::tmux::pane_id(&session).unwrap();
1684        std::process::Command::new("tmux")
1685            .args(["set-option", "-p", "-t", &pane_id, "remain-on-exit", "on"])
1686            .output()
1687            .unwrap();
1688
1689        crate::tmux::send_keys(&pane_id, "exit", true).unwrap();
1690        for _ in 0..5 {
1691            if crate::tmux::pane_dead(&pane_id).unwrap_or(false) {
1692                break;
1693            }
1694            std::thread::sleep(std::time::Duration::from_millis(200));
1695        }
1696        assert!(crate::tmux::pane_dead(&pane_id).unwrap());
1697
1698        let mut watcher = SessionWatcher::new(&pane_id, "eng-1-1", 300, None);
1699        assert_eq!(watcher.poll().unwrap(), WatcherState::PaneDead);
1700
1701        crate::tmux::kill_session(&session).unwrap();
1702    }
1703
1704    #[test]
1705    fn discovers_codex_session_by_exact_cwd() {
1706        let tmp = tempfile::tempdir().unwrap();
1707        let sessions_root = tmp.path().join("sessions");
1708        let session_dir = sessions_root.join("2026").join("03").join("10");
1709        std::fs::create_dir_all(&session_dir).unwrap();
1710
1711        let wanted_cwd = tmp
1712            .path()
1713            .join("repo")
1714            .join(".batty")
1715            .join("codex-context")
1716            .join("architect");
1717        let other_cwd = tmp.path().join("repo");
1718        let wanted_file = session_dir.join("wanted.jsonl");
1719        let other_file = session_dir.join("other.jsonl");
1720
1721        std::fs::write(
1722            &wanted_file,
1723            format!(
1724                "{{\"type\":\"session_meta\",\"payload\":{{\"cwd\":\"{}\"}}}}\n",
1725                wanted_cwd.display()
1726            ),
1727        )
1728        .unwrap();
1729        std::fs::write(
1730            &other_file,
1731            format!(
1732                "{{\"type\":\"session_meta\",\"payload\":{{\"cwd\":\"{}\"}}}}\n",
1733                other_cwd.display()
1734            ),
1735        )
1736        .unwrap();
1737
1738        let discovered = discover_codex_session_file(&sessions_root, &wanted_cwd, None).unwrap();
1739        assert_eq!(discovered.as_deref(), Some(wanted_file.as_path()));
1740    }
1741
1742    #[test]
1743    fn discover_codex_session_file_requires_exact_session_id_when_configured() {
1744        let tmp = tempfile::tempdir().unwrap();
1745        let sessions_root = tmp.path().join("sessions");
1746        let session_dir = sessions_root.join("2026").join("03").join("21");
1747        std::fs::create_dir_all(&session_dir).unwrap();
1748
1749        let cwd = tmp
1750            .path()
1751            .join("repo")
1752            .join(".batty")
1753            .join("codex-context")
1754            .join("eng-1");
1755        let other_file = session_dir.join("other-session.jsonl");
1756        std::fs::write(
1757            &other_file,
1758            format!(
1759                "{{\"type\":\"session_meta\",\"payload\":{{\"cwd\":\"{}\"}}}}\n",
1760                cwd.display()
1761            ),
1762        )
1763        .unwrap();
1764
1765        let discovered =
1766            discover_codex_session_file(&sessions_root, &cwd, Some("missing-session")).unwrap();
1767        assert!(discovered.is_none());
1768    }
1769
1770    #[test]
1771    fn codex_session_poll_detects_task_complete() {
1772        let tmp = tempfile::tempdir().unwrap();
1773        let session_file = tmp.path().join("session.jsonl");
1774        let mut handle = File::create(&session_file).unwrap();
1775        writeln!(
1776            handle,
1777            "{{\"type\":\"session_meta\",\"payload\":{{\"cwd\":\"/tmp/example\"}}}}"
1778        )
1779        .unwrap();
1780        handle.flush().unwrap();
1781
1782        let mut offset = 0;
1783        let mut quality = CodexQualitySignals::default();
1784        let mut last_response_hash = None;
1785        // session_meta is a new event but not task_complete → Active
1786        assert_eq!(
1787            poll_codex_session_file(
1788                &session_file,
1789                &mut offset,
1790                &mut quality,
1791                &mut last_response_hash,
1792            )
1793            .unwrap(),
1794            TrackerState::Active
1795        );
1796
1797        writeln!(
1798            handle,
1799            "{{\"type\":\"event_msg\",\"payload\":{{\"type\":\"task_complete\"}}}}"
1800        )
1801        .unwrap();
1802        handle.flush().unwrap();
1803
1804        assert_eq!(
1805            poll_codex_session_file(
1806                &session_file,
1807                &mut offset,
1808                &mut quality,
1809                &mut last_response_hash,
1810            )
1811            .unwrap(),
1812            TrackerState::Completed
1813        );
1814        // No new events → Unknown
1815        assert_eq!(
1816            poll_codex_session_file(
1817                &session_file,
1818                &mut offset,
1819                &mut quality,
1820                &mut last_response_hash,
1821            )
1822            .unwrap(),
1823            TrackerState::Unknown
1824        );
1825    }
1826
1827    #[test]
1828    fn codex_existing_session_ignores_historical_task_complete_when_binding() {
1829        let tmp = tempfile::tempdir().unwrap();
1830        let sessions_root = tmp.path().join("sessions");
1831        let session_dir = sessions_root.join("2026").join("03").join("10");
1832        std::fs::create_dir_all(&session_dir).unwrap();
1833
1834        let cwd = tmp
1835            .path()
1836            .join("repo")
1837            .join(".batty")
1838            .join("codex-context")
1839            .join("eng-1");
1840        let session_file = session_dir.join("session.jsonl");
1841        std::fs::write(
1842            &session_file,
1843            format!(
1844                "{{\"type\":\"session_meta\",\"payload\":{{\"cwd\":\"{}\"}}}}\n\
1845                 {{\"type\":\"event_msg\",\"payload\":{{\"type\":\"task_complete\"}}}}\n",
1846                cwd.display()
1847            ),
1848        )
1849        .unwrap();
1850
1851        let mut tracker = CodexSessionTracker {
1852            sessions_root,
1853            cwd,
1854            session_id: None,
1855            session_file: None,
1856            offset: 0,
1857            quality: CodexQualitySignals::default(),
1858            last_response_hash: None,
1859        };
1860
1861        if tracker.session_file.is_none() {
1862            tracker.session_file =
1863                discover_codex_session_file(&tracker.sessions_root, &tracker.cwd, None).unwrap();
1864            if let Some(found) = tracker.session_file.as_ref() {
1865                tracker.offset = current_file_len(found).unwrap();
1866            }
1867        }
1868
1869        // After binding at EOF, no new events → Unknown
1870        assert_eq!(
1871            poll_codex_session_file(
1872                tracker.session_file.as_ref().unwrap(),
1873                &mut tracker.offset,
1874                &mut tracker.quality,
1875                &mut tracker.last_response_hash,
1876            )
1877            .unwrap(),
1878            TrackerState::Unknown
1879        );
1880
1881        let mut handle = std::fs::OpenOptions::new()
1882            .append(true)
1883            .open(tracker.session_file.as_ref().unwrap())
1884            .unwrap();
1885        writeln!(
1886            handle,
1887            "{{\"type\":\"event_msg\",\"payload\":{{\"type\":\"task_complete\"}}}}"
1888        )
1889        .unwrap();
1890        handle.flush().unwrap();
1891
1892        assert_eq!(
1893            poll_codex_session_file(
1894                tracker.session_file.as_ref().unwrap(),
1895                &mut tracker.offset,
1896                &mut tracker.quality,
1897                &mut tracker.last_response_hash,
1898            )
1899            .unwrap(),
1900            TrackerState::Completed
1901        );
1902    }
1903
1904    #[test]
1905    fn codex_session_quality_detects_shrinking_responses() {
1906        let tmp = tempfile::tempdir().unwrap();
1907        let session_file = tmp.path().join("session.jsonl");
1908        std::fs::write(
1909            &session_file,
1910            concat!(
1911                "{\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"This is a fairly detailed reply with enough content.\"}]}}\n",
1912                "{\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Shorter follow-up reply.\"}]}}\n",
1913                "{\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Tiny.\"}]}}\n",
1914            ),
1915        )
1916        .unwrap();
1917
1918        let mut offset = 0;
1919        let mut quality = CodexQualitySignals::default();
1920        let mut last_response_hash = None;
1921        assert_eq!(
1922            poll_codex_session_file(
1923                &session_file,
1924                &mut offset,
1925                &mut quality,
1926                &mut last_response_hash,
1927            )
1928            .unwrap(),
1929            TrackerState::Active
1930        );
1931
1932        assert_eq!(quality.last_response_chars, Some(5));
1933        assert_eq!(quality.shortening_streak, 2);
1934        assert!(quality.shrinking_responses);
1935        assert!(!quality.repeated_identical_outputs);
1936    }
1937
1938    #[test]
1939    fn codex_session_quality_detects_repeated_identical_outputs() {
1940        let tmp = tempfile::tempdir().unwrap();
1941        let session_file = tmp.path().join("session.jsonl");
1942        std::fs::write(
1943            &session_file,
1944            concat!(
1945                "{\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Same response.\"}]}}\n",
1946                "{\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Same response.\"}]}}\n",
1947                "{\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Same response.\"}]}}\n",
1948            ),
1949        )
1950        .unwrap();
1951
1952        let mut offset = 0;
1953        let mut quality = CodexQualitySignals::default();
1954        let mut last_response_hash = None;
1955        poll_codex_session_file(
1956            &session_file,
1957            &mut offset,
1958            &mut quality,
1959            &mut last_response_hash,
1960        )
1961        .unwrap();
1962
1963        assert_eq!(quality.repeated_output_streak, 3);
1964        assert!(quality.repeated_identical_outputs);
1965        assert!(!quality.shrinking_responses);
1966    }
1967
1968    #[test]
1969    fn codex_session_quality_detects_tool_failure_messages() {
1970        let tmp = tempfile::tempdir().unwrap();
1971        let session_file = tmp.path().join("session.jsonl");
1972        std::fs::write(
1973            &session_file,
1974            "{\"type\":\"response_item\",\"payload\":{\"type\":\"function_call_output\",\"call_id\":\"call_123\",\"output\":\"exec_command failed: SandboxDenied { message: \\\"operation not permitted\\\" }\"}}\n",
1975        )
1976        .unwrap();
1977
1978        let mut offset = 0;
1979        let mut quality = CodexQualitySignals::default();
1980        let mut last_response_hash = None;
1981        poll_codex_session_file(
1982            &session_file,
1983            &mut offset,
1984            &mut quality,
1985            &mut last_response_hash,
1986        )
1987        .unwrap();
1988
1989        assert_eq!(
1990            quality.tool_failure_message.as_deref(),
1991            Some("exec_command failed: SandboxDenied { message: \"operation not permitted\" }")
1992        );
1993    }
1994
1995    #[test]
1996    fn watcher_exposes_codex_quality_signals() {
1997        let mut watcher = SessionWatcher::new("%0", "eng-1-1", 300, None);
1998        watcher.tracker = Some(SessionTracker::Codex(CodexSessionTracker {
1999            sessions_root: PathBuf::from("/tmp"),
2000            cwd: PathBuf::from("/repo"),
2001            session_id: None,
2002            session_file: None,
2003            offset: 0,
2004            quality: CodexQualitySignals {
2005                last_response_chars: Some(12),
2006                shortening_streak: 2,
2007                repeated_output_streak: 3,
2008                shrinking_responses: true,
2009                repeated_identical_outputs: true,
2010                tool_failure_message: Some("exec_command failed".to_string()),
2011            },
2012            last_response_hash: Some(simple_hash("same response")),
2013        }));
2014
2015        assert_eq!(
2016            watcher.codex_quality_signals(),
2017            Some(CodexQualitySignals {
2018                last_response_chars: Some(12),
2019                shortening_streak: 2,
2020                repeated_output_streak: 3,
2021                shrinking_responses: true,
2022                repeated_identical_outputs: true,
2023                tool_failure_message: Some("exec_command failed".to_string()),
2024            })
2025        );
2026    }
2027
2028    #[test]
2029    fn watcher_set_session_id_rebinds_codex_tracker() {
2030        let mut watcher = SessionWatcher::new("%0", "eng-1", 300, None);
2031        watcher.tracker = Some(SessionTracker::Codex(CodexSessionTracker {
2032            sessions_root: PathBuf::from("/tmp"),
2033            cwd: PathBuf::from("/repo"),
2034            session_id: Some("old-session".to_string()),
2035            session_file: Some(PathBuf::from("/tmp/old-session.jsonl")),
2036            offset: 42,
2037            quality: CodexQualitySignals {
2038                last_response_chars: Some(12),
2039                shortening_streak: 1,
2040                repeated_output_streak: 2,
2041                shrinking_responses: true,
2042                repeated_identical_outputs: true,
2043                tool_failure_message: Some("failure".to_string()),
2044            },
2045            last_response_hash: Some(simple_hash("old")),
2046        }));
2047
2048        watcher.set_session_id(Some("new-session".to_string()));
2049
2050        let Some(SessionTracker::Codex(codex)) = watcher.tracker.as_ref() else {
2051            panic!("expected codex tracker");
2052        };
2053        assert_eq!(codex.session_id.as_deref(), Some("new-session"));
2054        assert!(codex.session_file.is_none());
2055        assert_eq!(codex.offset, 0);
2056        assert_eq!(codex.quality, CodexQualitySignals::default());
2057        assert!(codex.last_response_hash.is_none());
2058    }
2059
2060    #[test]
2061    fn discovers_claude_session_by_exact_cwd() {
2062        let tmp = tempfile::tempdir().unwrap();
2063        let projects_root = tmp.path().join("projects");
2064        let cwd = PathBuf::from("/Users/zedmor/chess_test");
2065        let project_dir = projects_root.join("-Users-zedmor-chess-test");
2066        std::fs::create_dir_all(&project_dir).unwrap();
2067
2068        let session_file = project_dir.join("latest.jsonl");
2069        std::fs::write(
2070            &session_file,
2071            format!(
2072                "{{\"type\":\"user\",\"cwd\":\"{}\",\"message\":{{\"content\":\"hello\"}}}}\n",
2073                cwd.display()
2074            ),
2075        )
2076        .unwrap();
2077
2078        let discovered = discover_claude_session_file(&projects_root, &cwd, None).unwrap();
2079        assert_eq!(discovered.as_deref(), Some(session_file.as_path()));
2080    }
2081
2082    #[test]
2083    fn discover_claude_session_file_requires_exact_session_id_when_configured() {
2084        let tmp = tempfile::tempdir().unwrap();
2085        let projects_root = tmp.path().join("projects");
2086        let cwd = PathBuf::from("/Users/zedmor/chess_test");
2087        let project_dir = projects_root.join("-Users-zedmor-chess-test");
2088        std::fs::create_dir_all(&project_dir).unwrap();
2089
2090        let other_session = project_dir.join("11111111-1111-4111-8111-111111111111.jsonl");
2091        std::fs::write(
2092            &other_session,
2093            format!(
2094                "{{\"type\":\"user\",\"cwd\":\"{}\",\"sessionId\":\"11111111-1111-4111-8111-111111111111\"}}\n",
2095                cwd.display()
2096            ),
2097        )
2098        .unwrap();
2099
2100        let discovered = discover_claude_session_file(
2101            &projects_root,
2102            &cwd,
2103            Some("22222222-2222-4222-8222-222222222222"),
2104        )
2105        .unwrap();
2106        assert!(discovered.is_none());
2107    }
2108
2109    #[test]
2110    fn classify_claude_log_entry_tracks_tool_and_end_turn() {
2111        let tool_use: Value =
2112            serde_json::from_str(r#"{"type":"assistant","message":{"stop_reason":"tool_use"}}"#)
2113                .unwrap();
2114        let end_turn: Value =
2115            serde_json::from_str(r#"{"type":"assistant","message":{"stop_reason":"end_turn"}}"#)
2116                .unwrap();
2117        let tool_result: Value =
2118            serde_json::from_str(r#"{"type":"user","toolUseResult":{"stdout":"ok"}}"#).unwrap();
2119
2120        assert_eq!(classify_claude_log_entry(&tool_use), TrackerState::Active);
2121        assert_eq!(
2122            classify_claude_log_entry(&tool_result),
2123            TrackerState::Active
2124        );
2125        assert_eq!(classify_claude_log_entry(&end_turn), TrackerState::Idle);
2126    }
2127
2128    #[test]
2129    fn parse_claude_session_file_reports_latest_state() {
2130        let tmp = tempfile::tempdir().unwrap();
2131        let session_file = tmp.path().join("session.jsonl");
2132        std::fs::write(
2133            &session_file,
2134            concat!(
2135                "{\"type\":\"user\",\"message\":{\"content\":\"hello\"}}\n",
2136                "{\"type\":\"assistant\",\"message\":{\"stop_reason\":\"tool_use\"}}\n",
2137                "{\"type\":\"user\",\"toolUseResult\":{\"stdout\":\"ok\"}}\n",
2138                "{\"type\":\"assistant\",\"message\":{\"stop_reason\":\"end_turn\"}}\n",
2139            ),
2140        )
2141        .unwrap();
2142
2143        let (state, offset) = parse_claude_session_file(&session_file, 0).unwrap();
2144        assert_eq!(state, TrackerState::Idle);
2145        assert!(offset > 0);
2146    }
2147
2148    #[test]
2149    fn claude_tracker_binding_ignores_historical_state() {
2150        let tmp = tempfile::tempdir().unwrap();
2151        let projects_root = tmp.path().join("projects");
2152        let cwd = tmp
2153            .path()
2154            .join("repo")
2155            .join(".batty")
2156            .join("worktrees")
2157            .join("eng-1");
2158        let project_dir = projects_root.join(cwd.to_string_lossy().replace('/', "-"));
2159        std::fs::create_dir_all(&project_dir).unwrap();
2160
2161        let session_file = project_dir.join("latest.jsonl");
2162        std::fs::write(
2163            &session_file,
2164            concat!(
2165                "{\"type\":\"user\",\"message\":{\"content\":\"hello\"}}\n",
2166                "{\"type\":\"assistant\",\"message\":{\"stop_reason\":\"tool_use\"}}\n",
2167            ),
2168        )
2169        .unwrap();
2170
2171        let mut tracker = ClaudeSessionTracker {
2172            projects_root,
2173            cwd,
2174            session_id: None,
2175            session_file: None,
2176            offset: 0,
2177            last_state: TrackerState::Unknown,
2178        };
2179
2180        assert_eq!(
2181            poll_claude_session(&mut tracker).unwrap(),
2182            TrackerState::Unknown
2183        );
2184        assert_eq!(tracker.offset, current_file_len(&session_file).unwrap());
2185        assert_eq!(tracker.last_state, TrackerState::Unknown);
2186    }
2187
2188    #[test]
2189    fn claude_tracker_rebinds_to_newer_session_file_after_manual_resume() {
2190        let tmp = tempfile::tempdir().unwrap();
2191        let projects_root = tmp.path().join("projects");
2192        let cwd = tmp
2193            .path()
2194            .join("repo")
2195            .join(".batty")
2196            .join("worktrees")
2197            .join("eng-1");
2198        let project_dir = projects_root.join(cwd.to_string_lossy().replace('/', "-"));
2199        std::fs::create_dir_all(&project_dir).unwrap();
2200
2201        let old_session = project_dir.join("11111111-1111-4111-8111-111111111111.jsonl");
2202        std::fs::write(
2203            &old_session,
2204            "{\"type\":\"assistant\",\"message\":{\"stop_reason\":\"end_turn\"}}\n",
2205        )
2206        .unwrap();
2207
2208        let mut tracker = ClaudeSessionTracker {
2209            projects_root: projects_root.clone(),
2210            cwd: cwd.clone(),
2211            session_id: Some("11111111-1111-4111-8111-111111111111".to_string()),
2212            session_file: Some(old_session.clone()),
2213            offset: current_file_len(&old_session).unwrap(),
2214            last_state: TrackerState::Idle,
2215        };
2216
2217        std::thread::sleep(std::time::Duration::from_millis(20));
2218
2219        let new_session = project_dir.join("22222222-2222-4222-8222-222222222222.jsonl");
2220        std::fs::write(
2221            &new_session,
2222            "{\"type\":\"assistant\",\"message\":{\"stop_reason\":\"end_turn\"}}\n",
2223        )
2224        .unwrap();
2225
2226        assert_eq!(
2227            poll_claude_session(&mut tracker).unwrap(),
2228            TrackerState::Unknown
2229        );
2230        assert_eq!(tracker.session_file.as_deref(), Some(new_session.as_path()));
2231        assert_eq!(
2232            tracker.session_id.as_deref(),
2233            Some("22222222-2222-4222-8222-222222222222")
2234        );
2235        assert_eq!(tracker.offset, current_file_len(&new_session).unwrap());
2236    }
2237
2238    #[test]
2239    fn watcher_exposes_tracker_session_id_from_bound_file() {
2240        let mut watcher = SessionWatcher::new("%0", "architect", 300, None);
2241        watcher.tracker = Some(SessionTracker::Claude(ClaudeSessionTracker {
2242            projects_root: PathBuf::from("/tmp"),
2243            cwd: PathBuf::from("/repo"),
2244            session_id: Some("1e94dc68-6004-402a-9a7b-1bfca674806e".to_string()),
2245            session_file: Some(PathBuf::from(
2246                "/tmp/-Users-zedmor-project/1e94dc68-6004-402a-9a7b-1bfca674806e.jsonl",
2247            )),
2248            offset: 0,
2249            last_state: TrackerState::Unknown,
2250        }));
2251
2252        assert_eq!(
2253            watcher.current_session_id().as_deref(),
2254            Some("1e94dc68-6004-402a-9a7b-1bfca674806e")
2255        );
2256    }
2257
2258    fn production_unwrap_expect_count(source: &str) -> usize {
2259        let prod = if let Some(pos) = source.find("\n#[cfg(test)]\nmod tests") {
2260            &source[..pos]
2261        } else {
2262            source
2263        };
2264        prod.lines()
2265            .filter(|line| {
2266                let trimmed = line.trim();
2267                !trimmed.starts_with("#[cfg(test)]")
2268                    && (trimmed.contains(".unwrap(") || trimmed.contains(".expect("))
2269            })
2270            .count()
2271    }
2272
2273    #[test]
2274    fn production_watcher_has_no_unwrap_or_expect_calls() {
2275        let src = include_str!("watcher.rs");
2276        assert_eq!(
2277            production_unwrap_expect_count(src),
2278            0,
2279            "production watcher.rs should avoid unwrap/expect"
2280        );
2281    }
2282
2283    #[test]
2284    fn secs_since_last_output_change_starts_at_zero() {
2285        let w = SessionWatcher::new("%0", "eng-1-1", 300, None);
2286        // Freshly created watcher — elapsed should be near zero.
2287        assert!(w.secs_since_last_output_change() < 2);
2288    }
2289
2290    #[test]
2291    fn activate_resets_last_output_changed_at() {
2292        let mut w = SessionWatcher::new("%0", "eng-1-1", 300, None);
2293        // Simulate time passing by backdating the field.
2294        w.last_output_changed_at = Instant::now() - std::time::Duration::from_secs(600);
2295        assert!(w.secs_since_last_output_change() >= 600);
2296
2297        w.activate();
2298        assert!(w.secs_since_last_output_change() < 2);
2299    }
2300
2301    // --- Readiness gate tests ---
2302
2303    #[test]
2304    fn new_watcher_is_not_ready_for_delivery() {
2305        let w = SessionWatcher::new("%0", "eng-1-1", 300, None);
2306        assert!(!w.is_ready_for_delivery());
2307        assert_eq!(w.state, WatcherState::Idle);
2308    }
2309
2310    #[test]
2311    fn confirm_ready_sets_ready_state() {
2312        let mut w = SessionWatcher::new("%0", "eng-1-1", 300, None);
2313        w.confirm_ready();
2314        assert!(w.is_ready_for_delivery());
2315        assert_eq!(w.state, WatcherState::Ready);
2316    }
2317
2318    #[test]
2319    fn activate_sets_ready_confirmed() {
2320        let mut w = SessionWatcher::new("%0", "eng-1-1", 300, None);
2321        assert!(!w.is_ready_for_delivery());
2322        w.activate();
2323        assert!(w.is_ready_for_delivery());
2324        assert_eq!(w.state, WatcherState::Active);
2325    }
2326
2327    #[test]
2328    fn deactivate_preserves_readiness() {
2329        let mut w = SessionWatcher::new("%0", "eng-1-1", 300, None);
2330        w.activate();
2331        assert!(w.is_ready_for_delivery());
2332        w.deactivate();
2333        assert!(w.is_ready_for_delivery());
2334        assert_eq!(w.state, WatcherState::Idle);
2335    }
2336
2337    #[test]
2338    fn ready_state_transitions_to_active_on_work() {
2339        assert_eq!(
2340            next_state_after_capture(
2341                TrackerKind::None,
2342                ScreenState::Active,
2343                TrackerState::Unknown,
2344                WatcherState::Ready,
2345            ),
2346            WatcherState::Active
2347        );
2348    }
2349
2350    #[test]
2351    fn ready_state_transitions_to_idle_on_idle_screen() {
2352        assert_eq!(
2353            next_state_after_capture(
2354                TrackerKind::None,
2355                ScreenState::Idle,
2356                TrackerState::Unknown,
2357                WatcherState::Ready,
2358            ),
2359            WatcherState::Idle
2360        );
2361    }
2362
2363    #[test]
2364    fn ready_state_stays_on_unknown_screen() {
2365        assert_eq!(
2366            next_state_after_capture(
2367                TrackerKind::None,
2368                ScreenState::Unknown,
2369                TrackerState::Unknown,
2370                WatcherState::Ready,
2371            ),
2372            WatcherState::Ready
2373        );
2374    }
2375
2376    #[test]
2377    fn confirm_ready_on_already_idle_with_completion_does_not_override() {
2378        let mut w = SessionWatcher::new("%0", "eng-1-1", 300, None);
2379        w.activate();
2380        w.deactivate();
2381        w.confirm_ready();
2382        assert_eq!(w.state, WatcherState::Idle);
2383        assert!(w.is_ready_for_delivery());
2384    }
2385}