Skip to main content

tmx/agents/
mod.rs

1//! Agent dashboard — shows which Claude/Codex agents are running in which
2//! tmux panes, with cwd, task, and review/intent status.
3//!
4//! Data sources are all local: tmux itself + `~/.claude/state/`. No daemon.
5//! Identity anchor is the tmux pane PID; cwd is for state-marker matching
6//! and display, never for identity. See `state.rs` for the matching logic.
7
8pub mod attention;
9pub mod classify;
10pub mod collector;
11pub mod panes;
12pub mod proc;
13pub mod session_meta;
14pub mod state;
15pub mod ui;
16
17use std::path::PathBuf;
18use std::time::SystemTime;
19
20use serde::Serialize;
21
22#[derive(Debug, Clone, Serialize)]
23pub struct Agent {
24    /// Stable identity used as the cursor anchor across snapshot refreshes.
25    /// `pane:<target>` for tmux panes, `codex:<job_id>` for background jobs.
26    pub id: String,
27    pub pane: Option<PaneLocator>,
28    pub kind: AgentKind,
29    pub status: Status,
30    pub cwd: PathBuf,
31    pub repo_name: String,
32    pub flags: Flags,
33    pub extra: String,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
37pub struct PaneLocator {
38    pub session: String,
39    pub window: u32,
40    pub pane: u32,
41    pub pane_pid: u32,
42}
43
44impl PaneLocator {
45    pub fn target(&self) -> String {
46        format!("{}:{}.{}", self.session, self.window, self.pane)
47    }
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
51#[serde(rename_all = "kebab-case")]
52pub enum AgentKind {
53    Claude,
54    Codex,
55    Shell,
56    Other,
57}
58
59impl AgentKind {
60    pub fn from_command(cmd: &str) -> Self {
61        match cmd {
62            "claude" => Self::Claude,
63            "codex" => Self::Codex,
64            "zsh" | "bash" | "fish" | "sh" => Self::Shell,
65            _ => Self::Other,
66        }
67    }
68
69    pub fn label(self) -> &'static str {
70        match self {
71            Self::Claude => "claude",
72            Self::Codex => "codex",
73            Self::Shell => "shell",
74            Self::Other => "other",
75        }
76    }
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
80#[serde(rename_all = "kebab-case")]
81pub enum Status {
82    /// Claude/Codex is mid-turn (running tools or composing). No input
83    /// from the user is needed.
84    Working,
85    /// Claude/Codex is parked at the chat prompt. User can type a new
86    /// request whenever.
87    Ready,
88    /// Claude/Codex is blocking on a selection/permission dialog and
89    /// will not progress until the user acts.
90    AwaitingDecision,
91    /// Pane has no recognised Claude/Codex UI (plain shell, nvim, etc.).
92    Idle,
93    /// Codex-companion background job (no associated tmux pane).
94    Background,
95}
96
97impl Status {
98    pub fn glyph(self) -> &'static str {
99        match self {
100            Self::Working => "●",
101            Self::Ready => "◐",
102            Self::AwaitingDecision => "⚠",
103            Self::Idle => "○",
104            Self::Background => "▷",
105        }
106    }
107
108    pub fn label(self) -> &'static str {
109        match self {
110            Self::Working => "working",
111            Self::Ready => "ready",
112            Self::AwaitingDecision => "decision",
113            Self::Idle => "idle",
114            Self::Background => "bg",
115        }
116    }
117}
118
119#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
120pub struct Flags {
121    pub has_intent: bool,
122    pub blocked: bool,
123    pub reviewed_fresh: bool,
124}
125
126#[derive(Debug, Clone, Serialize)]
127pub struct Snapshot {
128    pub agents: Vec<Agent>,
129    /// Capture time as epoch milliseconds — stable across timezones, easy
130    /// to diff between snapshots when consumers poll.
131    #[serde(rename = "captured_at_ms", serialize_with = "serialize_epoch_ms")]
132    pub captured_at: SystemTime,
133    /// Total blocked sessions in `~/.claude/state/` — not always pane-mappable.
134    pub global_blocked: usize,
135    /// Non-fatal error from the most recent `tmux list-panes` call, if any.
136    /// Surfaced as a banner so the user doesn't mistake "tmux is broken" for
137    /// "nothing is running."
138    pub panes_error: Option<String>,
139    /// Cross-tool attention queue (Claude stop / notification / codex turn
140    /// hooks). Newest first. Pre-filtered to the same 1h cutoff
141    /// `attention-picker.sh` uses so the two surfaces agree on what's pending.
142    pub attention: Vec<attention::AttentionEntry>,
143}
144
145fn serialize_epoch_ms<S: serde::Serializer>(t: &SystemTime, s: S) -> Result<S::Ok, S::Error> {
146    let ms = t
147        .duration_since(std::time::UNIX_EPOCH)
148        .map(|d| d.as_millis() as i64)
149        .unwrap_or(0);
150    s.serialize_i64(ms)
151}
152
153impl Snapshot {
154    pub fn empty() -> Self {
155        Self {
156            agents: Vec::new(),
157            captured_at: SystemTime::now(),
158            global_blocked: 0,
159            panes_error: None,
160            attention: Vec::new(),
161        }
162    }
163}
164
165pub fn run() -> anyhow::Result<()> {
166    ui::run()
167}