Structured sessions were rejected by `send_input` with NotTerminal. Now `run_send` checks the session view and routes to `send_structured_prompt` for structured sessions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
515 lines
18 KiB
Rust
515 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,
|
|
}
|
|
}
|
|
|
|
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}");
|
|
}
|
|
}
|
|
}
|
|
}
|