Prompts sent while a turn is active are queued on the daemon instead of in the browser's React state. The queue is persisted in the session sidecar and drained as the next turn when the current one completes. Fixes the bug where queued messages were delivered to the wrong session when switching focus in the UI: the client-side queue captured the new sessionId in its drain closure. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
517 lines
18 KiB
Rust
517 lines
18 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, supports_structured_view};
|
|
use maestro_protocol::{
|
|
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, SendInputRequest,
|
|
Session, SessionStatus, SessionView, 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: spawn_view(&args),
|
|
cwd: args.cwd.clone(),
|
|
command: args.command.clone(),
|
|
env,
|
|
metadata,
|
|
worktree,
|
|
auto_approve: false,
|
|
};
|
|
client.create_session(&body).await
|
|
}
|
|
|
|
/// The view a `maestro spawn` asks for: structured whenever the command
|
|
/// resolves to an agent with an ACP adapter, since that is the richer session
|
|
/// and the one both other clients now default to. `--terminal` opts out;
|
|
/// `--structured` forces the request through even for a command this side
|
|
/// does not recognise, leaving the refusal to the daemon.
|
|
fn spawn_view(args: &SpawnArgs) -> SessionView {
|
|
if args.terminal {
|
|
return SessionView::Terminal;
|
|
}
|
|
if args.structured || supports_structured_view(&args.command) {
|
|
SessionView::Structured
|
|
} else {
|
|
SessionView::Terminal
|
|
}
|
|
}
|
|
|
|
/// `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.
|
|
///
|
|
/// Terminal sessions get raw keystrokes via `send_input`; structured sessions
|
|
/// get an ACP prompt via `send_structured_prompt` (`--no-enter` is ignored
|
|
/// there — ACP prompts are always complete messages).
|
|
///
|
|
/// 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);
|
|
}
|
|
|
|
// We always need the session list: even a bare UUID needs the view field
|
|
// to decide which endpoint to hit.
|
|
let sessions = client.list_sessions().await?;
|
|
let id = resolve_session(&sessions, &args.session)?;
|
|
let view = sessions
|
|
.iter()
|
|
.find(|s| s.id == id)
|
|
.map(|s| s.view)
|
|
.unwrap_or(SessionView::Terminal);
|
|
|
|
match view {
|
|
SessionView::Structured => {
|
|
client.send_structured_prompt(id, &text).await?;
|
|
}
|
|
SessionView::Terminal => {
|
|
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} {:<10} {:<9} {:<28} {:<40} {}",
|
|
"ID", "STATUS", "VIEW", "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 view = match s.view {
|
|
SessionView::Terminal => "terminal",
|
|
SessionView::Structured => "structured",
|
|
};
|
|
let activity = match s.activity {
|
|
ActivityState::Active => "active",
|
|
ActivityState::Idle => "idle",
|
|
ActivityState::AwaitingInput => "awaiting",
|
|
ActivityState::Error => "error",
|
|
ActivityState::Starting => "starting",
|
|
};
|
|
// 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} {:<10} {:<9} {:<28} {:<40} {blocked}",
|
|
s.id, status, view, 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, SessionView};
|
|
use uuid::Uuid;
|
|
|
|
use super::DaemonInfo;
|
|
|
|
use clap::Parser;
|
|
|
|
use crate::cli::{Cli, Command};
|
|
|
|
fn spawn_args(extra: &[&str]) -> crate::cli::SpawnArgs {
|
|
let mut argv = vec!["maestro", "spawn", "--cwd", "/tmp", "--command"];
|
|
argv.extend_from_slice(extra);
|
|
let cli = Cli::try_parse_from(argv).expect("parses");
|
|
let Command::Spawn(args) = cli.command else {
|
|
panic!("expected spawn")
|
|
};
|
|
args
|
|
}
|
|
|
|
/// The CLI follows the two interactive clients: a command with an ACP
|
|
/// adapter spawns structured unless `--terminal` says otherwise, and
|
|
/// `--structured` still forces the request for a command this side does
|
|
/// not recognise.
|
|
#[test]
|
|
fn spawn_defaults_to_structured_for_supported_agents() {
|
|
use super::spawn_view;
|
|
assert_eq!(
|
|
spawn_view(&spawn_args(&["claude"])),
|
|
SessionView::Structured
|
|
);
|
|
assert_eq!(
|
|
spawn_view(&spawn_args(&["npx codex exec"])),
|
|
SessionView::Structured
|
|
);
|
|
assert_eq!(spawn_view(&spawn_args(&["bash"])), SessionView::Terminal);
|
|
assert_eq!(
|
|
spawn_view(&spawn_args(&["claude", "--terminal"])),
|
|
SessionView::Terminal
|
|
);
|
|
assert_eq!(
|
|
spawn_view(&spawn_args(&["opencode", "--structured"])),
|
|
SessionView::Structured
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn spawn_refuses_both_view_flags_at_once() {
|
|
let err = Cli::try_parse_from([
|
|
"maestro",
|
|
"spawn",
|
|
"--cwd",
|
|
"/tmp",
|
|
"--command",
|
|
"claude",
|
|
"--structured",
|
|
"--terminal",
|
|
])
|
|
.unwrap_err();
|
|
assert!(err.to_string().contains("--terminal"), "{err}");
|
|
}
|
|
|
|
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,
|
|
auto_approve: false,
|
|
queued_prompts: Vec::new(),
|
|
}
|
|
}
|
|
|
|
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 table_exposes_terminal_and_structured_views() {
|
|
let terminal = session("/tmp", "bash", 1);
|
|
let mut structured = session("/tmp", "claude", 2);
|
|
structured.view = SessionView::Structured;
|
|
|
|
let mut buf = Vec::new();
|
|
super::print_session_table(&mut buf, &[terminal, structured], &info(""));
|
|
let out = String::from_utf8(buf).unwrap();
|
|
let lines: Vec<&str> = out.lines().collect();
|
|
let header = lines.iter().position(|line| line.contains("VIEW")).unwrap();
|
|
|
|
assert!(lines[header + 1].contains("terminal"), "{out}");
|
|
assert!(lines[header + 2].contains("structured"), "{out}");
|
|
}
|
|
|
|
#[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}");
|
|
}
|
|
}
|
|
}
|
|
}
|