1use std::collections::HashMap;
17use std::fmt;
18use std::io::{Read, Write};
19use std::net::TcpStream;
20use std::time::{Duration, Instant};
21
22use serde::Deserialize;
23
24use crate::error::PawError;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Mode {
34 AcceptEdits,
36 Interactive,
38 Unknown,
40}
41
42impl Mode {
43 #[must_use]
45 pub fn as_str(self) -> &'static str {
46 match self {
47 Self::AcceptEdits => "accept-edits",
48 Self::Interactive => "interactive",
49 Self::Unknown => "unknown",
50 }
51 }
52}
53
54impl fmt::Display for Mode {
55 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56 f.write_str(self.as_str())
57 }
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct AgentEntry {
63 pub branch_id: String,
66 pub status: String,
68 pub last_seen_seconds: u64,
70 pub cli: Option<String>,
72 pub mode: Mode,
74 pub pane_index: Option<usize>,
76}
77
78#[derive(Debug, Clone)]
80pub struct AgentInventory {
81 pub entries: Vec<AgentEntry>,
83 pub refreshed_at: Instant,
85}
86
87impl AgentInventory {
88 #[must_use]
91 pub fn find(&self, target_id: &str) -> Option<&AgentEntry> {
92 let needle = normalize_id(target_id);
93 self.entries
94 .iter()
95 .find(|e| normalize_id(&e.branch_id) == needle)
96 }
97
98 #[must_use]
101 pub fn candidate_ids(&self) -> Vec<String> {
102 let mut ids: Vec<String> = self
103 .entries
104 .iter()
105 .filter(|e| e.branch_id != "supervisor")
106 .map(|e| e.branch_id.clone())
107 .collect();
108 ids.sort();
109 ids
110 }
111}
112
113fn normalize_id(id: &str) -> String {
117 id.trim().replace('/', "-")
118}
119
120#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
126pub struct StatusAgent {
127 pub agent_id: String,
129 #[serde(default)]
131 pub status: String,
132 #[serde(default)]
134 pub last_seen_seconds: u64,
135 #[serde(default)]
138 pub cli: String,
139}
140
141#[derive(Debug, Deserialize)]
142struct StatusBody {
143 #[serde(default)]
144 agents: Vec<StatusAgent>,
145}
146
147pub fn parse_status_agents(json: &str) -> Result<Vec<StatusAgent>, PawError> {
153 let body: StatusBody = serde_json::from_str(json)
154 .map_err(|e| PawError::SessionError(format!("broker /status parse error: {e}")))?;
155 Ok(body.agents)
156}
157
158#[must_use]
165pub fn parse_pane_paths(output: &str) -> Vec<(usize, String)> {
166 output
167 .lines()
168 .filter_map(|line| {
169 let line = line.trim_end();
170 let (idx, path) = line.split_once(' ')?;
171 let idx: usize = idx.trim().parse().ok()?;
172 Some((idx, path.to_string()))
173 })
174 .collect()
175}
176
177#[must_use]
186pub fn match_pane(agent_id: &str, pane_paths: &[(usize, String)]) -> Option<usize> {
187 if agent_id == "supervisor" {
188 return Some(0);
189 }
190 let suffix = format!("-{agent_id}");
191 pane_paths.iter().find_map(|(idx, path)| {
192 let base = path
193 .trim_end_matches('/')
194 .rsplit('/')
195 .next()
196 .unwrap_or(path);
197 (base == agent_id || base.ends_with(&suffix)).then_some(*idx)
198 })
199}
200
201#[must_use]
207pub fn join_inventory<S: std::hash::BuildHasher>(
208 agents: Vec<StatusAgent>,
209 pane_paths: &[(usize, String)],
210 modes: &HashMap<usize, Mode, S>,
211) -> Vec<AgentEntry> {
212 agents
213 .into_iter()
214 .map(|a| {
215 let pane_index = match_pane(&a.agent_id, pane_paths);
216 let mode = pane_index
217 .and_then(|idx| modes.get(&idx).copied())
218 .unwrap_or(Mode::Unknown);
219 let cli = if a.cli.trim().is_empty() {
220 None
221 } else {
222 Some(a.cli)
223 };
224 AgentEntry {
225 branch_id: a.agent_id,
226 status: a.status,
227 last_seen_seconds: a.last_seen_seconds,
228 cli,
229 mode,
230 pane_index,
231 }
232 })
233 .collect()
234}
235
236#[must_use]
244pub fn detect_mode(pane_title: &str, capture: &str) -> Mode {
245 let hay = format!("{pane_title}\n{capture}").to_lowercase();
246 if hay.contains("accept edits")
247 || hay.contains("accept-edits")
248 || hay.contains("bypass permissions")
249 {
250 Mode::AcceptEdits
251 } else if hay.contains("? for shortcuts")
252 || hay.contains("do you want to proceed")
253 || hay.contains("do you want to allow")
254 || hay.contains("(y/n)")
255 || hay.contains("[y/n]")
256 || hay.contains("❯ 1. yes")
257 {
258 Mode::Interactive
259 } else {
260 Mode::Unknown
261 }
262}
263
264#[derive(Debug, Clone, PartialEq, Eq)]
271pub enum ValidationError {
272 UnknownTarget {
275 target: String,
277 candidates: Vec<String>,
279 },
280}
281
282impl fmt::Display for ValidationError {
283 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
284 match self {
285 Self::UnknownTarget { target, candidates } => {
286 write!(
287 f,
288 "unknown target `{target}`; available agents: {}",
289 candidates.join(", ")
290 )
291 }
292 }
293 }
294}
295
296impl std::error::Error for ValidationError {}
297
298pub fn validate_target<'a>(
309 inventory: &'a AgentInventory,
310 target_id: &str,
311) -> Result<&'a AgentEntry, ValidationError> {
312 inventory
313 .find(target_id)
314 .ok_or_else(|| ValidationError::UnknownTarget {
315 target: target_id.trim().to_string(),
316 candidates: inventory.candidate_ids(),
317 })
318}
319
320#[derive(Debug)]
327pub struct InventoryCache {
328 snapshot: Option<AgentInventory>,
329 max_age: Duration,
330}
331
332impl InventoryCache {
333 #[must_use]
335 pub fn new(max_age: Duration) -> Self {
336 Self {
337 snapshot: None,
338 max_age,
339 }
340 }
341
342 #[must_use]
345 pub fn from_seconds(seconds: u64) -> Self {
346 Self::new(Duration::from_secs(seconds))
347 }
348
349 #[must_use]
351 pub fn max_age(&self) -> Duration {
352 self.max_age
353 }
354
355 #[must_use]
357 pub fn snapshot(&self) -> Option<&AgentInventory> {
358 self.snapshot.as_ref()
359 }
360
361 #[must_use]
363 pub fn is_fresh_at(&self, now: Instant) -> bool {
364 self.snapshot
365 .as_ref()
366 .is_some_and(|s| now.duration_since(s.refreshed_at) < self.max_age)
367 }
368
369 pub fn store(&mut self, snapshot: AgentInventory) {
371 self.snapshot = Some(snapshot);
372 }
373
374 pub fn get_or_refresh<F, E>(&mut self, now: Instant, refresh: F) -> Result<&AgentInventory, E>
389 where
390 F: FnOnce() -> Result<AgentInventory, E>,
391 {
392 if !self.is_fresh_at(now) {
393 let snapshot = refresh()?;
394 self.snapshot = Some(snapshot);
395 }
396 Ok(self
397 .snapshot
398 .as_ref()
399 .expect("snapshot present after refresh"))
400 }
401}
402
403pub fn build_inventory(broker_url: &str, tmux_session: &str) -> Result<AgentInventory, PawError> {
416 let body = fetch_status_body(broker_url)?;
417 let agents = parse_status_agents(&body)?;
418 let pane_output = list_pane_paths(tmux_session).unwrap_or_default();
419 let pane_paths = parse_pane_paths(&pane_output);
420 let mut modes = HashMap::new();
421 for (idx, _) in &pane_paths {
422 modes.insert(*idx, detect_pane_mode(tmux_session, *idx));
423 }
424 let entries = join_inventory(agents, &pane_paths, &modes);
425 Ok(AgentInventory {
426 entries,
427 refreshed_at: Instant::now(),
428 })
429}
430
431fn fetch_status_body(broker_url: &str) -> Result<String, PawError> {
433 let addr = broker_url.strip_prefix("http://").unwrap_or(broker_url);
434 let socket_addr = if let Ok(a) = addr.parse() {
435 a
436 } else {
437 use std::net::ToSocketAddrs;
438 addr.to_socket_addrs()
439 .map_err(|e| PawError::SessionError(format!("invalid broker address {addr}: {e}")))?
440 .next()
441 .ok_or_else(|| {
442 PawError::SessionError(format!("broker address {addr} resolved to no addrs"))
443 })?
444 };
445
446 let mut stream = TcpStream::connect_timeout(&socket_addr, Duration::from_millis(500))
447 .map_err(|e| PawError::SessionError(format!("failed to connect to broker: {e}")))?;
448 stream.set_read_timeout(Some(Duration::from_secs(2))).ok();
449 stream.set_write_timeout(Some(Duration::from_secs(2))).ok();
450
451 let request = format!("GET /status HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n");
452 stream
453 .write_all(request.as_bytes())
454 .map_err(|e| PawError::SessionError(format!("failed to write status request: {e}")))?;
455
456 let mut response = String::new();
457 let _ = stream.read_to_string(&mut response);
458
459 if !(response.starts_with("HTTP/1.1 200") || response.starts_with("HTTP/1.0 200")) {
460 return Err(PawError::SessionError(format!(
461 "broker /status returned non-200: {}",
462 response.lines().next().unwrap_or("<empty>")
463 )));
464 }
465
466 let body_start = response
467 .find("\r\n\r\n")
468 .map(|i| i + 4)
469 .ok_or_else(|| PawError::SessionError("malformed broker /status response".to_string()))?;
470 Ok(response[body_start..].to_string())
471}
472
473fn list_pane_paths(session: &str) -> Result<String, PawError> {
476 let output = std::process::Command::new("tmux")
477 .args([
478 "list-panes",
479 "-t",
480 &format!("{session}:0"),
481 "-F",
482 "#{pane_index} #{pane_current_path}",
483 ])
484 .output()
485 .map_err(|e| PawError::SessionError(format!("tmux list-panes failed: {e}")))?;
486 if !output.status.success() {
487 return Err(PawError::SessionError(format!(
488 "tmux list-panes exited with {}",
489 output.status
490 )));
491 }
492 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
493}
494
495fn detect_pane_mode(session: &str, pane_index: usize) -> Mode {
497 let title = std::process::Command::new("tmux")
498 .args([
499 "display-message",
500 "-t",
501 &format!("{session}:0.{pane_index}"),
502 "-p",
503 "#{pane_title}",
504 ])
505 .output()
506 .ok()
507 .map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
508 .unwrap_or_default();
509 let capture =
510 crate::supervisor::permission_prompt::capture_pane(session, pane_index).unwrap_or_default();
511 detect_mode(&title, &capture)
512}
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517 use std::cell::Cell;
518
519 const STATUS_JSON: &str = r#"{
520 "git_paw": true,
521 "version": "0.6.0",
522 "uptime_seconds": 42,
523 "agents": [
524 {"agent_id": "feat-auth", "cli": "claude", "status": "working", "last_seen_seconds": 3, "summary": ""},
525 {"agent_id": "feat-api", "cli": "", "status": "blocked", "last_seen_seconds": 90, "summary": ""},
526 {"agent_id": "supervisor", "cli": "claude", "status": "working", "last_seen_seconds": 1, "summary": ""}
527 ]
528 }"#;
529
530 fn fixture_inventory() -> AgentInventory {
531 let agents = parse_status_agents(STATUS_JSON).unwrap();
532 let panes = parse_pane_paths(
534 "0 /home/user/myproj\n1 /home/user/myproj-feat-api\n2 /home/user/myproj-feat-auth\n",
535 );
536 let mut modes = HashMap::new();
537 modes.insert(2usize, Mode::AcceptEdits);
538 let entries = join_inventory(agents, &panes, &modes);
539 AgentInventory {
540 entries,
541 refreshed_at: Instant::now(),
542 }
543 }
544
545 #[test]
546 fn parse_status_agents_reads_all_rows() {
547 let agents = parse_status_agents(STATUS_JSON).unwrap();
548 assert_eq!(agents.len(), 3);
549 assert_eq!(agents[0].agent_id, "feat-auth");
550 assert_eq!(agents[0].cli, "claude");
551 assert_eq!(agents[1].last_seen_seconds, 90);
552 }
553
554 #[test]
555 fn parse_pane_paths_handles_spaces_and_skips_garbage() {
556 let panes =
557 parse_pane_paths("0 /home/user/my proj\n1 /home/user/wt-feat-x\nnot-a-pane line\n");
558 assert_eq!(panes.len(), 2);
559 assert_eq!(panes[0], (0, "/home/user/my proj".to_string()));
560 assert_eq!(panes[1], (1, "/home/user/wt-feat-x".to_string()));
561 }
562
563 #[test]
564 fn pane_index_is_path_resolved_not_ordered() {
565 let inv = fixture_inventory();
566 let api = inv.find("feat-api").unwrap();
567 let auth = inv.find("feat-auth").unwrap();
568 assert_eq!(api.pane_index, Some(1));
571 assert_eq!(auth.pane_index, Some(2));
572 }
573
574 #[test]
575 fn match_pane_does_not_partial_match_prefix() {
576 let panes = parse_pane_paths("1 /home/user/proj-feat-api\n");
577 assert_eq!(match_pane("feat-a", &panes), None);
579 assert_eq!(match_pane("feat-api", &panes), Some(1));
580 }
581
582 #[test]
583 fn supervisor_resolves_to_pane_zero() {
584 let inv = fixture_inventory();
585 let sup = inv.find("supervisor").unwrap();
586 assert_eq!(sup.pane_index, Some(0));
587 }
588
589 #[test]
590 fn empty_cli_maps_to_none() {
591 let inv = fixture_inventory();
592 assert_eq!(
593 inv.find("feat-auth").unwrap().cli.as_deref(),
594 Some("claude")
595 );
596 assert_eq!(inv.find("feat-api").unwrap().cli, None);
597 }
598
599 #[test]
600 fn agent_removed_mid_grid_drops_pane_index() {
601 let agents = parse_status_agents(STATUS_JSON).unwrap();
604 let panes = parse_pane_paths("0 /home/user/myproj\n2 /home/user/myproj-feat-auth\n");
605 let entries = join_inventory(agents, &panes, &HashMap::new());
606 let inv = AgentInventory {
607 entries,
608 refreshed_at: Instant::now(),
609 };
610 assert_eq!(inv.find("feat-api").unwrap().pane_index, None);
611 assert_eq!(inv.find("feat-auth").unwrap().pane_index, Some(2));
612 }
613
614 #[test]
615 fn detect_mode_accept_edits() {
616 assert_eq!(
617 detect_mode("", "⏵⏵ accept edits on (shift+tab to cycle)"),
618 Mode::AcceptEdits
619 );
620 assert_eq!(
621 detect_mode("claude — bypass permissions", ""),
622 Mode::AcceptEdits
623 );
624 }
625
626 #[test]
627 fn detect_mode_interactive_prompt() {
628 assert_eq!(
629 detect_mode("", "Do you want to proceed?\n❯ 1. Yes"),
630 Mode::Interactive
631 );
632 }
633
634 #[test]
635 fn detect_mode_unknown_when_no_signal() {
636 assert_eq!(
637 detect_mode("", "Boondoggling… (esc to interrupt)"),
638 Mode::Unknown
639 );
640 }
641
642 #[test]
643 fn unknown_mode_signals_join_to_unknown() {
644 let inv = fixture_inventory();
645 assert_eq!(inv.find("feat-api").unwrap().mode, Mode::Unknown);
647 assert_eq!(inv.find("feat-auth").unwrap().mode, Mode::AcceptEdits);
648 }
649
650 #[test]
651 fn validate_target_accepts_slug_and_slash_form() {
652 let inv = fixture_inventory();
653 assert!(validate_target(&inv, "feat-auth").is_ok());
654 assert_eq!(
656 validate_target(&inv, "feat/auth").unwrap().branch_id,
657 "feat-auth"
658 );
659 }
660
661 #[test]
662 fn validate_target_unknown_returns_candidate_list() {
663 let inv = fixture_inventory();
664 let err = validate_target(&inv, "feat/ghost").unwrap_err();
665 match err {
666 ValidationError::UnknownTarget { target, candidates } => {
667 assert_eq!(target, "feat/ghost");
668 assert_eq!(
670 candidates,
671 vec!["feat-api".to_string(), "feat-auth".to_string()]
672 );
673 }
674 }
675 }
676
677 #[test]
678 fn validation_error_display_lists_candidates() {
679 let err = ValidationError::UnknownTarget {
680 target: "feat/ghost".to_string(),
681 candidates: vec!["feat/a".to_string(), "feat/b".to_string()],
682 };
683 let msg = err.to_string();
684 assert!(msg.contains("feat/ghost"));
685 assert!(msg.contains("feat/a, feat/b"), "got: {msg}");
686 }
687
688 fn snapshot_now() -> AgentInventory {
691 AgentInventory {
692 entries: Vec::new(),
693 refreshed_at: Instant::now(),
694 }
695 }
696
697 #[test]
698 fn cache_starts_empty_and_not_fresh() {
699 let cache = InventoryCache::from_seconds(60);
700 assert!(cache.snapshot().is_none());
701 assert!(!cache.is_fresh_at(Instant::now()));
702 }
703
704 #[test]
705 fn rapid_lookups_within_window_refresh_once() {
706 let calls = Cell::new(0u32);
707 let mut cache = InventoryCache::from_seconds(60);
708 let refresh = || {
709 calls.set(calls.get() + 1);
710 Ok::<_, ()>(snapshot_now())
711 };
712 cache.get_or_refresh(Instant::now(), refresh).unwrap();
714 let refresh2 = || {
715 calls.set(calls.get() + 1);
716 Ok::<_, ()>(snapshot_now())
717 };
718 cache.get_or_refresh(Instant::now(), refresh2).unwrap();
719 assert_eq!(calls.get(), 1, "fresh cache must not re-poll the broker");
720 }
721
722 #[test]
723 fn stale_snapshot_triggers_refresh() {
724 let mut cache = InventoryCache::from_seconds(60);
725 let stale = AgentInventory {
727 entries: Vec::new(),
728 refreshed_at: Instant::now()
729 .checked_sub(Duration::from_mins(2))
730 .expect("instant in range"),
731 };
732 cache.store(stale);
733 assert!(!cache.is_fresh_at(Instant::now()));
734
735 let calls = Cell::new(0u32);
736 cache
737 .get_or_refresh(Instant::now(), || {
738 calls.set(calls.get() + 1);
739 Ok::<_, ()>(snapshot_now())
740 })
741 .unwrap();
742 assert_eq!(calls.get(), 1, "stale cache must rebuild");
743 assert!(cache.is_fresh_at(Instant::now()));
744 }
745
746 fn spawn_status_server(body: &'static str) -> String {
751 use std::net::TcpListener;
752 let listener = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port");
753 let addr = listener.local_addr().expect("local addr");
754 std::thread::spawn(move || {
755 if let Ok((mut stream, _)) = listener.accept() {
756 let mut buf = [0u8; 1024];
757 let _ = stream.read(&mut buf);
758 let resp = format!(
759 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
760 body.len()
761 );
762 let _ = stream.write_all(resp.as_bytes());
763 }
764 });
765 format!("http://{addr}")
766 }
767
768 #[test]
769 fn build_inventory_against_fake_broker_no_tmux() {
770 let url = spawn_status_server(STATUS_JSON);
771 let inv = build_inventory(&url, "paw-nonexistent-xyz-123").expect("inventory builds");
774 assert_eq!(inv.entries.len(), 3);
775 assert_eq!(inv.find("feat-auth").unwrap().pane_index, None);
776 assert_eq!(inv.find("feat-auth").unwrap().mode, Mode::Unknown);
777 assert_eq!(inv.find("supervisor").unwrap().pane_index, Some(0));
778 }
779
780 #[test]
781 fn build_inventory_unreachable_broker_errors() {
782 assert!(build_inventory("http://127.0.0.1:1", "x").is_err());
784 }
785
786 #[test]
787 fn parse_status_agents_rejects_garbage() {
788 assert!(parse_status_agents("not json at all").is_err());
789 }
790
791 #[test]
792 fn detect_pane_mode_helper_on_dead_session_is_unknown() {
793 assert_eq!(
795 detect_pane_mode("paw-nonexistent-xyz-123", 9),
796 Mode::Unknown
797 );
798 }
799}