Something went wrong. Try again.
Personal fork of prime agent. Mirror of: https://mnemosyne.sigilzero.dev/sigilzero/prime-agent
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719//! The daemon-backed command runner behind the public commands `list`,//! `sessions`, `stop`, `rename`, `send`, and `schedule`: argument parsing,//! request shaping, and output rendering. `schedule` maps to the internal//! `cron` command and `stop` to `kill`.
use std::path::{Path, PathBuf};
use anyhow::{anyhow, bail, Result};use pa_types::daemon::DaemonCommand;use serde_json::{json, Value};
use crate::daemon_client::DaemonClient;use crate::daemon_session_list::{ format_session_list_table, get_session_summaries, live_session_summary, matches_session_id_suffix,};
#[derive(Debug)]struct ParsedDaemonCommand { socket_path: PathBuf, json: bool, positionals: Vec<String>, /// `--help`/`-h` becomes the internal no-op `help` command, exactly like the TS /// `runDaemonClientCommand` fallthrough. help: bool,}
/// Run one internal daemon command. Errors are printed by the public command/// router exactly like the `handleDaemonCommand` catch in the TS CLI.pub(crate) fn run_daemon_command(command: &str, args: &[String]) -> Result<()> { let parsed = parse_daemon_command(command, args)?; if parsed.help { return Ok(()); } let mut client = DaemonClient::connect(&parsed.socket_path)?; match command { "list" => run_list(&mut client, &parsed.positionals, parsed.json), "sessions" => run_sessions(&mut client, &parsed.positionals, parsed.json), "kill" => run_kill(&mut client, &parsed.positionals, parsed.json), "rename" => run_rename(&mut client, &parsed.positionals, parsed.json), "send" => run_send(&mut client, &parsed.positionals, parsed.json), "cron" => run_cron(&mut client, &parsed.positionals, parsed.json), other => bail!("Unknown daemon command: {other}"), }}
/// Option scanning with `--` passthrough semantics (`send`/`cron` keep the/// separator as an operand, the others consume it).fn parse_daemon_command(command: &str, args: &[String]) -> Result<ParsedDaemonCommand> { let mut socket_path = default_socket_path(); let mut json = false; let mut positionals: Vec<String> = Vec::new(); let mut passthrough = false; let mut index = 0; while index < args.len() { let arg = args[index].as_str(); index += 1; if passthrough { positionals.push(arg.to_string()); continue; } if arg == "--" { if command == "cron" || command == "send" { positionals.push(arg.to_string()); } passthrough = true; continue; }
if arg == "--help" || arg == "-h" { return Ok(ParsedDaemonCommand { socket_path, json, positionals, help: true, }); } if arg == "--socket" || arg == "--daemon-socket" { let value = args .get(index) .ok_or_else(|| anyhow!("{arg} requires a value"))?; index += 1; socket_path = normalize_socket_path(value); continue; } if arg == "--json" { json = true; continue; } positionals.push(arg.to_string()); } Ok(ParsedDaemonCommand { socket_path, json, positionals, help: false, })}
fn default_socket_path() -> PathBuf { crate::config::resolve_daemon_socket_path(None)}
/// Lexically resolve against the current directory.fn normalize_socket_path(value: &str) -> PathBuf { let path = Path::new(value); if path.is_absolute() { path.to_path_buf() } else { std::env::current_dir() .unwrap_or_else(|_| PathBuf::from(".")) .join(path) }}
/// Surface the daemon's error text.fn require_success(response: pa_types::daemon::DaemonResponse) -> Result<Option<Value>> { if !response.success { return Err(anyhow!(response.error.unwrap_or_default())); } Ok(response.data)}
fn print_json(value: &Value) { match serde_json::to_string_pretty(value) { Ok(text) => println!("{text}"), Err(error) => eprintln!("Error: {error}"), }}
fn require_active_session_id(args: &[String]) -> Result<String> { args.first() .cloned() .ok_or_else(|| anyhow!("Missing agent id or name"))}
fn run_list(client: &mut DaemonClient, args: &[String], json: bool) -> Result<()> { let mut all = false; for arg in args { if arg == "-a" || arg == "--all" { all = true; continue; } bail!("Unknown list option: {arg}"); } let response = client.request(list_command(all))?; let data = require_success(response)?.unwrap_or(Value::Null); if json { print_json(&data); return Ok(()); } let Some(sessions) = get_session_summaries(&data) else { print_json(&data); return Ok(()); }; if sessions.is_empty() { println!( "{}", if all { "No agents." } else { "No active agents." } ); return Ok(()); } println!("{}", format_session_list_table(&sessions)); Ok(())}
fn list_command(all: bool) -> DaemonCommand { DaemonCommand::List { id: None, all: Some(all), cwd: None, session_dir: None, include_client_owned: None, rest: serde_json::Map::new(), }}
/// The same list RPC as `prime-agent list`, rendered as the/// one-line-per-agent operator table.fn run_sessions(client: &mut DaemonClient, args: &[String], json: bool) -> Result<()> { let mut all = false; for arg in args { if arg == "-a" || arg == "--all" { all = true; continue; } bail!("Unknown sessions option: {arg}"); } let response = client.request(list_command(all))?; let data = require_success(response)?.unwrap_or(Value::Null); if json { print_json(&data); return Ok(()); } let Some(sessions) = crate::daemon_session_list::validated_session_values(&data) else { print_json(&data); return Ok(()); }; if sessions.is_empty() { println!( "{}", if all { "No agents." } else { "No active agents." } ); return Ok(()); } println!( "{}", crate::sessions_table_format::format_sessions_table( &sessions, crate::daemon_session_list::now_ms() ) ); Ok(())}
/// Match a name/id/session-id selector against the live session list,/// with unambiguous hex-suffix fallback.fn resolve_live_session_selector(client: &mut DaemonClient, selector: &str) -> Result<String> { let response = client.request(list_command(false))?; let data = require_success(response)?; let sessions = data .as_ref() .and_then(|data| data.get("sessions")) .and_then(Value::as_array) .ok_or_else(|| anyhow!("Daemon returned an invalid list response"))?; let mut exact: Vec<String> = Vec::new(); let mut suffix: Vec<String> = Vec::new(); for session in sessions { let Some(active_session_id) = session.get("activeSessionId").and_then(Value::as_str) else { continue; }; let session_id = session.get("sessionId").and_then(Value::as_str); let session_name = session.get("sessionName").and_then(Value::as_str); if active_session_id == selector || session_id == Some(selector) || session_name == Some(selector) { exact.push(active_session_id.to_string()); continue; } if matches_session_id_suffix(active_session_id, selector) || session_id.is_some_and(|id| matches_session_id_suffix(id, selector)) { suffix.push(active_session_id.to_string()); } } let matches = if exact.is_empty() { suffix } else { exact }; match matches.len() { 1 => Ok(matches[0].clone()), 0 => Err(anyhow!("Unknown active session: {selector}")), _ => Err(anyhow!("Ambiguous active session \"{selector}\"")), }}
fn run_kill(client: &mut DaemonClient, args: &[String], json: bool) -> Result<()> { let active_session_id = require_active_session_id(args)?; let response = client.request(DaemonCommand::Kill { id: None, active_session_id, rest: serde_json::Map::new(), })?; print_response_data(&response, json)}
/// Pretty-print the response data (the whole response as JSON when/// there is none and `--json` was asked), else `ok`.fn print_response_data(response: &pa_types::daemon::DaemonResponse, json: bool) -> Result<()> { let data = require_success(response.clone())?; if json || data.is_some() { let value = data.unwrap_or_else(|| response_value(response)); print_json(&value); return Ok(()); } println!("ok"); Ok(())}
/// The response as the TS wire shape (`data ?? response` prints the frame the/// daemon sent, including the `type` tag the typed struct does not carry).fn response_value(response: &pa_types::daemon::DaemonResponse) -> Value { let mut value = json!({ "type": "response", "command": response.command, "success": response.success, }); let object = value.as_object_mut().expect("response object"); if let Some(id) = &response.id { object.insert("id".to_string(), json!(id)); } if let Some(data) = &response.data { object.insert("data".to_string(), data.clone()); } if let Some(error) = &response.error { object.insert("error".to_string(), json!(error)); } value}
fn run_rename(client: &mut DaemonClient, args: &[String], json: bool) -> Result<()> { let active_session_id = require_active_session_id(args)?; let name = args[1..].join(" ").trim().to_string(); if name.is_empty() { bail!("Usage: prime-agent rename <agent> <name>"); } let response = client.request(DaemonCommand::Rename { id: None, active_session_id, name: name.clone(), renamed_by: None, rest: serde_json::Map::new(), })?; let data = require_success(response)?.unwrap_or(Value::Null); if json { print_json(&data); return Ok(()); } match live_session_summary(&data) { Some(summary) => { let summary_name = summary.get("sessionName").and_then(Value::as_str); println!( "Renamed {} to {}", summary .get("activeSessionId") .and_then(Value::as_str) .unwrap_or_default(), summary_name.unwrap_or(&name) ); } None => print_json(&data), } Ok(())}
struct ParsedSendArgs { target_active_session_id: String, from_active_session_id: Option<String>, message: String,}
fn run_send(client: &mut DaemonClient, args: &[String], json: bool) -> Result<()> { let parsed = parse_send_args(args)?; let response = client.request(DaemonCommand::SendMessage { id: None, target_active_session_id: parsed.target_active_session_id, message: parsed.message, from_active_session_id: parsed.from_active_session_id, agent_origin: None, delivery_mode: None, rest: serde_json::Map::new(), })?; let data = require_success(response)?.unwrap_or(Value::Null); if json { print_json(&data); return Ok(()); } // A target block with a routable active id. let receipt_target = data .get("target") .filter(|target| { target .get("activeSessionId") .and_then(Value::as_str) .is_some() }) .and_then(|target| { target .get("sessionName") .and_then(Value::as_str) .or_else(|| target.get("activeSessionId").and_then(Value::as_str)) }) .map(str::to_string); let Some(target) = receipt_target else { println!("ok"); return Ok(()); }; let queued = data.get("deliveryStatus").and_then(Value::as_str) == Some("queued"); println!( "{}", if queued { format!("Queued for {target}") } else { format!("Sent to {target}") } ); Ok(())}
fn parse_send_args(args: &[String]) -> Result<ParsedSendArgs> { let mut from_active_session_id: Option<String> = None; let mut target_active_session_id: Option<String> = None; let mut explicit_message: Option<String> = None; let mut message_parts: Vec<String> = Vec::new(); let mut parse_options = true; let mut index = 0; while index < args.len() { let arg = args[index].as_str(); index += 1; if parse_options && arg == "--" { parse_options = false; continue; } if parse_options && arg == "--from" { let value = args .get(index) .ok_or_else(|| anyhow!("--from requires a session id or name"))?; index += 1; from_active_session_id = Some(value.clone()); continue; } if parse_options && arg == "--message" { if target_active_session_id.is_none() { bail!("--message must appear after the target session"); } let value = args .get(index) .ok_or_else(|| anyhow!("--message requires message text"))?; index += 1; explicit_message = Some(value.clone()); parse_options = false; continue; } if parse_options && arg.starts_with("--") { bail!("Unknown option for send: {arg} (use -- before message text starting with --)"); } if target_active_session_id.is_none() { target_active_session_id = Some(arg.to_string()); continue; } message_parts.push(arg.to_string()); } if explicit_message.is_some() && !message_parts.is_empty() { bail!("Usage: prime-agent send [--from <agent>] <agent> [--message <message>|<message>]"); } let message = explicit_message .unwrap_or_else(|| message_parts.join(" ")) .trim() .to_string(); if target_active_session_id.is_none() || message.is_empty() { bail!("Usage: prime-agent send [--from <agent>] <agent> [--message <message>|<message>]"); } Ok(ParsedSendArgs { target_active_session_id: target_active_session_id.expect("checked above"), from_active_session_id, message, })}
fn run_cron(client: &mut DaemonClient, args: &[String], json: bool) -> Result<()> { let subcommand = args.first().map_or("list", String::as_str); match subcommand { "list" => run_cron_list(client, args, json), "add" | "schedule" => run_cron_add(client, args, json), "cancel" | "delete" | "remove" => run_cron_cancel(client, args, json), other => bail!("Unknown schedule command: {other}"), }}
fn run_cron_list(client: &mut DaemonClient, args: &[String], json: bool) -> Result<()> { let include_inactive = args.iter().skip(1).any(|arg| arg == "--all" || arg == "-a"); let selector = args .iter() .skip(1) .find(|arg| !arg.starts_with('-') && arg.as_str() != "list"); let active_session_id = match selector { Some(selector) => Some(resolve_live_session_selector(client, selector)?), None => None, }; let response = client.request(DaemonCommand::CronList { id: None, active_session_id, include_inactive: Some(include_inactive), rest: serde_json::Map::new(), })?; let data = require_success(response)?.unwrap_or(Value::Null); if json { print_json(&data); return Ok(()); } let Some(jobs) = data.get("jobs").and_then(Value::as_array) else { print_json(&data); return Ok(()); }; if jobs.is_empty() { println!("No scheduled prompts."); return Ok(()); } for job in jobs { println!("{}", format_agent_cron_job(job)); } Ok(())}
fn run_cron_add(client: &mut DaemonClient, args: &[String], json: bool) -> Result<()> { let usage = "Usage: prime-agent schedule add <agent> <schedule> -- <message>"; let separator = args .iter() .position(|arg| arg == "--") .ok_or_else(|| anyhow!("{usage}"))?; let Some(active_session_id) = args.get(1) else { bail!("{usage}"); }; if separator < 2 { bail!("{usage}"); } let schedule = args[2..separator].join(" ").trim().to_string(); let message = args[separator + 1..].join(" ").trim().to_string(); if schedule.is_empty() || message.is_empty() { bail!("{usage}"); } let response = client.request(DaemonCommand::CronAdd { id: None, active_session_id: active_session_id.clone(), schedule, prompt: message, promote_owned_session: None, rest: serde_json::Map::new(), })?; let data = require_success(response)?.unwrap_or(Value::Null); if json { print_json(&data); return Ok(()); } match cron_job_id_and_next_run(&data) { Some((id, next_run)) => println!("Scheduled {id} next={next_run}"), None => println!("Scheduled prompt."), } Ok(())}
fn run_cron_cancel(client: &mut DaemonClient, args: &[String], json: bool) -> Result<()> { let Some(job_id) = args.get(1) else { bail!("Usage: prime-agent schedule cancel <job-id>"); }; let response = client.request(DaemonCommand::CronCancel { id: None, active_session_id: None, job_id: job_id.clone(), rest: serde_json::Map::new(), })?; let data = require_success(response)?.unwrap_or(Value::Null); if json { print_json(&data); return Ok(()); } match cron_job_id_and_next_run(&data) { Some((id, _)) => println!("Cancelled {id}"), None => println!("Cancelled cron job."), } Ok(())}
/// The job id and next run when the response carries a job.fn cron_job_id_and_next_run(data: &Value) -> Option<(String, String)> { let job = data.get("job")?; let id = job.get("id").and_then(Value::as_str)?.to_string(); let next_run = job .get("nextRunAt") .and_then(Value::as_str) .map_or_else(|| "-".to_string(), str::to_string); Some((id, next_run))}
fn format_agent_cron_job(job: &Value) -> String { let id = job.get("id").and_then(Value::as_str).unwrap_or_default(); let status = job .get("status") .and_then(Value::as_str) .unwrap_or_default(); let label = job .get("label") .and_then(Value::as_str) .map(|label| format!(" label=\"{label}\"")) .unwrap_or_default(); let next = cron_datetime(job.get("nextRunAt")); let last = cron_datetime(job.get("lastRunAt")); let skipped = job .get("lastSkippedAt") .map(|value| format!(" skipped={}", cron_datetime(Some(value)))) .unwrap_or_default(); let run_count = job .get("runCount") .and_then(Value::as_f64) .unwrap_or_default(); let expression = job .get("schedule") .and_then(|schedule| schedule.get("expression")) .and_then(Value::as_str) .unwrap_or_default(); let prompt: String = job .get("prompt") .and_then(Value::as_str) .unwrap_or_default() .split_whitespace() .collect::<Vec<_>>() .join(" "); let preview: String = prompt.chars().take(80).collect(); let error = job .get("lastError") .and_then(Value::as_str) .map(|error| format!(" error={error}")) .unwrap_or_default(); format!( "{id} {status}{label} next={next} last={last}{skipped} runs={run_count} schedule=\"{expression}\" prompt=\"{preview}\"{error}" )}
/// `toLocaleString()` for a cron timestamp: en-US date with a 12-hour clock,/// e.g. `9/16/2026, 6:36:59 PM`. Rendered in UTC.fn cron_datetime(value: Option<&Value>) -> String { let Some(value) = value.and_then(Value::as_str) else { return "-".to_string(); }; let Some(ms) = crate::daemon_session_list::parse_iso_ms(value) else { return "-".to_string(); }; let seconds_of_day = (ms / 1000) % 86_400; let hour24 = seconds_of_day / 3600; let minute = (seconds_of_day % 3600) / 60; let second = seconds_of_day % 60; let (hour12, meridiem) = match hour24 % 12 { 0 => (12, if hour24 < 12 { "AM" } else { "PM" }), other => (other, if hour24 < 12 { "AM" } else { "PM" }), }; let days = (ms / 1000) / 86_400; let (year, month, day) = civil_from_days(days); format!("{month}/{day}/{year}, {hour12}:{minute:02}:{second:02} {meridiem}")}
/// Civil date from days since the epoch (Howard Hinnant's algorithm).fn civil_from_days(days: u64) -> (i64, u32, u32) { let z = days as i64 + 719_468; let era = z.div_euclid(146_097); let doe = z.rem_euclid(146_097); let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; let y = yoe + era * 400; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let day = (doy - (153 * mp + 2) / 5 + 1) as u32; let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32; (if month <= 2 { y + 1 } else { y }, month, day)}
#[cfg(test)]mod tests { use super::*;
fn args(list: &[&str]) -> Vec<String> { list.iter().map(std::string::ToString::to_string).collect() }
// The socket-path/separator shapes are unix paths (the windows transport // is named pipes; the tilde/socket spellings do not exist there). #[cfg(unix)] #[test] fn parse_consumes_socket_json_and_separator() { let parsed = parse_daemon_command("list", &args(&["--json", "--socket", "/s", "-a"])).unwrap(); assert_eq!(parsed.socket_path, PathBuf::from("/s")); assert!(parsed.json); assert_eq!(parsed.positionals, args(&["-a"]));
let missing = parse_daemon_command("list", &args(&["--daemon-socket"])).unwrap_err(); assert_eq!(missing.to_string(), "--daemon-socket requires a value"); }
#[test] fn separator_passes_through_only_for_send_and_cron() { let send = parse_daemon_command("send", &args(&["target", "--", "hi", "there"])).unwrap(); assert_eq!(send.positionals, args(&["target", "--", "hi", "there"])); let list = parse_daemon_command("list", &args(&["--", "-a"])).unwrap(); assert_eq!(list.positionals, args(&["-a"])); }
#[test] fn send_args_match_ts_usage_rules() { let parsed = parse_send_args(&args(&["--from", "me", "target", "hello", "there"])).unwrap(); assert_eq!(parsed.target_active_session_id, "target"); assert_eq!(parsed.from_active_session_id.as_deref(), Some("me")); assert_eq!(parsed.message, "hello there");
let flag = parse_send_args(&args(&["target", "--message", "queued"])).unwrap(); assert_eq!(flag.message, "queued");
let explicit_and_positional = parse_send_args(&args(&["target", "--message", "queued", "extra"])); assert!(explicit_and_positional.is_err());
let unknown = parse_send_args(&args(&["target", "--bogus"])); assert!(unknown.is_err());
let no_target = parse_send_args(&args(&["--message", "text"])); assert!(no_target.is_err()); }
#[test] fn cron_job_line_matches_captured_ts_golden() { let job = json!({ "id": "0451e5a9-951c-457b-ae76-ea3142ea5d25", "status": "active", "nextRunAt": "2026-09-16T18:36:59.287Z", "runCount": 0, "schedule": { "expression": "every 5 minutes" }, "prompt": "do the thing", }); assert_eq!( format_agent_cron_job(&job), "0451e5a9-951c-457b-ae76-ea3142ea5d25 active next=9/16/2026, 6:36:59 PM last=- runs=0 schedule=\"every 5 minutes\" prompt=\"do the thing\"" ); }}