Compare commits

..
3 Commits
Author SHA1 Message Date
Miguel Palhas 4668cf8ece Merge remote-tracking branch 'origin/main' into recorder-4
ci / rust (push) Failing after 42s
ci / web (push) Failing after 20s
ci / flake (push) Successful in 3m39s
2026-09-08 13:46:03 +01:00
Miguel PalhasandClaude Opus 4.6 638155de03 feat(maestro): move activity indicator into scroll area
The "Running 1m ..." status now sits at the tail of the chat
history instead of pinned above the input, matching Claude Code's
placement.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 13:45:56 +01:00
Miguel PalhasandClaude Opus 4.6 b339fecad3 feat(maestro): route maestro send through ACP for structured sessions
ci / rust (push) Waiting to run
ci / web (push) Waiting to run
ci / flake (push) Waiting to run
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>
2026-09-08 13:43:01 +01:00
6 changed files with 107 additions and 25 deletions
+2 -2
View File
@@ -41,8 +41,8 @@ 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 text to a running session. Terminal sessions receive raw
/// keystrokes; structured sessions receive an ACP prompt.
Send(SendArgs),
/// Install or update the agent activity hook (#106).
Hooks(HooksArgs),
+27 -15
View File
@@ -130,9 +130,12 @@ pub fn resolve_session(sessions: &[Session], needle: &str) -> Result<uuid::Uuid,
/// `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.
/// 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,
@@ -155,19 +158,28 @@ pub async fn run_send(
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)?
}
};
// 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);
let req = SendInputRequest {
text,
submit: !args.no_enter,
};
client.send_input(id, &req).await?;
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)
}
@@ -2,7 +2,7 @@
//! was unit-tested, but nothing exercised the client, the error decoding, or
//! the unreachable-daemon path without compiling and forking a real daemon.
use maestro_protocol::{DaemonClient, DaemonInfo};
use maestro_protocol::{DaemonClient, DaemonInfo, SessionView};
use maestro_testing::{session, MockDaemon};
use uuid::Uuid;
@@ -202,6 +202,35 @@ async fn send_resolves_a_session_by_name() {
assert_eq!(sent, id);
}
/// A structured session routes through the ACP prompt endpoint, not the
/// terminal input one — `--no-enter` is irrelevant (ACP prompts are
/// complete messages).
#[tokio::test]
async fn send_to_structured_session_uses_acp_prompt() {
let mock = MockDaemon::start().await;
let id = Uuid::from_u128(13);
let mut s = session(id, "/tmp", "claude");
s.view = SessionView::Structured;
mock.add_session(s);
let args = maestro_cli::cli::SendArgs {
session: id.to_string(),
text: vec!["fix".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);
// Terminal input must stay empty — the message went through ACP.
assert!(mock.inputs().is_empty());
let prompts = mock.structured_prompts();
assert_eq!(prompts.len(), 1);
assert_eq!(prompts[0].0, id);
assert_eq!(prompts[0].1.prompt, "fix the tests");
}
#[tokio::test]
async fn send_to_an_unknown_session_fails_without_typing_anything() {
let mock = MockDaemon::start().await;
+38 -1
View File
@@ -22,7 +22,8 @@ use chrono::Utc;
use futures_util::{SinkExt, StreamExt};
use maestro_protocol::{
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonClient, DaemonInfo, DirListing,
GitRoot, RecentDirs, SendInputRequest, Session, SessionStatus, PROTOCOL_VERSION,
GitRoot, RecentDirs, SendInputRequest, Session, SessionStatus, StructuredPromptRequest,
PROTOCOL_VERSION,
};
use tokio::net::TcpListener;
use tokio::sync::{broadcast, oneshot};
@@ -60,6 +61,8 @@ struct Inner {
forced_error: Option<(u16, ApiError)>,
/// Everything `POST /api/sessions/:id/input` was asked to type, in order.
inputs: Vec<(Uuid, SendInputRequest)>,
/// Every ACP prompt sent to a structured session, in order.
structured_prompts: Vec<(Uuid, StructuredPromptRequest)>,
}
#[derive(Clone)]
@@ -92,6 +95,10 @@ impl MockDaemon {
.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::patterns::STRUCTURED_PROMPT,
post(structured_prompt),
)
.route(routes::INFO, get(daemon_info))
.route(routes::DIRS, get(list_dir))
.route(routes::RECENT_DIRS, get(recent_dirs))
@@ -178,6 +185,16 @@ impl MockDaemon {
self.state.inner.lock().expect("poisoned").inputs.clone()
}
/// Every ACP prompt this daemon received, oldest first.
pub fn structured_prompts(&self) -> Vec<(Uuid, StructuredPromptRequest)> {
self.state
.inner
.lock()
.expect("poisoned")
.structured_prompts
.clone()
}
pub fn session_count(&self) -> usize {
self.state.inner.lock().expect("poisoned").sessions.len()
}
@@ -266,6 +283,26 @@ async fn send_input(
StatusCode::NO_CONTENT.into_response()
}
async fn structured_prompt(
State(s): State<AppState>,
Path(id): Path<Uuid>,
Json(req): Json<StructuredPromptRequest>,
) -> 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.structured_prompts.push((id, req));
StatusCode::NO_CONTENT.into_response()
}
async fn daemon_info(State(s): State<AppState>) -> Response {
if let Some(r) = forced(&s) {
return r;
+10 -4
View File
@@ -85,10 +85,10 @@ export function createStructuredConnection(
<div class="structured-main">
<div class="structured-scroll">
<div class="structured-rows"></div>
</div>
<div class="structured-activity" role="status" aria-live="polite" hidden>
<span class="structured-activity-pulse" aria-hidden="true"></span>
<span class="structured-activity-text">Thinking</span>
<div class="structured-activity" role="status" aria-live="polite" hidden>
<span class="structured-activity-pulse" aria-hidden="true"></span>
<span class="structured-activity-text">Thinking</span>
</div>
</div>
<form class="structured-composer">
<button type="button" class="btn btn-ghost btn-icon structured-attach" aria-label="Attach files" title="Attach files (up to 20 MiB each)">
@@ -698,6 +698,7 @@ export function createStructuredConnection(
function renderActivity(): void {
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
const wasHidden = activityEl.hidden;
activityEl.hidden = !active;
if (!active) {
optimisticTurnStartedAt = null;
@@ -720,6 +721,11 @@ export function createStructuredConnection(
}
activityText.textContent = parts.join(" · ");
if (activityTimer === null) activityTimer = window.setInterval(renderActivity, 1_000);
// Activity lives inside the scroll area; nudge scroll when it first appears.
if (wasHidden && followingBottom) {
beginTranscriptLayout(true);
finishTranscriptLayout();
}
}
function reconcileAwaitingBegin(): void {
-2
View File
@@ -2318,9 +2318,7 @@ input.leaf-rename:focus {
gap: var(--sp-2);
min-height: 30px;
padding: var(--sp-1) var(--sp-3);
border-top: 1px solid var(--border);
color: var(--muted);
background: var(--surface-2);
font-size: var(--fs-11);
font-variant-numeric: tabular-nums;
}