use std::path::PathBuf;
use std::time::{Duration, Instant, SystemTime};
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use crate::snapshot;
pub const DELIVER: &str = "k1-c.linear-tui.deliver";
pub fn bin() -> Option<PathBuf> {
std::env::var_os("HERDR_BIN_PATH")
.filter(|p| !p.is_empty())
.map(PathBuf::from)
}
pub fn available() -> bool {
bin().is_some()
}
pub fn outbox_dir() -> Result<PathBuf> {
Ok(snapshot::state_dir()?.join("herdr").join("outbox"))
}
pub fn agents_file() -> Result<PathBuf> {
Ok(snapshot::state_dir()?.join("herdr").join("agents.json"))
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct AgentLink {
pub pane: String,
#[serde(default)]
pub workspace: Option<String>,
#[serde(default)]
pub workspace_label: Option<String>,
pub agent: String,
pub status: AgentStatus,
#[serde(default)]
pub cwd: Option<PathBuf>,
#[serde(default)]
pub issue: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AgentStatus {
Working,
Blocked,
Idle,
Done,
#[serde(other)]
Unknown,
}
impl AgentStatus {
pub fn label(self) -> &'static str {
match self {
Self::Working => "working",
Self::Blocked => "waiting for you",
Self::Idle => "idle",
Self::Done => "done",
Self::Unknown => "unknown",
}
}
pub fn rank(self) -> u8 {
match self {
Self::Blocked => 0,
Self::Working => 1,
Self::Done => 2,
Self::Idle => 3,
Self::Unknown => 4,
}
}
}
#[derive(Debug, Deserialize)]
struct AgentsFile {
version: u32,
#[serde(default)]
agents: Vec<AgentLink>,
}
pub fn parse_agents(text: &str) -> Result<Vec<AgentLink>> {
let file: AgentsFile = serde_json::from_str(text)?;
Ok(if file.version == 1 {
file.agents
} else {
Vec::new()
})
}
#[derive(Debug)]
pub struct AgentWatch {
path: PathBuf,
modified: Option<SystemTime>,
next: Instant,
}
impl AgentWatch {
const EVERY: Duration = Duration::from_secs(1);
pub fn new() -> Option<Self> {
Some(Self {
path: agents_file().ok()?,
modified: None,
next: Instant::now(),
})
}
pub fn poll(&mut self, now: Instant) -> Option<Vec<AgentLink>> {
if now < self.next {
return None;
}
self.next = now + Self::EVERY;
let modified = std::fs::metadata(&self.path)
.and_then(|m| m.modified())
.ok();
if modified == self.modified {
return None;
}
self.modified = modified;
if modified.is_none() {
return Some(Vec::new());
}
let text = std::fs::read_to_string(&self.path).ok()?;
parse_agents(&text)
.inspect_err(|e| tracing::warn!("unreadable {}: {e:#}", self.path.display()))
.ok()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Handoff {
Prompt {
text: String,
notes: String,
view: String,
hint: String,
},
Focus { pane: String },
}
impl Handoff {
pub fn done(&self) -> &'static str {
match self {
Self::Prompt { .. } => "Notes handed to herdr for your agent",
Self::Focus { .. } => "Switched to the agent",
}
}
}
#[derive(Debug, Serialize)]
struct Envelope<'a> {
version: u32,
from: From,
#[serde(flatten)]
handoff: &'a Handoff,
}
#[derive(Debug, Serialize)]
struct From {
pane: Option<String>,
workspace: Option<String>,
cwd: Option<PathBuf>,
}
pub async fn deliver(handoff: &Handoff) -> Result<()> {
let bin = bin().context("not running inside herdr")?;
let env = |name: &str| std::env::var(name).ok().filter(|v| !v.is_empty());
let envelope = Envelope {
version: 1,
from: From {
pane: env("HERDR_PANE_ID"),
workspace: env("HERDR_WORKSPACE_ID"),
cwd: std::env::current_dir().ok(),
},
handoff,
};
let name = format!(
"{}-{}.json",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_millis()),
std::process::id()
);
let path = outbox_dir()?.join(name);
crate::private_file::write(&path, &serde_json::to_vec_pretty(&envelope)?)?;
let out = tokio::process::Command::new(&bin)
.args(["plugin", "action", "invoke", DELIVER])
.output()
.await
.with_context(|| format!("could not run {}", bin.display()))?;
if !out.status.success() {
let _ = std::fs::remove_file(&path);
let stderr = String::from_utf8_lossy(&out.stderr);
let stdout = String::from_utf8_lossy(&out.stdout);
let reason = if stderr.trim().is_empty() {
stdout
} else {
stderr
};
bail!("the linear-tui herdr plugin did not run: {}", reason.trim());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_agents_file_parses_and_tolerates_new_states() {
let agents = parse_agents(
r#"{"version":1,"updated_at":"2026-09-25T00:00:00Z","agents":[
{"pane":"w2:p1","agent":"claude","status":"working","issue":"ENG-42"},
{"pane":"w3:p1","agent":"codex","status":"thinking"}]}"#,
)
.unwrap();
assert_eq!(agents[0].issue.as_deref(), Some("ENG-42"));
assert_eq!(agents[1].status, AgentStatus::Unknown);
assert!(
parse_agents(r#"{"version":2,"agents":[]}"#)
.unwrap()
.is_empty()
);
}
}