A Structured session's adapter hands back an ACP session id from session/new; a respawn will pass it to session/load (#135). This is a new field rather than #161's agent_session_id: that one is the Claude Code conversation id the activity hook reports and `claude --resume` takes, minted by Claude Code itself. The ACP id is minted by the adapter, only exists for Structured sessions, and is what the adapter takes back. Overloading one field with two ids from two processes would leave a respawn unable to tell which kind it holds.
409 lines
14 KiB
Rust
409 lines
14 KiB
Rust
//! `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<Session, ClientError> {
|
|
let mut env: HashMap<String, String> = HashMap::new();
|
|
for (k, v) in args.env.iter() {
|
|
env.insert(k.clone(), v.clone());
|
|
}
|
|
|
|
let metadata: HashMap<String, Value> = 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 {
|
|
view: Default::default(),
|
|
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<uuid::Uuid, ResolveError> {
|
|
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<uuid::Uuid, CliError> {
|
|
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 {
|
|
view: Default::default(),
|
|
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,
|
|
acp_session_id: 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}");
|
|
}
|
|
}
|
|
}
|
|
}
|