feat(maestro): add maestro send for typing into a session
Adds POST /api/sessions/:id/input and a CLI command on top of it, so a script (or another agent) can hand an instruction to a running session without attaching to it. The text goes in as one write and the Enter follows 150ms later: agents that run a full-screen prompt coalesce a burst of input into a paste, and a newline inside that burst becomes a line break instead of sending the message. `--no-enter` skips the Enter and leaves the text in the prompt. The session argument takes an ID, a unique ID prefix, or a session name; names win over prefixes. A session whose pane has exited is refused (409) rather than silently accepted — unlike a stray keystroke on an attach socket, a send that goes nowhere is worth hearing about. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
766eabbcb6
commit
28778b2224
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user