use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use crate::tools::{self, Tool, Vars};
pub const PERSONA_SESSION: &str = "ljos-personas";
pub const RESTARTS: u32 = 3;
pub const RESTART_WINDOW_S: u64 = 60;
#[must_use]
pub fn runner_argv_in(all: &crate::Harnesses, runner: &str, resume: bool) -> Option<Vec<String>> {
let h = all.harness.iter().find(|h| h.name == runner)?;
let start = if h.start.is_empty() {
vec![h.name.clone()]
} else {
h.start.clone()
};
Some(if resume && !h.resume.is_empty() {
h.resume.clone()
} else {
start
})
}
#[must_use]
pub fn runner_argv(runner: &str, resume: bool) -> Option<Vec<String>> {
runner_argv_in(
&crate::harnesses_from(&crate::harnesses_path()).ok()?,
runner,
resume,
)
}
#[must_use]
pub fn runner_names() -> Vec<String> {
crate::harnesses_from(&crate::harnesses_path())
.map(|all| all.harness.into_iter().map(|h| h.name).collect())
.unwrap_or_default()
}
fn declared_tools() -> Vec<Tool> {
crate::harnesses_from(&crate::harnesses_path())
.map(|all| all.tool)
.unwrap_or_default()
}
#[must_use]
pub fn home(name: &str) -> PathBuf {
std::env::var_os("XDG_STATE_HOME")
.filter(|v| !v.is_empty())
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state")))
.unwrap_or_else(|| PathBuf::from(".local/state"))
.join("ljos")
.join("personas")
.join(name)
}
#[must_use]
pub fn pane_script(name: &str, argv: &[String], again: &[String], home: &Path) -> String {
let words = |a: &[String]| {
a.iter()
.map(|w| tools::sh_quote(w))
.collect::<Vec<_>>()
.join(" ")
};
let again = if again.is_empty() { argv } else { again };
format!(
"#!/bin/sh\nprintf '\\033]2;%s\\007' {n}\ncd {h} || exit 1\necho $$ > .runner.pid\n\
LJOS_SEAT={n} {cmd}\ncode=$?\ntries=0\nsince=$(date +%s)\n\
while [ \"$code\" -ne 0 ]; do\n now=$(date +%s)\n \
if [ $((now - since)) -ge {window} ]; then since=$now; tries=0; fi\n \
tries=$((tries + 1))\n if [ \"$tries\" -gt {max} ]; then\n \
echo \"persona {name}: the runner failed $tries times in {window}s; not restarting\"\n break\n fi\n \
echo \"persona {name}: the runner exited $code; resuming, $tries of {max}\"\n sleep 1\n \
LJOS_SEAT={n} {again}\n code=$?\ndone\nrm -f .runner.pid\n\
echo \"persona {name}: the runner exited; this pane stays for reading\"\n\
exec \"${{SHELL:-/bin/sh}}\" -i\n",
n = tools::sh_quote(name),
h = tools::sh_quote(&home.display().to_string()),
cmd = words(argv),
again = words(again),
window = RESTART_WINDOW_S,
max = RESTARTS,
)
}
fn vars_for(name: &str, home: &Path, pane: &str) -> Vars {
let script = home.join("pane.sh").display().to_string();
Vars::default()
.with("name", name)
.with("label", format!("persona-{name}"))
.with("session", PERSONA_SESSION)
.with("home", home.display().to_string())
.with("script_q", tools::sh_quote(&script))
.with("script", script)
.with("pane", pane)
}
fn read_record(home: &Path) -> Option<(String, String)> {
let text = std::fs::read_to_string(home.join(".pane")).ok()?;
let mut lines = text.lines();
let tool = lines.next()?.trim().to_string();
let pane = lines.next().unwrap_or("").trim().to_string();
(!tool.is_empty()).then_some((tool, pane))
}
fn write_record(home: &Path, tool: &str, pane: &str) -> Result<()> {
std::fs::write(home.join(".pane"), format!("{tool}\n{pane}\n"))
.with_context(|| format!("{}", home.join(".pane").display()))
}
fn runner_up(home: &Path) -> bool {
std::fs::read_to_string(home.join(".runner.pid"))
.ok()
.and_then(|t| t.trim().parse::<u32>().ok())
.is_some_and(crate::pid_alive)
}
fn describe(tool: &str, pane: &str, name: &str) -> String {
match tool {
"tmux" => format!("tmux {PERSONA_SESSION}:{name}"),
_ if pane.is_empty() => format!("{tool} persona-{name}"),
_ => format!("{tool} pane {pane}"),
}
}
enum Pane {
Gone,
Idle(Tool, String),
Live(Tool, String),
}
fn pane_state(name: &str, home: &Path, declared: &[Tool]) -> Pane {
let Some((tool_name, pane)) = read_record(home) else {
let legacy = std::process::Command::new("tmux")
.args(["list-windows", "-t", PERSONA_SESSION, "-F", "#W"])
.stderr(std::process::Stdio::null())
.output()
.is_ok_and(|o| {
String::from_utf8_lossy(&o.stdout)
.lines()
.any(|w| w == name)
});
return match tools::ordered(declared)
.into_iter()
.find(|t| t.name == "tmux")
{
Some(tmux) if legacy => Pane::Live(tmux, String::new()),
_ => Pane::Gone,
};
};
let Some(tool) = tools::ordered(declared)
.into_iter()
.find(|t| t.name == tool_name)
else {
return Pane::Gone;
};
let vars = vars_for(name, home, &pane);
if !tools::answers(&tools::fill(&tool.alive, &vars)) {
return Pane::Gone;
}
if runner_up(home) {
Pane::Live(tool, pane)
} else {
Pane::Idle(tool, pane)
}
}
#[must_use]
pub fn live_pane(name: &str) -> Option<String> {
match pane_state(name, &home(name), &declared_tools()) {
Pane::Live(tool, pane) => Some(describe(&tool.name, &pane, name)),
_ => None,
}
}
pub fn hand(name: &str, runner: &str, task: &str) -> Result<String> {
let home = home(name);
let inbox = home.join("inbox");
std::fs::create_dir_all(&inbox)
.with_context(|| format!("persona {name}: {}", inbox.display()))?;
let millis = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_millis());
let file = inbox.join(format!("{millis}-{}.md", std::process::id()));
std::fs::write(&file, task)?;
let line = format!(
"Read {} and do what it asks, through ljos; it is your next task as {name}.",
file.display()
);
let declared = declared_tools();
let started = home.join(".started");
let argv_for = |resume: bool| {
runner_argv(runner, resume).with_context(|| {
format!(
"persona {name}: runner {runner:?} is not a [[harness]] in {}",
crate::harnesses_path().display()
)
})
};
let script = home.join("pane.sh");
match pane_state(name, &home, &declared) {
Pane::Live(tool, pane) => {
tools::send(&tool, &vars_for(name, &home, &pane).with("line", line))
.with_context(|| format!("persona {name}: the line did not reach its pane"))?;
return Ok(describe(&tool.name, &pane, name));
}
Pane::Idle(tool, pane) if !tool.respawn.is_empty() => {
let again = argv_for(true)?;
std::fs::write(&script, pane_script(name, &again, &again, &home))?;
let vars = vars_for(name, &home, &pane);
tools::run(&tool.name, &tools::fill(&tool.respawn, &vars))?;
tools::wait_ready(&tool, &vars);
tools::send(&tool, &vars.with("line", line))
.with_context(|| format!("persona {name}: the line did not reach its pane"))?;
return Ok(describe(&tool.name, &pane, name));
}
Pane::Idle(..) | Pane::Gone => {}
}
let argv = argv_for(started.exists())?;
let again = argv_for(true)?;
std::fs::write(&script, pane_script(name, &argv, &again, &home))?;
let only = std::env::var("LJOS_PANE_TOOL")
.ok()
.filter(|v| !v.is_empty());
let here = tools::available(&declared, only.as_deref());
if here.is_empty() {
bail!("persona {name}: {}", tools::doctor_state(&declared).1);
}
let mut refused = Vec::new();
for tool in here {
let vars = vars_for(name, &home, "");
let pane = match tools::open(&tool, &vars) {
Ok(p) => p,
Err(e) => {
refused.push(format!("{e:#}"));
continue;
}
};
write_record(&home, &tool.name, &pane)?;
let _ = std::fs::write(&started, crate::now_utc());
let vars = vars_for(name, &home, &pane);
tools::wait_ready(&tool, &vars);
tools::send(&tool, &vars.with("line", line))
.with_context(|| format!("persona {name}: the line did not reach {}", tool.name))?;
let mut said = describe(&tool.name, &pane, name);
if !refused.is_empty() {
said.push_str(&format!("; before it, {}", refused.join("; ")));
}
return Ok(said);
}
bail!("persona {name}: every tool refused: {}", refused.join("; "))
}
#[must_use]
pub fn herdr_up() -> bool {
which::which("herdr").is_ok()
&& std::process::Command::new("herdr")
.args(["workspace", "list"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|st| st.success())
}
#[must_use]
pub fn herdr_name(name: &str) -> String {
format!("persona-{name}")
}
#[must_use]
pub fn herdr_agent_status(name: &str) -> Option<String> {
if !herdr_up() {
return None;
}
let out = std::process::Command::new("herdr")
.args(["agent", "get", &herdr_name(name)])
.stderr(std::process::Stdio::null())
.output()
.ok()?;
if !out.status.success() {
return None;
}
let v: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
v["result"]["agent"]["status"]
.as_str()
.or_else(|| v["result"]["status"].as_str())
.or_else(|| v["status"].as_str())
.map(str::to_string)
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ActorMessage {
Cast {
caller: String,
task: String,
},
Call {
request_id: String,
caller: String,
command: String,
timeout_ms: u64,
},
Event {
event: String,
payload: serde_json::Value,
},
Exit {
reason: String,
},
}
impl ActorMessage {
#[must_use]
pub fn to_task_content(&self) -> String {
match self {
Self::Cast { task, caller } => format!(
"<!-- actor_msg: {} -->\nFrom {caller}:\n{task}",
serde_json::to_string(self).unwrap_or_default()
),
Self::Call {
command,
caller,
request_id,
timeout_ms,
} => format!(
"<!-- actor_msg: {} -->\nCall {request_id} from {caller} (timeout {timeout_ms}ms):\n{command}",
serde_json::to_string(self).unwrap_or_default()
),
Self::Event { event, payload } => format!(
"<!-- actor_msg: {} -->\nEvent {event}:\n{payload}",
serde_json::to_string(self).unwrap_or_default()
),
Self::Exit { reason } => format!(
"<!-- actor_msg: {} -->\nExit requested: {reason}",
serde_json::to_string(self).unwrap_or_default()
),
}
}
}
pub fn hand_actor(name: &str, runner: &str, msg: &ActorMessage) -> Result<String> {
hand(name, runner, &msg.to_task_content())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn actor_message_serializes_and_deserializes() {
let msg = ActorMessage::Cast {
caller: "lead-architect".into(),
task: "Verify consensus bounds".into(),
};
let json = serde_json::to_string(&msg).expect("json");
assert!(json.contains("\"type\":\"cast\""));
assert!(json.contains("\"caller\":\"lead-architect\""));
let de: ActorMessage = serde_json::from_str(&json).expect("from json");
assert_eq!(de, msg);
let call = ActorMessage::Call {
request_id: "req-1".into(),
caller: "evaluator".into(),
command: "ljos consensus issue-12".into(),
timeout_ms: 5000,
};
let call_json = serde_json::to_string(&call).expect("json");
assert!(call_json.contains("\"type\":\"call\""));
let de_call: ActorMessage = serde_json::from_str(&call_json).expect("from json");
assert_eq!(de_call, call);
}
#[test]
fn herdr_name_formats_consistently() {
assert_eq!(herdr_name("reviewer"), "persona-reviewer");
}
#[test]
fn a_runner_starts_fresh_then_resumes_its_home_session() {
let all: crate::Harnesses = toml::from_str(concat!(
"[[harness]]\nname = \"grok\"\nresume = [\"grok\", \"--continue\"]\n",
"[[harness]]\nname = \"plain\"\nstart = [\"plain-cli\", \"--tui\"]\n",
))
.unwrap();
assert_eq!(runner_argv_in(&all, "grok", false).unwrap(), ["grok"]);
assert_eq!(
runner_argv_in(&all, "grok", true).unwrap(),
["grok", "--continue"]
);
assert_eq!(
runner_argv_in(&all, "plain", true).unwrap(),
["plain-cli", "--tui"],
"no resume, a fresh start"
);
assert!(runner_argv_in(&all, "nobody", false).is_none());
}
#[test]
fn the_pane_runs_the_runner_as_the_persona_and_stays_open() {
let s = pane_script(
"buildengineer",
&["grok".into(), "--continue".into()],
&["grok".into(), "--continue".into()],
Path::new("/s/personas/buildengineer"),
);
assert!(s.contains("cd '/s/personas/buildengineer'"));
assert!(s.contains("LJOS_SEAT='buildengineer' 'grok' '--continue'"));
assert!(s.trim_end().ends_with("-i"));
}
#[test]
fn the_pane_resumes_a_failing_runner_a_bounded_number_of_times() {
let dir = tempfile::tempdir().unwrap();
let count = dir.path().join("runs");
let fail = vec![
"sh".to_string(),
"-c".into(),
format!("echo run >> {}; exit 3", count.display()),
];
let script = pane_script("p", &fail, &fail, dir.path())
.replace("exec \"${SHELL:-/bin/sh}\" -i\n", "");
let script = script.replace("sleep 1\n", "");
let out = std::process::Command::new("sh")
.arg("-c")
.arg(&script)
.output()
.unwrap();
let said = String::from_utf8_lossy(&out.stdout);
let runs = std::fs::read_to_string(&count).unwrap().lines().count();
assert_eq!(runs as u32, 1 + RESTARTS, "{said}");
assert!(said.contains("not restarting"), "{said}");
assert!(!dir.path().join(".runner.pid").exists());
let ok = vec![
"sh".to_string(),
"-c".into(),
format!("echo run >> {}.ok", count.display()),
];
let script =
pane_script("p", &ok, &fail, dir.path()).replace("exec \"${SHELL:-/bin/sh}\" -i\n", "");
std::process::Command::new("sh")
.arg("-c")
.arg(&script)
.status()
.unwrap();
let runs = std::fs::read_to_string(format!("{}.ok", count.display()))
.unwrap()
.lines()
.count();
assert_eq!(runs, 1, "a runner that exits 0 is done");
assert!(!dir.path().join(".runner.pid").exists());
}
#[test]
fn a_pane_record_round_trips_and_names_the_pane() {
let dir = tempfile::tempdir().unwrap();
write_record(dir.path(), "herdr", "w1:p2").unwrap();
assert_eq!(
read_record(dir.path()),
Some(("herdr".to_string(), "w1:p2".to_string()))
);
assert_eq!(describe("herdr", "w1:p2", "rev"), "herdr pane w1:p2");
assert_eq!(describe("tmux", "", "rev"), "tmux ljos-personas:rev");
assert!(!runner_up(dir.path()), "no pid file, no runner");
std::fs::write(
dir.path().join(".runner.pid"),
std::process::id().to_string(),
)
.unwrap();
assert!(runner_up(dir.path()));
}
}