//! `maestro list / spawn / kill` — daemon HTTP calls + console formatting. use std::collections::HashMap; use std::io::Write; use maestro_protocol::display::abbreviate_dir; use maestro_protocol::{ ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, SendInputRequest, Session, SessionStatus, WorktreeSpec, }; use serde_json::Value; use crate::cli::{KillArgs, ListArgs, SendArgs, SpawnArgs}; use crate::dispatch::CliError; /// `maestro list` pub async fn run_list( client: &DaemonClient, args: &ListArgs, out: &mut impl Write, ) -> Result<(), ClientError> { let sessions = client.list_sessions().await?; let info = client.daemon_info().await.unwrap_or_default(); if args.json { let body = serde_json::json!({ "sessions": sessions }); writeln!(out, "{}", serde_json::to_string_pretty(&body)?).ok(); return Ok(()); } print_session_table(out, &sessions, &info); Ok(()) } /// `maestro spawn` pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result { let mut env: HashMap = HashMap::new(); for (k, v) in args.env.iter() { env.insert(k.clone(), v.clone()); } let metadata: HashMap = HashMap::new(); let worktree = match (args.worktree_from.as_ref(), args.branch.as_ref()) { (Some(repo), Some(branch)) => Some(WorktreeSpec { base_repo: repo.clone(), branch: branch.clone(), create_branch: !args.existing_branch, }), _ => None, }; let body = CreateSessionRequest { cwd: args.cwd.clone(), command: args.command.clone(), env, metadata, worktree, }; client.create_session(&body).await } /// `maestro kill`. A forced kill (#81) retains the guarded worktree instead /// of removing it; the outcome names what was kept so dispatch can say where /// the work went. pub async fn run_kill(client: &DaemonClient, args: &KillArgs) -> Result<(), ClientError> { client.kill_session(args.session_id, args.force).await } /// Nothing on the daemon answers to what the operator named. #[derive(Debug, thiserror::Error)] pub enum ResolveError { #[error("no session matches {0:?} — run `maestro list` to see what is running")] NoMatch(String), #[error("{0:?} matches {1} sessions; use a session id instead")] Ambiguous(String, usize), } /// Turn what the operator typed into a session id: a full ID, a unique ID /// prefix, or a session's name (#125), matched case-insensitively. Names come /// first — an operator who named a session after a hex string meant the name. pub fn resolve_session(sessions: &[Session], needle: &str) -> Result { let needle = needle.trim(); let by_name: Vec<&Session> = sessions .iter() .filter(|s| { maestro_protocol::display::custom_name(s) .is_some_and(|n| n.eq_ignore_ascii_case(needle)) }) .collect(); match by_name.len() { 1 => return Ok(by_name[0].id), 0 => {} n => return Err(ResolveError::Ambiguous(needle.to_string(), n)), } let by_id: Vec<&Session> = sessions .iter() .filter(|s| { s.id.to_string() .to_ascii_lowercase() .starts_with(&needle.to_ascii_lowercase()) }) .collect(); match by_id.len() { 1 => Ok(by_id[0].id), 0 => Err(ResolveError::NoMatch(needle.to_string())), n => Err(ResolveError::Ambiguous(needle.to_string(), n)), } } /// `maestro send`. Answers with the session that was typed into, so the /// caller can name it once the daemon has accepted the text. /// /// A bare ID skips the catalog fetch; anything else needs the list to match /// against. Text comes from the arguments, or from stdin when there is none — /// the shape a heredoc or a pipe wants. #[allow(clippy::result_large_err)] pub async fn run_send( client: &DaemonClient, args: &SendArgs, stdin: &mut impl std::io::Read, ) -> Result { let text = if args.text.is_empty() { let mut buf = String::new(); stdin.read_to_string(&mut buf)?; // One trailing newline is how a pipe ends, not part of the message — // keeping it would submit the text twice. buf.strip_suffix('\n') .map(|t| t.strip_suffix('\r').unwrap_or(t)) .unwrap_or(&buf) .to_string() } else { args.text.join(" ") }; if text.is_empty() { return Err(CliError::NothingToSend); } let id = match uuid::Uuid::parse_str(args.session.trim()) { Ok(id) => id, Err(_) => { let sessions = client.list_sessions().await?; resolve_session(&sessions, &args.session)? } }; let req = SendInputRequest { text, submit: !args.no_enter, }; client.send_input(id, &req).await?; Ok(id) } #[allow(clippy::write_literal)] fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &DaemonInfo) { if sessions.is_empty() { writeln!(out, "no sessions").ok(); return; } let mut sorted: Vec<&Session> = sessions.iter().collect(); sorted.sort_by_key(|s| s.created_at); if !info.hostname.is_empty() { let warn = if maestro_protocol::is_compatible(&info.version) { "" } else { " (protocol mismatch)" }; let version = if info.version.is_empty() { String::new() } else { format!(" maestro {}{warn}", info.version) }; writeln!(out, "\n{}{version}", info.hostname).ok(); } writeln!( out, " {:<36} {:<8} {:<9} {:<28} {:<40} {}", "ID", "STATUS", "ACTIVITY", "DIR", "COMMAND", "BLOCKED" ) .ok(); for s in sorted { // The pane's live cwd if observed, else the spawn dir, home-abbreviated. let dir = abbreviate_dir(s.current_cwd.as_deref().unwrap_or(&s.cwd), &info.home_dir); let status = match s.status { SessionStatus::Running => "running", SessionStatus::Dead => "dead", }; let activity = match s.activity { ActivityState::Active => "active", ActivityState::Idle => "idle", ActivityState::AwaitingInput => "awaiting", ActivityState::Error => "error", }; // Show the live foreground command alongside the spawn command when // the daemon has observed one that differs (NAP-340). let command = match &s.foreground { Some(fg) if fg != &s.command => format!("{} [{}]", s.command, fg), _ => s.command.clone(), }; // #83: what an unforced kill would be refused for, in InTheWay's own // words; blank while nothing blocks (or hasn't been probed yet). let blocked = blocked_label(s); writeln!( out, " {:<36} {:<8} {:<9} {:<28} {:<40} {blocked}", s.id, status, activity, dir, command ) .ok(); } } /// The blocked-work indicator (#83), phrased by [`InTheWay`]'s `Display`. /// Empty unless something actually blocks — "no unsaved work" would just be /// noise on every row. pub(crate) fn blocked_label(s: &Session) -> String { s.blocked_work .filter(|b| !b.is_clear()) .map(|b| b.to_string()) .unwrap_or_default() } #[cfg(test)] mod tests { use std::collections::HashMap; use std::path::PathBuf; use chrono::{TimeZone, Utc}; use maestro_protocol::{ActivityState, Session, SessionStatus}; use uuid::Uuid; use super::DaemonInfo; fn session(cwd: &str, command: &str, id: u128) -> Session { Session { id: Uuid::from_u128(id), cwd: PathBuf::from(cwd), command: command.into(), env: HashMap::new(), metadata: HashMap::new(), worktree: None, status: SessionStatus::Running, created_at: Utc.with_ymd_and_hms(2026, 5, 25, 12, 0, 0).unwrap(), foreground: None, activity: Default::default(), activity_detail: None, last_activity_at: chrono::Utc::now(), activity_since: chrono::Utc::now(), current_cwd: None, blocked_work: None, last_focused_at: None, } } fn info(home: &str) -> DaemonInfo { DaemonInfo { hostname: "naps-laptop".into(), home_dir: home.into(), ..Default::default() } } #[test] fn formats_table() { let sessions = vec![ session("/home/naps/maestro", "claude", 1), session("/srv/work/api", "bash", 2), ]; let mut buf = Vec::new(); super::print_session_table(&mut buf, &sessions, &info("/home/naps")); let s = String::from_utf8(buf).unwrap(); assert!(s.contains("naps-laptop"), "{s}"); // Home dir collapses to ~ in the DIR column. assert!(s.contains("~/maestro"), "{s}"); // Outside the home dir → path shown verbatim. assert!(s.contains("/srv/work/api"), "{s}"); assert!(s.contains("running"), "{s}"); assert!(s.contains("ACTIVITY"), "{s}"); assert!(s.contains("DIR"), "{s}"); assert!(s.contains("idle"), "{s}"); } #[test] fn live_cwd_overrides_spawn_dir_in_table() { let mut s = session("/home/naps/maestro", "bash", 1); s.current_cwd = Some(PathBuf::from("/home/naps/maestro/crates/maestro-cli")); let mut buf = Vec::new(); super::print_session_table(&mut buf, &[s], &info("/home/naps")); let out = String::from_utf8(buf).unwrap(); assert!(out.contains("~/maestro/crates/maestro-cli"), "{out}"); } #[test] fn shows_activity_and_foreground() { let mut s1 = session("/tmp", "bash", 1); s1.activity = ActivityState::AwaitingInput; s1.foreground = Some("claude".into()); // differs from spawn command let mut buf = Vec::new(); super::print_session_table(&mut buf, &[s1], &info("")); let out = String::from_utf8(buf).unwrap(); assert!(out.contains("awaiting"), "{out}"); // Live foreground surfaces next to the spawn command. assert!(out.contains("bash [claude]"), "{out}"); } fn named(id: u128, name: &str) -> Session { let mut s = session("/tmp", "claude", id); s.metadata .insert("name".into(), serde_json::Value::String(name.into())); s } #[test] fn resolves_by_name_id_prefix_and_reports_what_it_cannot() { use super::{resolve_session, ResolveError}; // Ids that differ in their first characters, so a short prefix is // enough to pick one — the ergonomics the prefix match exists for. let (a, b) = (1u128 << 124, 2u128 << 124); let sessions = vec![named(a, "planner"), named(b, "builder")]; let one = Uuid::from_u128(a); assert_eq!(resolve_session(&sessions, "planner").unwrap(), one); // Names match case-insensitively and ignore surrounding space. assert_eq!(resolve_session(&sessions, " Planner ").unwrap(), one); // An id prefix works when it picks exactly one session. let prefix = &one.to_string()[..8]; assert_eq!(resolve_session(&sessions, prefix).unwrap(), one); assert!(matches!( resolve_session(&sessions, "nope"), Err(ResolveError::NoMatch(_)) )); } /// Two sessions sharing a name is legal — the daemon never enforced /// uniqueness — so `send` must refuse rather than pick one. #[test] fn an_ambiguous_name_is_refused() { use super::{resolve_session, ResolveError}; let sessions = vec![named(1 << 124, "worker"), named(2 << 124, "worker")]; assert!(matches!( resolve_session(&sessions, "worker"), Err(ResolveError::Ambiguous(_, 2)) )); } /// A session named after its own id prefix means the name: an operator /// who typed a name they chose should not be routed by hex coincidence. #[test] fn names_win_over_id_prefixes() { use super::resolve_session; let two = Uuid::from_u128(2 << 124); let prefix = two.to_string()[..8].to_string(); let sessions = vec![named(1 << 124, &prefix), session("/tmp", "bash", 2 << 124)]; assert_eq!( resolve_session(&sessions, &prefix).unwrap(), Uuid::from_u128(1 << 124) ); } #[test] fn empty_sessions() { let mut buf = Vec::new(); super::print_session_table(&mut buf, &[], &info("")); assert_eq!(String::from_utf8(buf).unwrap(), "no sessions\n"); } /// #83: a blocked session shows what an unforced kill would be refused /// for, in `InTheWay`'s own words; a clear or unprobed one shows nothing. #[test] fn shows_the_blocked_work_summary_only_when_something_blocks() { use maestro_protocol::InTheWay; let mut blocked = session("/tmp", "claude", 1); blocked.blocked_work = Some(InTheWay { uncommitted_files: 3, unpushed_commits: 2, ..InTheWay::default() }); let mut clear = session("/tmp", "bash", 2); clear.blocked_work = Some(InTheWay::default()); let unprobed = session("/tmp", "zsh", 3); let mut buf = Vec::new(); super::print_session_table(&mut buf, &[blocked, clear, unprobed], &info("")); let out = String::from_utf8(buf).unwrap(); assert!( out.contains("3 uncommitted files, 2 unpushed commits"), "{out}" ); // Clear and unprobed rows stay blank in the BLOCKED column. let lines: Vec<&str> = out.lines().collect(); let header = lines.iter().position(|l| l.contains("BLOCKED")).unwrap(); for line in &lines[header + 1..] { if !line.contains("uncommitted files") { assert!(!line.contains("unsaved work"), "{line}"); } } } }