use crate::db::{Db, nonempty};
use crate::errors::{Code, Info};
use anyhow::{Context, Result, ensure};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use std::{
path::{Path, PathBuf},
process::Command,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentSession {
pub agent: String,
pub kind: String,
pub value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pane {
pub pane_id: String,
pub workspace_id: String,
pub tab_id: String,
#[serde(default)]
pub cwd: Option<PathBuf>,
#[serde(default)]
pub foreground_cwd: Option<PathBuf>,
#[serde(default)]
pub agent_session: Option<AgentSession>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub terminal_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Link {
pub server: Option<String>,
pub identity: AgentSession,
pub pane: Pane,
}
#[derive(Deserialize)]
struct Envelope<T> {
result: T,
}
#[derive(Deserialize)]
struct Agents {
agents: Vec<Pane>,
}
#[derive(Deserialize)]
struct Current {
pane: Pane,
}
pub(crate) fn call<T: DeserializeOwned>(server: Option<&str>, args: &[&str]) -> Result<T> {
let mut cmd = Command::new("herdr");
if let Some(server) = server {
nonempty(server, "Herdr server")?;
cmd.args(["--session", server]);
}
let out = cmd.args(args).output().context(
Info::new(
Code::DispatchError,
"Cannot run herdr; install Herdr or provide --session for local ownership",
)
.detail("reason", "herdr_unavailable"),
)?;
ensure!(
out.status.success(),
Info::new(Code::DispatchError, "Herdr command failed")
.detail("reason", "herdr_command_failed")
.detail("exit_code", out.status.code())
.human(format!(
"Herdr failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
))
);
let response: Envelope<T> = serde_json::from_slice(&out.stdout).context(
Info::new(Code::DispatchError, "Invalid Herdr response")
.detail("reason", "invalid_herdr_response"),
)?;
Ok(response.result)
}
pub fn find(identity: &AgentSession, server: Option<&str>) -> Result<Pane> {
let agents: Agents = call(server, &["agent", "list"])?;
let mut matches = agents.agents.into_iter().filter(|pane| {
if identity.kind == "terminal" {
return pane.terminal_id.as_deref() == Some(&identity.value)
&& pane.agent.as_deref() == Some(&identity.agent);
}
pane.agent_session.as_ref().is_some_and(|s| {
s.agent == identity.agent && s.kind == identity.kind && s.value == identity.value
})
});
let pane = matches.next().context(
Info::new(
Code::DispatchError,
"Linked Herdr agent session is not live",
)
.detail("reason", "agent_not_live"),
)?;
ensure!(
matches.next().is_none(),
Info::new(
Code::DispatchError,
"Multiple Herdr panes match agent session; resolve duplicate session reports"
)
.detail("reason", "ambiguous_agent")
);
Ok(pane)
}
pub fn pane_identity(pane: &Pane) -> Result<AgentSession> {
if let Some(identity) = &pane.agent_session {
nonempty(&identity.agent, "Herdr agent kind")?;
nonempty(&identity.kind, "Herdr identity kind")?;
nonempty(&identity.value, "Herdr agent session")?;
return Ok(identity.clone());
}
let value = pane.terminal_id.as_deref().context(
Info::invalid_argument(
"--session",
"Herdr agent has no session or terminal identity",
)
.detail("reason", "missing_agent_identity"),
)?;
let agent = pane.agent.as_deref().context(
Info::invalid_argument("--session", "Herdr agent kind missing")
.detail("reason", "missing_agent_kind"),
)?;
nonempty(value, "Herdr terminal identity")?;
nonempty(agent, "Herdr agent kind")?;
Ok(AgentSession {
agent: agent.into(),
kind: "terminal".into(),
value: value.into(),
})
}
fn retained_identity(pane: &Pane, db: &Db) -> Result<AgentSession> {
let identity = pane_identity(pane)?;
if let Some(terminal) = &pane.terminal_id {
if let Some(active) = db.active_identity_for_terminal(&identity.agent, terminal)? {
return Ok(active);
}
}
Ok(identity)
}
pub fn has_context() -> bool {
std::env::var_os("HERDR_PANE_ID").is_some() || std::env::var("HERDR_ENV").as_deref() == Ok("1")
}
fn current_pane() -> Result<Pane> {
ensure!(
has_context(),
Info::invalid_argument(
"--session",
"No exact Herdr context; use --session or QQQ_SESSION"
)
.detail("reason", "missing_herdr_context")
);
let pane_id = std::env::var("HERDR_PANE_ID").context(
Info::invalid_argument(
"--session",
"HERDR_PANE_ID missing or not UTF-8; use --session",
)
.detail("reason", "missing_herdr_pane"),
)?;
nonempty(&pane_id, "HERDR_PANE_ID")?;
let current: Current = call(None, &["pane", "current", "--pane", &pane_id])?;
Ok(current.pane)
}
pub fn dispatch_caller(db: &Db) -> Result<String> {
let pane = current_pane()?;
let identity = retained_identity(&pane, db)?;
Ok(serde_json::to_string(&(
identity.agent,
identity.kind,
identity.value,
))?)
}
pub fn explicit(agent: String, value: String, server: Option<String>) -> Result<Link> {
nonempty(&agent, "Agent")?;
nonempty(&value, "Agent session")?;
let identity = AgentSession {
agent,
kind: "id".into(),
value,
};
let pane = find(&identity, server.as_deref())?;
let server = server.or_else(server_session);
Ok(Link {
server,
identity,
pane,
})
}
pub fn server_session() -> Option<String> {
let Some(socket) = std::env::var_os("HERDR_SOCKET_PATH") else {
return Some("default".into());
};
#[derive(Deserialize)]
struct Sessions {
sessions: Vec<ServerSession>,
}
#[derive(Deserialize)]
struct ServerSession {
name: String,
socket_path: PathBuf,
}
let out = Command::new("herdr")
.args(["session", "list", "--json"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let sessions: Sessions = serde_json::from_slice(&out.stdout).ok()?;
let socket = PathBuf::from(socket);
let mut matches = sessions
.sessions
.into_iter()
.filter(|s| s.socket_path == socket);
let session = matches.next()?;
if matches.next().is_some() || session.name.trim().is_empty() {
return None;
}
Some(session.name)
}
pub fn current() -> Result<Link> {
let pane = current_pane()?;
let identity = pane_identity(&pane).context(
"Cannot identify current Herdr agent; use --session or enable Herdr session reporting",
)?;
Ok(Link {
server: server_session(),
identity,
pane,
})
}
pub fn discover(project_dir: &Path) -> Result<Link> {
if has_context() {
return current();
}
let project_dir = project_dir
.canonicalize()
.context("Cannot resolve project root")?;
let agents: Agents = call(None, &["agent", "list"])?;
let mut matches = agents.agents.into_iter().filter(|pane| {
pane.foreground_cwd
.as_deref()
.or(pane.cwd.as_deref())
.filter(|cwd| cwd.is_absolute())
.and_then(|cwd| cwd.canonicalize().ok())
.is_some_and(|cwd| cwd == project_dir)
});
let pane = matches.next().with_context(|| {
Info::invalid_argument(
"--session",
format!(
"No Herdr agent matches project root {}; use --session or QQQ_SESSION",
project_dir.display()
),
)
.detail("reason", "missing_project_agent")
})?;
ensure!(
matches.next().is_none(),
Info::invalid_argument("--session", format!("Multiple Herdr agents match project root {}; use --session or run inside intended Herdr pane", project_dir.display())).detail("reason", "ambiguous_project_agent")
);
let identity = pane_identity(&pane).context(
"Cannot identify matching Herdr agent; use --session or enable Herdr session reporting",
)?;
Ok(Link {
server: server_session(),
identity,
pane,
})
}
pub fn owner(
explicit: Option<&str>,
project_dir: &Path,
db: &Db,
) -> Result<(String, Option<Link>)> {
if let Some(session) = explicit {
nonempty(session, "Session")?;
return Ok((session.into(), None));
}
let mut link = discover(project_dir)?;
link.identity = retained_identity(&link.pane, db)?;
let owner = serde_json::to_string(&(
&link.identity.agent,
&link.identity.kind,
&link.identity.value,
))?;
Ok((owner, Some(link)))
}