1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum WatcherState {
20 Active,
22 Ready,
25 Idle,
27 PaneDead,
29 ContextExhausted,
31}
32
33pub struct SessionWatcher {
34 pub pane_id: String,
35 #[allow(dead_code)] pub member_name: String,
37 pub state: WatcherState,
38 completion_observed: bool,
39 last_output_hash: u64,
40 last_capture: String,
41 last_output_changed_at: Instant,
43 tracker: Option<SessionTracker>,
44 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 pub fn poll(&mut self) -> Result<WatcherState> {
146 if !tmux::pane_exists(&self.pane_id) {
148 self.state = WatcherState::PaneDead;
149 return Ok(self.state);
150 }
151
152 if tmux::pane_dead(&self.pane_id).unwrap_or(false) {
154 self.state = WatcherState::PaneDead;
155 return Ok(self.state);
156 }
157
158 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 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 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 pub fn is_ready_for_delivery(&self) -> bool {
238 self.ready_confirmed
239 }
240
241 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 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 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 pub fn deactivate(&mut self) {
307 self.state = WatcherState::Idle;
308 self.completion_observed = false;
309 }
310
311 pub fn secs_since_last_output_change(&self) -> u64 {
313 self.last_output_changed_at.elapsed().as_secs()
314 }
315
316 #[allow(dead_code)] pub fn last_output(&self) -> &str {
319 &self.last_capture
320 }
321
322 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 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
441pub fn is_at_agent_prompt(capture: &str) -> bool {
450 let trimmed = recent_non_empty_lines(capture, 12);
453
454 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 if starts_with_agent_prompt(l, '❯') {
467 return true;
468 }
469 if starts_with_agent_prompt(l, '›') {
471 return true;
472 }
473 if looks_like_kiro_prompt(l) {
475 return true;
476 }
477 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 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 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 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(¤t_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 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 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 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 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 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 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 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 #[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}