diff --git a/CLAUDE.md b/CLAUDE.md index 8dc5011..8579633 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,6 +11,7 @@ persistence, WebSocket for transport. maestro daemon tmux wrapper + session API on 127.0.0.1:7070 ├── GET/POST /api/sessions list / spawn ├── DELETE /api/sessions/:id kill + ├── POST /api/sessions/:id/input type text into a session ├── GET /api/info hostname, version, home dir, spawn presets ├── GET /api/dirs directory listing (spawn autocomplete) ├── GET /api/recent-dirs spawn history @@ -18,7 +19,7 @@ maestro daemon tmux wrapper + session API on 127.0.0.1:7070 ├── WS /api/events Snapshot + session deltas └── WS /api/attach/:id raw PTY bytes -maestro list/spawn/attach/kill CLI client +maestro list/spawn/attach/kill/send CLI client maestro tui ratatui client maestro-web serves the SPA, reverse-proxies /api/* to the daemon ``` diff --git a/crates/maestro-cli/src/cli.rs b/crates/maestro-cli/src/cli.rs index aa1cd4b..90da608 100644 --- a/crates/maestro-cli/src/cli.rs +++ b/crates/maestro-cli/src/cli.rs @@ -41,6 +41,9 @@ pub enum Command { Attach(AttachArgs), /// Kill a session by ID. Kill(KillArgs), + /// Type text into a running session, as if it had been typed on an + /// attached terminal. + Send(SendArgs), /// Install or update the agent activity hook (#106). Hooks(HooksArgs), } @@ -103,6 +106,20 @@ pub struct AttachArgs { pub detach_key: String, } +/// `maestro send ` +#[derive(Debug, clap::Args)] +pub struct SendArgs { + /// Session to type into: its ID, a unique ID prefix, or its name. + pub session: String, + /// Text to send. Omit it to read the text from stdin. + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + pub text: Vec, + /// Leave the text sitting in the session's prompt instead of pressing + /// Enter after it. + #[arg(long)] + pub no_enter: bool, +} + #[derive(Debug, clap::Args)] pub struct KillArgs { /// Session ID to kill. @@ -245,6 +262,40 @@ mod tests { } } + #[test] + fn parses_send_joining_the_message_words() { + let cli = + Cli::try_parse_from(["maestro", "send", "planner", "run", "the", "tests"]).unwrap(); + let Command::Send(a) = cli.command else { + panic!("expected send") + }; + assert_eq!(a.session, "planner"); + assert_eq!(a.text, vec!["run", "the", "tests"]); + assert!(!a.no_enter); + } + + /// The message is trailing_var_arg, so a word starting with `-` inside it + /// is text, not a flag clap should reject. + #[test] + fn send_message_may_start_with_a_dash() { + let cli = + Cli::try_parse_from(["maestro", "send", "--no-enter", "planner", "--force"]).unwrap(); + let Command::Send(a) = cli.command else { + panic!("expected send") + }; + assert!(a.no_enter); + assert_eq!(a.text, vec!["--force"]); + } + + #[test] + fn send_text_may_be_omitted_for_stdin() { + let cli = Cli::try_parse_from(["maestro", "send", "planner"]).unwrap(); + let Command::Send(a) = cli.command else { + panic!("expected send") + }; + assert!(a.text.is_empty()); + } + #[test] fn rejects_env_without_equals() { let err = Cli::try_parse_from([ diff --git a/crates/maestro-cli/src/dispatch.rs b/crates/maestro-cli/src/dispatch.rs index 491b5a0..4b689ee 100644 --- a/crates/maestro-cli/src/dispatch.rs +++ b/crates/maestro-cli/src/dispatch.rs @@ -10,7 +10,7 @@ use maestro_protocol::DaemonClient; use crate::attach::{parse_detach_key, run_attach_url}; use crate::cli::Command; -use crate::sessions::{run_kill, run_list, run_spawn}; +use crate::sessions::{run_kill, run_list, run_send, run_spawn}; use maestro_config::{default_config_path, MaestroConfigFile}; #[derive(Debug, thiserror::Error)] @@ -24,6 +24,10 @@ pub enum CliError { Attach(#[from] crate::attach::AttachError), #[error("{0}")] Hooks(#[from] maestro_hooks::HooksError), + #[error("{0}")] + Resolve(#[from] crate::sessions::ResolveError), + #[error("nothing to send: pass the text as arguments or pipe it on stdin")] + NothingToSend, #[error("could not determine the home directory ($HOME unset?)")] NoHome, #[error("{0}")] @@ -82,6 +86,10 @@ pub async fn dispatch( run_kill(&daemon, &args).await?; println!("killed {}", args.session_id); } + Command::Send(args) => { + let id = run_send(&daemon, &args, &mut std::io::stdin()).await?; + println!("sent to {id}"); + } Command::Hooks(_) => unreachable!("hooks handled before the daemon client exists"), } Ok(()) diff --git a/crates/maestro-cli/src/sessions.rs b/crates/maestro-cli/src/sessions.rs index dfd3047..2b83457 100644 --- a/crates/maestro-cli/src/sessions.rs +++ b/crates/maestro-cli/src/sessions.rs @@ -5,12 +5,13 @@ use std::io::Write; use maestro_protocol::display::abbreviate_dir; use maestro_protocol::{ - ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, Session, - SessionStatus, WorktreeSpec, + ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, SendInputRequest, + Session, SessionStatus, WorktreeSpec, }; use serde_json::Value; -use crate::cli::{KillArgs, ListArgs, SpawnArgs}; +use crate::cli::{KillArgs, ListArgs, SendArgs, SpawnArgs}; +use crate::dispatch::CliError; /// `maestro list` pub async fn run_list( @@ -66,6 +67,92 @@ pub async fn run_kill(client: &DaemonClient, args: &KillArgs) -> Result<(), Clie 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() { @@ -218,6 +305,63 @@ mod tests { 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(); diff --git a/crates/maestro-cli/tests/against_mock_daemon.rs b/crates/maestro-cli/tests/against_mock_daemon.rs index 064daaf..7b6f9f8 100644 --- a/crates/maestro-cli/tests/against_mock_daemon.rs +++ b/crates/maestro-cli/tests/against_mock_daemon.rs @@ -108,3 +108,110 @@ async fn spawn_round_trips_through_the_daemon() { .expect("kill"); assert_eq!(mock.session_count(), 0); } + +#[tokio::test] +async fn send_types_text_and_submits_by_default() { + let mock = MockDaemon::start().await; + let id = Uuid::from_u128(7); + mock.add_session(session(id, "/home/mock/work", "claude")); + + let args = maestro_cli::cli::SendArgs { + session: id.to_string(), + text: vec!["run".into(), "the".into(), "tests".into()], + no_enter: false, + }; + let sent = maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty()) + .await + .expect("send"); + + assert_eq!(sent, id); + let inputs = mock.inputs(); + assert_eq!(inputs.len(), 1); + assert_eq!(inputs[0].0, id); + assert_eq!(inputs[0].1.text, "run the tests"); + assert!(inputs[0].1.submit, "Enter is the default"); +} + +/// `--no-enter` leaves the text in the prompt; the flag has to survive the +/// whole way to the daemon or the message is sent anyway. +#[tokio::test] +async fn send_without_enter_leaves_the_text_unsubmitted() { + let mock = MockDaemon::start().await; + let id = Uuid::from_u128(8); + mock.add_session(session(id, "/tmp", "bash")); + + let args = maestro_cli::cli::SendArgs { + session: id.to_string(), + text: vec!["ls".into()], + no_enter: true, + }; + maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty()) + .await + .expect("send"); + + assert!(!mock.inputs()[0].1.submit); +} + +/// No text arguments → the message comes from stdin, minus the trailing +/// newline a pipe or heredoc adds. +#[tokio::test] +async fn send_reads_stdin_and_drops_its_trailing_newline() { + let mock = MockDaemon::start().await; + let id = Uuid::from_u128(9); + mock.add_session(session(id, "/tmp", "bash")); + + let args = maestro_cli::cli::SendArgs { + session: id.to_string(), + text: Vec::new(), + no_enter: false, + }; + let mut stdin = std::io::Cursor::new(b"multi\nline\n".to_vec()); + maestro_cli::sessions::run_send(&mock.client(), &args, &mut stdin) + .await + .expect("send"); + + assert_eq!(mock.inputs()[0].1.text, "multi\nline"); +} + +/// A session named through `PATCH /api/sessions/:id` is addressable by that +/// name, so scripts don't have to carry a UUID around. +#[tokio::test] +async fn send_resolves_a_session_by_name() { + let mock = MockDaemon::start().await; + let id = Uuid::from_u128(10); + let mut named = session(id, "/tmp", "claude"); + named + .metadata + .insert("name".into(), serde_json::Value::String("planner".into())); + mock.add_session(named); + mock.add_session(session(Uuid::from_u128(11), "/tmp", "bash")); + + let args = maestro_cli::cli::SendArgs { + session: "Planner".into(), + text: vec!["go".into()], + no_enter: false, + }; + let sent = maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty()) + .await + .expect("send"); + + assert_eq!(sent, id); +} + +#[tokio::test] +async fn send_to_an_unknown_session_fails_without_typing_anything() { + let mock = MockDaemon::start().await; + mock.add_session(session(Uuid::from_u128(12), "/tmp", "bash")); + + let args = maestro_cli::cli::SendArgs { + session: "nope".into(), + text: vec!["hi".into()], + no_enter: false, + }; + let err = maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty()) + .await + .expect_err("should not resolve"); + + assert!(err.to_string().contains("no session matches"), "{err}"); + assert!(mock.inputs().is_empty()); +} diff --git a/crates/maestro-daemon/src/local_api.rs b/crates/maestro-daemon/src/local_api.rs index a33635f..82bfc91 100644 --- a/crates/maestro-daemon/src/local_api.rs +++ b/crates/maestro-daemon/src/local_api.rs @@ -20,7 +20,7 @@ use axum::{Json, Router}; use maestro_config::SpawnPreset; use maestro_protocol::{ routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch, - RenameSessionRequest, ReportActivityRequest, Session, PROTOCOL_VERSION, + RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, PROTOCOL_VERSION, }; use serde::Deserialize; use uuid::Uuid; @@ -44,6 +44,7 @@ pub fn router(state: LocalApiState) -> Router { Router::new() .route(routes::SESSIONS, get(list_sessions).post(create_session)) .route(routes::patterns::SESSION_ACTIVITY, post(report_activity)) + .route(routes::patterns::SESSION_INPUT, post(send_input)) .route( routes::patterns::SESSION, delete(kill_session).patch(rename_session), @@ -100,6 +101,9 @@ fn session_error_status(code: &str) -> StatusCode { "invalid_env" => StatusCode::BAD_REQUEST, // A guard refusal is the daemon doing its job, not a failure (#79). "kill_guarded" => StatusCode::CONFLICT, + // The session outlived its pane: nothing to type into, and nothing + // the daemon can do about it. + "not_running" => StatusCode::CONFLICT, _ => StatusCode::BAD_GATEWAY, } } @@ -247,6 +251,28 @@ async fn report_activity( } } +/// `POST /api/sessions/:id/input`: type text into a session's pane, then +/// press Enter unless the request turns `submit` off. Answers `204 No +/// Content`. A session that has exited is refused (409) rather than accepted +/// into nowhere. +async fn send_input( + State(s): State, + Path(id): Path, + Json(body): Json, +) -> Response { + match s.manager.send_input(id, &body.text, body.submit).await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(e) => { + let info = e.to_error_info(); + error_response( + session_error_status(&info.code), + info.code.as_str(), + info.message.as_str(), + ) + } + } +} + fn error_response(status: StatusCode, code: &str, message: &str) -> Response { (status, Json(ApiError::new(code, message))).into_response() } diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index d34468a..c2d510f 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -40,6 +40,15 @@ pub const DEFAULT_LIVENESS_INTERVAL: Duration = Duration::from_secs(2); /// attempt, which is all #83 needs. pub const DEFAULT_BLOCKED_WORK_INTERVAL: Duration = Duration::from_secs(15); +/// Gap between the text of a `send` and the Enter that submits it. +/// +/// Agents that run a full-screen prompt (Claude Code, opencode) coalesce a +/// burst of input into one paste; a newline inside that burst becomes a line +/// break in the message instead of sending it. Landing the Enter as its own +/// write, after the reader has drained the text, is what makes the message +/// actually go. +const SUBMIT_DELAY: Duration = Duration::from_millis(150); + /// Per-session daemon-local bookkeeping that complements the public `Session`. #[derive(Debug)] pub struct SessionRecord { @@ -78,6 +87,11 @@ pub enum SessionError { NotFound(Uuid), #[error("invalid environment variable name: {0:?}")] InvalidEnv(String), + /// The session exists but its pane is gone, so there is nothing to type + /// into. Writing to a dead session is a client bug worth reporting, unlike + /// a stray keystroke on an attach socket, which is dropped. + #[error("session is no longer running: {0}")] + NotRunning(Uuid), /// The kill was refused (#79): the session's worktree holds unsaved work. /// Nothing happened — the session keeps running and the tree is untouched, /// so it stays the operator's handle on their own unfinished work. @@ -94,6 +108,7 @@ impl SessionError { SessionError::Io(_) => "io_failed", SessionError::NotFound(_) => "not_found", SessionError::InvalidEnv(_) => "invalid_env", + SessionError::NotRunning(_) => "not_running", SessionError::KillGuarded(_) => "kill_guarded", }; let unsaved_work = match self { @@ -671,6 +686,33 @@ impl SessionManager { .map_err(SessionError::Tmux) } + /// Type `text` into a session's pane, with an Enter after it when + /// `submit` is set. + /// + /// Unlike [`Self::send_keys`], a dead session is an error: the caller + /// asked for text to land somewhere, and a silent success would let a + /// script believe an agent had been told something it never heard. + pub async fn send_input(&self, id: Uuid, text: &str, submit: bool) -> Result<(), SessionError> { + match self.status(id).await { + None => return Err(SessionError::NotFound(id)), + Some(SessionStatus::Dead) => return Err(SessionError::NotRunning(id)), + Some(SessionStatus::Running) => {} + } + let tmux = self.inner.tmux.clone(); + let bytes = text.as_bytes().to_vec(); + tmux_blocking(move || tmux.send_keys(id, &bytes)) + .await + .map_err(SessionError::Tmux)?; + if submit { + tokio::time::sleep(SUBMIT_DELAY).await; + let tmux = self.inner.tmux.clone(); + tmux_blocking(move || tmux.send_keys(id, b"\r")) + .await + .map_err(SessionError::Tmux)?; + } + Ok(()) + } + /// Resize a session's window. No-op for a dead session, as with /// [`Self::send_keys`]. pub async fn resize(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), SessionError> { diff --git a/crates/maestro-e2e/tests/e2e.rs b/crates/maestro-e2e/tests/e2e.rs index 1ce1db7..7c77395 100644 --- a/crates/maestro-e2e/tests/e2e.rs +++ b/crates/maestro-e2e/tests/e2e.rs @@ -1250,6 +1250,143 @@ async fn cli_list_reads_from_daemon() { ); } +/// `maestro send` end to end: the shipped binary, a real daemon, a real tmux +/// pane. The shell only runs the command if the Enter arrived as its own +/// write after the text, so the file appearing is the whole path working. +#[tokio::test(flavor = "multi_thread")] +async fn cli_send_types_a_command_into_a_live_shell() { + let (daemon, client) = bring_up().await; + let cwd = TempDir::new().unwrap(); + let sess = client + .spawn_session(&create_req(cwd.path(), "bash --norc --noprofile")) + .await + .expect("spawn bash"); + + let home = TempDir::new().unwrap(); + let cfg_path = home.path().join("config.toml"); + let out = run_cli( + &["send", &sess.id.to_string(), "touch", "marker.txt"], + &[ + ("HOME", home.path().to_str().unwrap()), + ("MAESTRO_CONFIG", cfg_path.to_str().unwrap()), + ("MAESTRO_DAEMON_URL", &daemon.http_base()), + ], + ); + assert!( + out.status.success(), + "send failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let marker = cwd.path().join("marker.txt"); + wait_for_async( + "the shell runs the sent command", + Duration::from_secs(10), + || { + let marker = marker.clone(); + async move { marker.exists() } + }, + ) + .await; + + client.kill_session(sess.id).await.expect("kill"); +} + +/// `--no-enter` fills the prompt without submitting: the command must sit +/// there unrun. Same setup as the test above, so the only difference is the +/// flag. +#[tokio::test(flavor = "multi_thread")] +async fn send_without_enter_does_not_run_the_command() { + let (daemon, client) = bring_up().await; + let cwd = TempDir::new().unwrap(); + let sess = client + .spawn_session(&create_req(cwd.path(), "bash --norc --noprofile")) + .await + .expect("spawn bash"); + + let home = TempDir::new().unwrap(); + let out = run_cli( + &[ + "send", + "--no-enter", + &sess.id.to_string(), + "touch", + "unsubmitted.txt", + ], + &[ + ("HOME", home.path().to_str().unwrap()), + ( + "MAESTRO_CONFIG", + home.path().join("c.toml").to_str().unwrap(), + ), + ("MAESTRO_DAEMON_URL", &daemon.http_base()), + ], + ); + assert!( + out.status.success(), + "send failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + + tokio::time::sleep(Duration::from_secs(2)).await; + assert!( + !cwd.path().join("unsubmitted.txt").exists(), + "the command ran despite --no-enter" + ); + + client.kill_session(sess.id).await.expect("kill"); +} + +/// A session whose command exited still exists in the catalog, but its pane +/// is gone. Typing into it is refused instead of being swallowed — a script +/// that sends an instruction to a dead agent has to hear about it. +#[tokio::test(flavor = "multi_thread")] +async fn send_to_a_dead_session_is_refused() { + use maestro_protocol::{SendInputRequest, SessionStatus}; + let (_daemon, client) = bring_up().await; + let cwd = TempDir::new().unwrap(); + let sess = client + .spawn_session(&create_req(cwd.path(), "true")) + .await + .expect("spawn true"); + + wait_for_async( + "the session to be seen dead", + Duration::from_secs(15), + || { + let client = client.clone(); + let id = sess.id; + async move { + client + .list_sessions() + .await + .map(|ss| { + ss.iter() + .any(|s| s.id == id && s.status == SessionStatus::Dead) + }) + .unwrap_or(false) + } + }, + ) + .await; + + let err = client + .daemon() + .send_input( + sess.id, + &SendInputRequest { + text: "hello".into(), + submit: true, + }, + ) + .await + .expect_err("a dead session cannot be typed into"); + assert!( + err.to_string().contains("no longer running"), + "unexpected error: {err}" + ); +} + #[tokio::test(flavor = "multi_thread")] async fn cli_list_fails_when_daemon_is_down() { let dead_daemon = format!("http://127.0.0.1:{}", maestro_e2e::util::free_port()); diff --git a/crates/maestro-protocol/src/client.rs b/crates/maestro-protocol/src/client.rs index 77f6050..01ceb7f 100644 --- a/crates/maestro-protocol/src/client.rs +++ b/crates/maestro-protocol/src/client.rs @@ -16,7 +16,7 @@ use uuid::Uuid; use crate::routes::{self, UrlError}; use crate::{ ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, - Project, RecentDirs, RenameSessionRequest, ReportActivityRequest, Session, + Project, RecentDirs, RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, }; /// Time budget for establishing a TCP connection to the daemon. @@ -155,6 +155,17 @@ impl DaemonClient { Ok(()) } + /// `POST /api/sessions/:id/input`: type text into a session's pane, with + /// an Enter after it unless `submit` is off. Answers `204 No Content`; a + /// session that has exited is refused rather than silently accepted. + pub async fn send_input(&self, id: Uuid, req: &SendInputRequest) -> Result<(), ClientError> { + let resp = self + .send(Method::POST, &routes::session_input(id), &[], Some(req)) + .await?; + check(resp).await?; + Ok(()) + } + pub async fn daemon_info(&self) -> Result { self.get(routes::INFO, &[]).await } diff --git a/crates/maestro-protocol/src/display.rs b/crates/maestro-protocol/src/display.rs index 47dc29a..b967b78 100644 --- a/crates/maestro-protocol/src/display.rs +++ b/crates/maestro-protocol/src/display.rs @@ -7,6 +7,15 @@ use std::path::{Path, PathBuf}; use crate::Session; +/// The name a session was given (#125), if any. `None` for one that was never +/// renamed — clients fall back to the command. +pub fn custom_name(s: &Session) -> Option<&str> { + match s.metadata.get("name") { + Some(serde_json::Value::String(name)) => Some(name.as_str()), + _ => None, + } +} + /// Collapse a daemon's home dir to `~`. `home` empty leaves the path /// untouched. Mirrors the web's `abbreviateCwd`. pub fn abbreviate_dir(path: &Path, home: &str) -> String { diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index 9a1c81b..2966d42 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -512,6 +512,24 @@ pub struct NameBatch { pub names: Vec, } +/// HTTP `POST /api/sessions/:id/input` body: text typed into a session's +/// pane, as if the operator had typed it on an attached terminal. +/// +/// `submit` presses Enter after the text — what makes `maestro send` a way to +/// give an agent an instruction rather than only to fill its prompt. It goes +/// out as its own write a beat later, because a TUI agent that reads the text +/// as one paste would otherwise swallow the newline into the message. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SendInputRequest { + pub text: String, + #[serde(default = "submit_default")] + pub submit: bool, +} + +fn submit_default() -> bool { + true +} + /// Longest session name the daemon stores (#125), in characters. A name is a /// sidebar card's line 1; past this it only truncates on every client anyway. pub const MAX_SESSION_NAME_LEN: usize = 64; diff --git a/crates/maestro-protocol/src/routes.rs b/crates/maestro-protocol/src/routes.rs index 7f4cbfb..9cf1dec 100644 --- a/crates/maestro-protocol/src/routes.rs +++ b/crates/maestro-protocol/src/routes.rs @@ -49,6 +49,11 @@ pub fn session_activity(id: Uuid) -> String { format!("{SESSIONS}/{id}/activity") } +/// `POST /api/sessions/:id/input`: type text into a session's pane. +pub fn session_input(id: Uuid) -> String { + format!("{SESSIONS}/{id}/input") +} + /// `WS /api/attach/:id` — raw PTY bytes. pub fn attach(id: Uuid) -> String { format!("{API_PREFIX}/attach/{id}") @@ -59,6 +64,7 @@ pub fn attach(id: Uuid) -> String { pub mod patterns { pub const SESSION: &str = "/api/sessions/:id"; pub const SESSION_ACTIVITY: &str = "/api/sessions/:id/activity"; + pub const SESSION_INPUT: &str = "/api/sessions/:id/input"; pub const ATTACH: &str = "/api/attach/:id"; } @@ -170,6 +176,10 @@ mod tests { session_activity(id()), "/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/activity" ); + assert_eq!( + session_input(id()), + "/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/input" + ); assert_eq!( attach(id()), "/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50" @@ -192,6 +202,10 @@ mod tests { patterns::SESSION_ACTIVITY.trim_end_matches(":id/activity"), session(id()).trim_end_matches(&id().to_string()) ); + assert_eq!( + patterns::SESSION_INPUT.trim_end_matches(":id/input"), + session(id()).trim_end_matches(&id().to_string()) + ); assert_eq!( patterns::ATTACH.trim_end_matches(":id"), attach(id()).trim_end_matches(&id().to_string()) diff --git a/crates/maestro-testing/src/lib.rs b/crates/maestro-testing/src/lib.rs index 8defccf..14bc430 100644 --- a/crates/maestro-testing/src/lib.rs +++ b/crates/maestro-testing/src/lib.rs @@ -16,13 +16,13 @@ use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; use axum::extract::{Path, Query, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; -use axum::routing::{any, delete, get}; +use axum::routing::{any, delete, get, post}; use axum::{Json, Router}; use chrono::Utc; use futures_util::{SinkExt, StreamExt}; use maestro_protocol::{ routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonClient, DaemonInfo, DirListing, - GitRoot, RecentDirs, Session, SessionStatus, PROTOCOL_VERSION, + GitRoot, RecentDirs, SendInputRequest, Session, SessionStatus, PROTOCOL_VERSION, }; use tokio::net::TcpListener; use tokio::sync::{broadcast, oneshot}; @@ -55,6 +55,8 @@ struct Inner { info: Option, /// When set, every route answers with this instead of its normal response. forced_error: Option<(u16, ApiError)>, + /// Everything `POST /api/sessions/:id/input` was asked to type, in order. + inputs: Vec<(Uuid, SendInputRequest)>, } #[derive(Clone)] @@ -86,6 +88,7 @@ impl MockDaemon { let app = Router::new() .route(routes::SESSIONS, get(list_sessions).post(create_session)) .route(routes::patterns::SESSION, delete(kill_session)) + .route(routes::patterns::SESSION_INPUT, post(send_input)) .route(routes::INFO, get(daemon_info)) .route(routes::DIRS, get(list_dir)) .route(routes::RECENT_DIRS, get(recent_dirs)) @@ -167,6 +170,11 @@ impl MockDaemon { self.state.inner.lock().expect("poisoned").forced_error = None; } + /// Every `send` this daemon received, oldest first. + pub fn inputs(&self) -> Vec<(Uuid, SendInputRequest)> { + self.state.inner.lock().expect("poisoned").inputs.clone() + } + pub fn session_count(&self) -> usize { self.state.inner.lock().expect("poisoned").sessions.len() } @@ -233,6 +241,28 @@ async fn kill_session(State(s): State, Path(id): Path) -> Respon StatusCode::NO_CONTENT.into_response() } +/// Records the text instead of typing it — the subject under test is the +/// client's request, not tmux. +async fn send_input( + State(s): State, + Path(id): Path, + Json(req): Json, +) -> Response { + if let Some(r) = forced(&s) { + return r; + } + let mut guard = s.inner.lock().expect("poisoned"); + if !guard.sessions.iter().any(|x| x.id == id) { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("not_found", "no such session on this daemon")), + ) + .into_response(); + } + guard.inputs.push((id, req)); + StatusCode::NO_CONTENT.into_response() +} + async fn daemon_info(State(s): State) -> Response { if let Some(r) = forced(&s) { return r; diff --git a/crates/maestro/src/main.rs b/crates/maestro/src/main.rs index ea157c0..9dfc11b 100644 --- a/crates/maestro/src/main.rs +++ b/crates/maestro/src/main.rs @@ -57,6 +57,10 @@ enum Command { /// Kill a session by ID. #[cfg(feature = "cli")] Kill(maestro_cli::cli::KillArgs), + /// Type text into a running session, as if it had been typed on an + /// attached terminal. + #[cfg(feature = "cli")] + Send(maestro_cli::cli::SendArgs), /// Install or update the agent activity hook (#106). #[cfg(feature = "cli")] Hooks(maestro_cli::cli::HooksArgs), @@ -144,6 +148,15 @@ async fn dispatch(cli: Cli) -> Result<(), Box> { .await? } #[cfg(feature = "cli")] + Command::Send(args) => { + maestro_cli::dispatch( + maestro_cli::cli::Command::Send(args), + cli.config, + cli.daemon_url, + ) + .await? + } + #[cfg(feature = "cli")] Command::Hooks(args) => { maestro_cli::dispatch( maestro_cli::cli::Command::Hooks(args), @@ -190,6 +203,7 @@ fn is_cli_subcommand(command: &Option) -> bool { | Some(Command::Spawn(_)) | Some(Command::Attach(_)) | Some(Command::Kill(_)) + | Some(Command::Send(_)) | Some(Command::Hooks(_)) ) }