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
431pub fn fetch_status_agents_over_http(broker_url: &str) -> Result<Vec<StatusAgent>, PawError> {
437 parse_status_agents(&fetch_status_body(broker_url)?)
438}
439
440fn fetch_status_body(broker_url: &str) -> Result<String, PawError> {
442 let addr = broker_url.strip_prefix("http://").unwrap_or(broker_url);
443 let socket_addr = if let Ok(a) = addr.parse() {
444 a
445 } else {
446 use std::net::ToSocketAddrs;
447 addr.to_socket_addrs()
448 .map_err(|e| PawError::SessionError(format!("invalid broker address {addr}: {e}")))?
449 .next()
450 .ok_or_else(|| {
451 PawError::SessionError(format!("broker address {addr} resolved to no addrs"))
452 })?
453 };
454
455 let mut stream = TcpStream::connect_timeout(&socket_addr, Duration::from_millis(500))
456 .map_err(|e| PawError::SessionError(format!("failed to connect to broker: {e}")))?;
457 stream.set_read_timeout(Some(Duration::from_secs(2))).ok();
458 stream.set_write_timeout(Some(Duration::from_secs(2))).ok();
459
460 let request = format!("GET /status HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n");
461 stream
462 .write_all(request.as_bytes())
463 .map_err(|e| PawError::SessionError(format!("failed to write status request: {e}")))?;
464
465 let mut response = String::new();
466 let _ = stream.read_to_string(&mut response);
467
468 if !(response.starts_with("HTTP/1.1 200") || response.starts_with("HTTP/1.0 200")) {
469 return Err(PawError::SessionError(format!(
470 "broker /status returned non-200: {}",
471 response.lines().next().unwrap_or("<empty>")
472 )));
473 }
474
475 let body_start = response
476 .find("\r\n\r\n")
477 .map(|i| i + 4)
478 .ok_or_else(|| PawError::SessionError("malformed broker /status response".to_string()))?;
479 Ok(response[body_start..].to_string())
480}
481
482fn list_pane_paths(session: &str) -> Result<String, PawError> {
485 let output = std::process::Command::new("tmux")
486 .args([
487 "list-panes",
488 "-t",
489 &format!("{session}:0"),
490 "-F",
491 "#{pane_index} #{pane_current_path}",
492 ])
493 .output()
494 .map_err(|e| PawError::SessionError(format!("tmux list-panes failed: {e}")))?;
495 if !output.status.success() {
496 return Err(PawError::SessionError(format!(
497 "tmux list-panes exited with {}",
498 output.status
499 )));
500 }
501 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
502}
503
504fn detect_pane_mode(session: &str, pane_index: usize) -> Mode {
506 let title = std::process::Command::new("tmux")
507 .args([
508 "display-message",
509 "-t",
510 &format!("{session}:0.{pane_index}"),
511 "-p",
512 "#{pane_title}",
513 ])
514 .output()
515 .ok()
516 .map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
517 .unwrap_or_default();
518 let capture =
519 crate::supervisor::permission_prompt::capture_pane(session, pane_index).unwrap_or_default();
520 detect_mode(&title, &capture)
521}
522
523#[cfg(test)]
524mod tests {
525 use super::*;
526 use std::cell::Cell;
527
528 const STATUS_JSON: &str = r#"{
529 "git_paw": true,
530 "version": "0.6.0",
531 "uptime_seconds": 42,
532 "agents": [
533 {"agent_id": "feat-auth", "cli": "claude", "status": "working", "last_seen_seconds": 3, "summary": ""},
534 {"agent_id": "feat-api", "cli": "", "status": "blocked", "last_seen_seconds": 90, "summary": ""},
535 {"agent_id": "supervisor", "cli": "claude", "status": "working", "last_seen_seconds": 1, "summary": ""}
536 ]
537 }"#;
538
539 fn fixture_inventory() -> AgentInventory {
540 let agents = parse_status_agents(STATUS_JSON).unwrap();
541 let panes = parse_pane_paths(
543 "0 /home/user/myproj\n1 /home/user/myproj-feat-api\n2 /home/user/myproj-feat-auth\n",
544 );
545 let mut modes = HashMap::new();
546 modes.insert(2usize, Mode::AcceptEdits);
547 let entries = join_inventory(agents, &panes, &modes);
548 AgentInventory {
549 entries,
550 refreshed_at: Instant::now(),
551 }
552 }
553
554 #[test]
555 fn parse_status_agents_reads_all_rows() {
556 let agents = parse_status_agents(STATUS_JSON).unwrap();
557 assert_eq!(agents.len(), 3);
558 assert_eq!(agents[0].agent_id, "feat-auth");
559 assert_eq!(agents[0].cli, "claude");
560 assert_eq!(agents[1].last_seen_seconds, 90);
561 }
562
563 #[test]
564 fn parse_pane_paths_handles_spaces_and_skips_garbage() {
565 let panes =
566 parse_pane_paths("0 /home/user/my proj\n1 /home/user/wt-feat-x\nnot-a-pane line\n");
567 assert_eq!(panes.len(), 2);
568 assert_eq!(panes[0], (0, "/home/user/my proj".to_string()));
569 assert_eq!(panes[1], (1, "/home/user/wt-feat-x".to_string()));
570 }
571
572 #[test]
573 fn pane_index_is_path_resolved_not_ordered() {
574 let inv = fixture_inventory();
575 let api = inv.find("feat-api").unwrap();
576 let auth = inv.find("feat-auth").unwrap();
577 assert_eq!(api.pane_index, Some(1));
580 assert_eq!(auth.pane_index, Some(2));
581 }
582
583 #[test]
584 fn match_pane_does_not_partial_match_prefix() {
585 let panes = parse_pane_paths("1 /home/user/proj-feat-api\n");
586 assert_eq!(match_pane("feat-a", &panes), None);
588 assert_eq!(match_pane("feat-api", &panes), Some(1));
589 }
590
591 #[test]
592 fn supervisor_resolves_to_pane_zero() {
593 let inv = fixture_inventory();
594 let sup = inv.find("supervisor").unwrap();
595 assert_eq!(sup.pane_index, Some(0));
596 }
597
598 #[test]
599 fn empty_cli_maps_to_none() {
600 let inv = fixture_inventory();
601 assert_eq!(
602 inv.find("feat-auth").unwrap().cli.as_deref(),
603 Some("claude")
604 );
605 assert_eq!(inv.find("feat-api").unwrap().cli, None);
606 }
607
608 #[test]
609 fn agent_removed_mid_grid_drops_pane_index() {
610 let agents = parse_status_agents(STATUS_JSON).unwrap();
613 let panes = parse_pane_paths("0 /home/user/myproj\n2 /home/user/myproj-feat-auth\n");
614 let entries = join_inventory(agents, &panes, &HashMap::new());
615 let inv = AgentInventory {
616 entries,
617 refreshed_at: Instant::now(),
618 };
619 assert_eq!(inv.find("feat-api").unwrap().pane_index, None);
620 assert_eq!(inv.find("feat-auth").unwrap().pane_index, Some(2));
621 }
622
623 #[test]
624 fn detect_mode_accept_edits() {
625 assert_eq!(
626 detect_mode("", "⏵⏵ accept edits on (shift+tab to cycle)"),
627 Mode::AcceptEdits
628 );
629 assert_eq!(
630 detect_mode("claude — bypass permissions", ""),
631 Mode::AcceptEdits
632 );
633 }
634
635 #[test]
636 fn detect_mode_interactive_prompt() {
637 assert_eq!(
638 detect_mode("", "Do you want to proceed?\n❯ 1. Yes"),
639 Mode::Interactive
640 );
641 }
642
643 #[test]
644 fn detect_mode_unknown_when_no_signal() {
645 assert_eq!(
646 detect_mode("", "Boondoggling… (esc to interrupt)"),
647 Mode::Unknown
648 );
649 }
650
651 #[test]
652 fn unknown_mode_signals_join_to_unknown() {
653 let inv = fixture_inventory();
654 assert_eq!(inv.find("feat-api").unwrap().mode, Mode::Unknown);
656 assert_eq!(inv.find("feat-auth").unwrap().mode, Mode::AcceptEdits);
657 }
658
659 #[test]
660 fn validate_target_accepts_slug_and_slash_form() {
661 let inv = fixture_inventory();
662 assert!(validate_target(&inv, "feat-auth").is_ok());
663 assert_eq!(
665 validate_target(&inv, "feat/auth").unwrap().branch_id,
666 "feat-auth"
667 );
668 }
669
670 #[test]
671 fn validate_target_unknown_returns_candidate_list() {
672 let inv = fixture_inventory();
673 let err = validate_target(&inv, "feat/ghost").unwrap_err();
674 match err {
675 ValidationError::UnknownTarget { target, candidates } => {
676 assert_eq!(target, "feat/ghost");
677 assert_eq!(
679 candidates,
680 vec!["feat-api".to_string(), "feat-auth".to_string()]
681 );
682 }
683 }
684 }
685
686 #[test]
687 fn validation_error_display_lists_candidates() {
688 let err = ValidationError::UnknownTarget {
689 target: "feat/ghost".to_string(),
690 candidates: vec!["feat/a".to_string(), "feat/b".to_string()],
691 };
692 let msg = err.to_string();
693 assert!(msg.contains("feat/ghost"));
694 assert!(msg.contains("feat/a, feat/b"), "got: {msg}");
695 }
696
697 fn snapshot_now() -> AgentInventory {
700 AgentInventory {
701 entries: Vec::new(),
702 refreshed_at: Instant::now(),
703 }
704 }
705
706 #[test]
707 fn cache_starts_empty_and_not_fresh() {
708 let cache = InventoryCache::from_seconds(60);
709 assert!(cache.snapshot().is_none());
710 assert!(!cache.is_fresh_at(Instant::now()));
711 }
712
713 #[test]
714 fn rapid_lookups_within_window_refresh_once() {
715 let calls = Cell::new(0u32);
716 let mut cache = InventoryCache::from_seconds(60);
717 let refresh = || {
718 calls.set(calls.get() + 1);
719 Ok::<_, ()>(snapshot_now())
720 };
721 cache.get_or_refresh(Instant::now(), refresh).unwrap();
723 let refresh2 = || {
724 calls.set(calls.get() + 1);
725 Ok::<_, ()>(snapshot_now())
726 };
727 cache.get_or_refresh(Instant::now(), refresh2).unwrap();
728 assert_eq!(calls.get(), 1, "fresh cache must not re-poll the broker");
729 }
730
731 #[test]
732 fn stale_snapshot_triggers_refresh() {
733 let mut cache = InventoryCache::from_seconds(60);
734 let stale = AgentInventory {
736 entries: Vec::new(),
737 refreshed_at: Instant::now()
738 .checked_sub(Duration::from_mins(2))
739 .expect("instant in range"),
740 };
741 cache.store(stale);
742 assert!(!cache.is_fresh_at(Instant::now()));
743
744 let calls = Cell::new(0u32);
745 cache
746 .get_or_refresh(Instant::now(), || {
747 calls.set(calls.get() + 1);
748 Ok::<_, ()>(snapshot_now())
749 })
750 .unwrap();
751 assert_eq!(calls.get(), 1, "stale cache must rebuild");
752 assert!(cache.is_fresh_at(Instant::now()));
753 }
754
755 fn spawn_status_server(body: &'static str) -> String {
760 use std::net::TcpListener;
761 let listener = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port");
762 let addr = listener.local_addr().expect("local addr");
763 std::thread::spawn(move || {
764 if let Ok((mut stream, _)) = listener.accept() {
765 let mut buf = [0u8; 1024];
766 let _ = stream.read(&mut buf);
767 let resp = format!(
768 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
769 body.len()
770 );
771 let _ = stream.write_all(resp.as_bytes());
772 }
773 });
774 format!("http://{addr}")
775 }
776
777 #[test]
778 fn build_inventory_against_fake_broker_no_tmux() {
779 let url = spawn_status_server(STATUS_JSON);
780 let inv = build_inventory(&url, "paw-nonexistent-xyz-123").expect("inventory builds");
783 assert_eq!(inv.entries.len(), 3);
784 assert_eq!(inv.find("feat-auth").unwrap().pane_index, None);
785 assert_eq!(inv.find("feat-auth").unwrap().mode, Mode::Unknown);
786 assert_eq!(inv.find("supervisor").unwrap().pane_index, Some(0));
787 }
788
789 #[test]
790 fn build_inventory_unreachable_broker_errors() {
791 assert!(build_inventory("http://127.0.0.1:1", "x").is_err());
793 }
794
795 #[test]
796 fn parse_status_agents_rejects_garbage() {
797 assert!(parse_status_agents("not json at all").is_err());
798 }
799
800 #[test]
801 fn detect_pane_mode_helper_on_dead_session_is_unknown() {
802 assert_eq!(
804 detect_pane_mode("paw-nonexistent-xyz-123", 9),
805 Mode::Unknown
806 );
807 }
808}