Skip to main content

plannotator_tui_hosts/
lib.rs

1//! Find a coding agent's transcript on disk and read its recent rendered messages.
2//!
3//! Everything here is pure over strings, slices, and injected directories: no `~`, no
4//! environment reads, no spawned processes. The binary passes in `sessions_dir`,
5//! `projects_dir`, a process-table snapshot, and an env lookup. The rules come from
6//! Plannotator's `last` implementation (see `docs/decisions.md`, decision 9).
7
8pub mod claude;
9pub mod codex;
10pub mod copilot;
11pub mod droid;
12pub mod pi;
13
14use std::path::PathBuf;
15
16/// A supported agent host.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Host {
19    ClaudeCode,
20    Codex,
21    /// GitHub Copilot CLI: `~/.copilot/session-state/<uuid>/events.jsonl`.
22    Copilot,
23    /// Droid (Factory): `~/.factory/sessions/<slug>/<session>.jsonl`, Claude's shape, file order.
24    Droid,
25    Pi,
26}
27
28impl Host {
29    /// The short label Herdr and the UI use.
30    pub fn label(self) -> &'static str {
31        match self {
32            Self::ClaudeCode => "claude",
33            Self::Codex => "codex",
34            Self::Copilot => "copilot",
35            Self::Droid => "droid",
36            Self::Pi => "pi",
37        }
38    }
39
40    /// Every host with a transcript reader, for messages that list them.
41    pub const ALL: [Host; 5] = [Host::ClaudeCode, Host::Codex, Host::Pi, Host::Copilot, Host::Droid];
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum Role {
46    Human,
47    Assistant,
48}
49
50/// One rendered message: every text block that shares the host's message id, in order.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct Message {
53    pub id: String,
54    pub role: Role,
55    pub text: String,
56    /// The host's timestamp, verbatim, when it has one.
57    pub at: Option<String>,
58}
59
60/// `~/.claude/sessions/<pid>.json`: one running Claude Code session.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct SessionMeta {
63    pub pid: u32,
64    pub session_id: String,
65    pub cwd: PathBuf,
66    pub started_at: u64,
67}
68
69#[derive(Debug)]
70pub enum HostError {
71    /// No transcript could be found; the message names what was searched.
72    NoTranscript(String),
73    /// A transcript was found but holds no renderable message.
74    NoMessages(String),
75    /// A host was recognised from the environment but is not supported yet.
76    Unsupported(String),
77    Io(std::io::Error),
78}
79
80impl std::fmt::Display for HostError {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        match self {
83            Self::NoTranscript(msg) | Self::NoMessages(msg) => f.write_str(msg),
84            Self::Unsupported(host) => write!(f, "{host} is not supported yet"),
85            Self::Io(err) => write!(f, "{err}"),
86        }
87    }
88}
89
90impl std::error::Error for HostError {}
91
92impl From<std::io::Error> for HostError {
93    fn from(err: std::io::Error) -> Self {
94        Self::Io(err)
95    }
96}
97
98/// Which host launched us, from the environment. `PLANNOTATOR_TUI_HOST` overrides when it
99/// names a known host; then the hosts' own markers, in Plannotator's order; then Claude Code.
100///
101/// Markers for hosts we do not support yet are reported as [`HostError::Unsupported`]
102/// rather than silently treated as Claude Code.
103pub fn detect_host(env: impl Fn(&str) -> Option<String>) -> Result<Host, HostError> {
104    let set = |key: &str| env(key).is_some_and(|v| !v.trim().is_empty());
105    if let Some(name) = env("PLANNOTATOR_TUI_HOST").map(|v| v.trim().to_ascii_lowercase()) {
106        match name.as_str() {
107            "claude" | "claude-code" | "claude_code" => return Ok(Host::ClaudeCode),
108            "codex" => return Ok(Host::Codex),
109            "copilot" | "copilot-cli" | "copilot_cli" => return Ok(Host::Copilot),
110            "droid" | "factory" => return Ok(Host::Droid),
111            "pi" => return Ok(Host::Pi),
112            _ => {}
113        }
114    }
115    if set("CODEX_THREAD_ID") {
116        return Ok(Host::Codex);
117    }
118    if set("COPILOT_CLI") {
119        return Ok(Host::Copilot);
120    }
121    // pi exports both: the generic marker names the agent, the specific one is a flag.
122    if env("AI_AGENT").is_some_and(|v| v.trim().eq_ignore_ascii_case("pi")) || set("PI_CODING_AGENT") {
123        return Ok(Host::Pi);
124    }
125    for (key, name) in [("OPENCODE", "OpenCode"), ("GEMINI_CLI", "Gemini CLI"), ("OMPCODE", "OMP")] {
126        if set(key) {
127            return Err(HostError::Unsupported(name.to_owned()));
128        }
129    }
130    Ok(Host::ClaudeCode)
131}