diff --git a/crates/maestro-daemon/src/activity.rs b/crates/maestro-daemon/src/activity.rs index 21d2069..8d27160 100644 --- a/crates/maestro-daemon/src/activity.rs +++ b/crates/maestro-daemon/src/activity.rs @@ -66,6 +66,23 @@ pub fn decide(idle_for: Duration, awaiting_input: bool) -> ActivityState { } } +/// Derive a structured session's sidebar state from its ACP projection. +pub fn decide_structured( + turn_active: bool, + pending_approval: bool, + turn_failed: bool, +) -> ActivityState { + if turn_failed { + ActivityState::Error + } else if pending_approval { + ActivityState::AwaitingInput + } else if turn_active { + ActivityState::Active + } else { + ActivityState::Idle + } +} + /// How long a session's pane must be continuously focused (a client's pane, /// not a sidebar highlight) before an `AwaitingInput` glow clears: the /// operator has looked, so the rail stops asking for attention. @@ -136,6 +153,17 @@ mod tests { ); } + #[test] + fn structured_activity_follows_lifecycle_with_approval_precedence() { + assert_eq!(decide_structured(false, false, false), ActivityState::Idle); + assert_eq!(decide_structured(true, false, false), ActivityState::Active); + assert_eq!( + decide_structured(true, true, false), + ActivityState::AwaitingInput + ); + assert_eq!(decide_structured(false, false, true), ActivityState::Error); + } + #[test] fn report_is_in_force_until_exactly_its_expiry() { let ttl = Duration::from_secs(300); diff --git a/crates/maestro-daemon/src/local_api.rs b/crates/maestro-daemon/src/local_api.rs index 1cdc19c..81f10fe 100644 --- a/crates/maestro-daemon/src/local_api.rs +++ b/crates/maestro-daemon/src/local_api.rs @@ -307,11 +307,10 @@ async fn rename_session( } } -/// `POST /api/sessions/:id/activity` (#105): an agent reports its own state. -/// The report holds above the daemon's inference for a TTL, then inference -/// resumes — an agent that dies mid-run cannot pin its session at `Error` -/// forever. Unknown session is 404; an unknown state value is rejected by the -/// JSON decoder (422) rather than coerced. Answers `204 No Content`. +/// `POST /api/sessions/:id/activity` (#105): an agent reports a terminal +/// session's state. Structured sessions acknowledge and ignore these reports +/// because their activity comes from ACP. Unknown session is 404; an unknown +/// state value is rejected by the JSON decoder (422). Answers `204 No Content`. async fn report_activity( State(s): State, Path(id): Path, diff --git a/crates/maestro-daemon/src/projection.rs b/crates/maestro-daemon/src/projection.rs index 5b3fadd..043e6e6 100644 --- a/crates/maestro-daemon/src/projection.rs +++ b/crates/maestro-daemon/src/projection.rs @@ -4,7 +4,8 @@ use std::collections::{HashMap, HashSet}; use maestro_acp::schema::{ContentChunk, SessionNotification, SessionUpdate, ToolCall}; use maestro_protocol::{ - ReducedState, TranscriptDelta, TranscriptRow, TranscriptRowKind, TranscriptSnapshot, + ActivityState, ReducedState, TranscriptDelta, TranscriptRow, TranscriptRowKind, + TranscriptSnapshot, }; use serde_json::Value; @@ -36,6 +37,7 @@ pub struct Projection { pending_tool_updates: HashMap>, state: ReducedState, active_turn: Option, + turn_failed: bool, next_turn: u64, turn_tool_calls: HashSet, } @@ -109,11 +111,21 @@ impl Projection { self.active_turn.is_some() } + /// Sidebar activity derived from the live turn projection. + pub fn activity(&self) -> ActivityState { + crate::activity::decide_structured( + self.active_turn.is_some(), + self.state.pending_approval.is_some(), + self.turn_failed, + ) + } + /// Start a prompt turn and return its monotonically increasing token. pub fn begin_turn(&mut self) -> (u64, ProjectionUpdate) { self.next_turn += 1; let turn = self.next_turn; self.active_turn = Some(turn); + self.turn_failed = false; self.state.turn.responding = true; self.state.turn.thinking = true; self.turn_tool_calls.clear(); @@ -141,6 +153,7 @@ impl Projection { return None; } self.active_turn = None; + self.turn_failed = lifecycle == TurnLifecycle::Failure; self.state.turn.responding = false; self.state.turn.thinking = false; self.turn_tool_calls.clear(); diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index 087878b..bdcba65 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -535,6 +535,8 @@ impl SessionManager { return Ok(false); }; let _ = stream.projected_live.send(projected); + drop(stream); + sync_structured_activity(&self.inner, id).await; Ok(true) } @@ -558,6 +560,8 @@ impl SessionManager { } let (turn, update) = stream.projection.begin_turn(); let _ = stream.projected_live.send(update); + drop(stream); + sync_structured_activity(&self.inner, id).await; Ok(BeginAcpTurn::Started(acp, turn)) } @@ -614,6 +618,8 @@ impl SessionManager { }; manager.finish_queued_acp_turn(id, turn, lifecycle).await; }); + drop(stream); + sync_structured_activity(&self.inner, id).await; return Ok(QueueAcpPrompt::Queued); } Err(message) => { @@ -625,6 +631,8 @@ impl SessionManager { "newly claimed turn remains current while stream is locked", ); let _ = stream.projected_live.send(projected); + drop(stream); + sync_structured_activity(&self.inner, id).await; return Ok(QueueAcpPrompt::Failed(message)); } } @@ -669,6 +677,8 @@ impl SessionManager { if let Some(projected) = stream.projection.turn_lifecycle(lifecycle, turn) { let _ = stream.projected_live.send(projected); } + drop(stream); + sync_structured_activity(&self.inner, id).await; } async fn fail_cancelled_acp_turn( @@ -741,6 +751,7 @@ impl SessionManager { .expect("active turn remains current while stream is locked"); let _ = stream.projected_live.send(projected); drop(stream); + sync_structured_activity(&self.inner, id).await; if let Some(turn) = cancelled_queued_turn { self.schedule_cancelled_acp_turn_failure(id, event_stream, turn); } @@ -789,6 +800,8 @@ impl SessionManager { if let Some(update) = stream.projection.resolve_approval(nonce) { let _ = stream.projected_live.send(update); } + drop(stream); + sync_structured_activity(&self.inner, id).await; Ok(Some(())) } @@ -1133,13 +1146,13 @@ impl SessionManager { SessionStatus::Running }, created_at: Utc::now(), - // A freshly-spawned session is producing its initial output; the - // activity watcher refines `foreground`/`activity` on its next tick. + // A terminal spawn begins active until its watcher sees otherwise; + // a structured spawn has no turn yet and begins idle. foreground: None, - activity: if acp_failure.is_some() { - ActivityState::Error - } else { - ActivityState::Active + activity: match (req.view, acp_failure.is_some()) { + (_, true) => ActivityState::Error, + (SessionView::Structured, false) => ActivityState::Idle, + (SessionView::Terminal, false) => ActivityState::Active, }, activity_detail: acp_failure, last_activity_at: Utc::now(), @@ -1201,7 +1214,7 @@ impl SessionManager { } if let Some((acp, events)) = event_pump { let done = start_acp_event_pump(&self.inner, id, &acp, events.clone()); - start_acp_permission_pump(&acp, events); + start_acp_permission_pump(&self.inner, id, &acp, events); if let Some(record) = self.inner.sessions.lock().await.get_mut(&id) { record.acp_event_pump = Some(done); } @@ -1331,6 +1344,7 @@ impl SessionManager { return; }; run_liveness_pass(&inner).await; + run_structured_activity_pass(&inner).await; // Same tick, no new timer (NAP-340): refresh activity for the // survivors and emit SessionUpdated on any transition. run_activity_pass(&inner).await; @@ -1499,11 +1513,15 @@ impl SessionManager { SessionStatus::Dead }; if recoverable { - session.activity = if failure.is_some() { + let activity = if failure.is_some() { ActivityState::Error } else { - ActivityState::Active + ActivityState::Idle }; + if session.activity != activity { + session.activity_since = Utc::now(); + } + session.activity = activity; session.activity_detail = failure; } @@ -1590,7 +1608,7 @@ impl SessionManager { ); if let Some((acp, events)) = event_pump { let done = start_acp_event_pump(&self.inner, id, &acp, events.clone()); - start_acp_permission_pump(&acp, events); + start_acp_permission_pump(&self.inner, id, &acp, events); if let Some(record) = self.inner.sessions.lock().await.get_mut(&id) { record.acp_event_pump = Some(done); } @@ -1750,12 +1768,10 @@ impl SessionManager { Ok(session) } - /// Accept an agent-reported activity state (#105), the only path that can - /// produce `ActivityState::Error`. The report outranks the activity pass's - /// own inference until its TTL lapses — so an agent that dies mid-run - /// cannot pin its session at `Error` forever. An unknown session is - /// [`SessionError::NotFound`]; the reported `detail` is capped - /// server-side. + /// Accept an agent-reported activity state (#105) for a terminal session. + /// Structured sessions derive their state from ACP instead, so their + /// inherited hooks are acknowledged but ignored. An unknown session is + /// [`SessionError::NotFound`]; the reported `detail` is capped server-side. /// /// A report that changes the effective state restarts `activity_since` /// (#104's clock); re-reporting an unchanged state only extends the TTL @@ -1769,6 +1785,9 @@ impl SessionManager { let updated = { let mut sessions = self.inner.sessions.lock().await; let rec = sessions.get_mut(&id).ok_or(SessionError::NotFound(id))?; + if rec.session.view == SessionView::Structured { + return Ok(()); + } let detail = crate::activity::truncate_detail(detail); let changed = rec.session.activity != state || rec.session.activity_detail != detail; if rec.session.activity != state { @@ -1983,10 +2002,51 @@ fn apply_persisted_event(projection: &mut Projection, event: &LoggedEvent) -> Pr } } -fn start_acp_permission_pump(acp: &Arc, events: Arc>) { +async fn sync_structured_activity(inner: &Arc, id: Uuid) { + let updated = { + let mut sessions = inner.sessions.lock().await; + let Some(record) = sessions.get_mut(&id) else { + return; + }; + if record.session.view != SessionView::Structured { + return; + } + let raw = if record.session.status == SessionStatus::Dead { + ActivityState::Error + } else if let Some(events) = &record.acp_events { + events.lock().await.projection.activity() + } else { + ActivityState::Error + }; + let activity = crate::activity::apply_focus_suppression( + raw, + record.focused_since, + std::time::Instant::now(), + &mut record.awaiting_suppressed, + ); + if record.session.activity == activity { + None + } else { + record.session.activity = activity; + record.session.activity_since = Utc::now(); + Some(record.session.clone()) + } + }; + if let Some(session) = updated { + let _ = inner.events.send(CatalogEvent::SessionUpdated { session }); + } +} + +fn start_acp_permission_pump( + inner: &Arc, + id: Uuid, + acp: &Arc, + events: Arc>, +) { let Some(mut requests) = acp.take_permission_requests() else { return; }; + let weak_inner = Arc::downgrade(inner); tokio::spawn(async move { while let Some(PermissionRequest { turn, @@ -2023,12 +2083,20 @@ fn start_acp_permission_pump(acp: &Arc, events: Arc) { } } -/// Refresh each live session's inferred activity (NAP-340) and emit +/// Refresh each live terminal session's inferred activity (NAP-340) and emit /// `SessionUpdated` only when `activity` or `foreground` actually changes — so a /// steady state produces no diffs. Runs on the existing liveness tick. async fn run_activity_pass(inner: &Arc) { @@ -2192,7 +2260,10 @@ async fn run_activity_pass(inner: &Arc) { // kept as-is so the UI can still show what it was doing when it died. sessions .iter() - .filter(|(_, r)| r.session.status == SessionStatus::Running) + .filter(|(_, r)| { + r.session.status == SessionStatus::Running + && r.session.view == SessionView::Terminal + }) .map(|(id, _)| *id) .collect() }; @@ -2290,6 +2361,21 @@ async fn run_activity_pass(inner: &Arc) { } } +/// Reapply focus suppression to structured activity on the liveness cadence. +async fn run_structured_activity_pass(inner: &Arc) { + let ids: Vec = inner + .sessions + .lock() + .await + .iter() + .filter(|(_, record)| record.session.view == SessionView::Structured) + .map(|(id, _)| *id) + .collect(); + for id in ids { + sync_structured_activity(inner, id).await; + } +} + /// Refresh each running session's blocked-work indicator (#83): what its /// worktree holds right now that would block an unforced kill. Rides the /// liveness tick on its own (slower) cadence — see @@ -2391,6 +2477,12 @@ async fn mark_session_dead_with_worktree_settlement( match sessions.get_mut(&id) { Some(record) if record.session.status == SessionStatus::Running => { record.session.status = SessionStatus::Dead; + if record.session.view == SessionView::Structured + && record.session.activity != ActivityState::Error + { + record.session.activity = ActivityState::Error; + record.session.activity_since = Utc::now(); + } // Release anyone attached; the scrollback survives for a later // attach. record.bridge.seal(); diff --git a/crates/maestro-daemon/tests/acp.rs b/crates/maestro-daemon/tests/acp.rs index 7a2f9ed..10f51c5 100644 --- a/crates/maestro-daemon/tests/acp.rs +++ b/crates/maestro-daemon/tests/acp.rs @@ -136,6 +136,7 @@ async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() { assert_eq!(session.view, SessionView::Structured); assert_eq!(session.status, SessionStatus::Running); + assert_eq!(session.activity, ActivityState::Idle); let acp_id = session.acp_session_id.clone().expect("acp session id"); assert!(acp_id.starts_with("fake-"), "{acp_id}"); @@ -204,6 +205,77 @@ async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() { .exists()); } +#[tokio::test] +async fn structured_activity_tracks_turn_completion_and_failure() { + let fx = DaemonFixture::new(); + let fake = Fake::new(); + let cwd = TempDir::new().unwrap(); + let mut env = fake.env(); + env.insert("FAKE_ACP_DELAY_PROMPT_MS".into(), "100".into()); + env.insert("FAKE_ACP_PROMPT_FAIL_ONCE".into(), "1".into()); + let session = fx + .manager + .create(structured(cwd.path(), env)) + .await + .unwrap(); + + assert!(matches!( + fx.manager + .queue_acp_prompt(session.id, "fail".into()) + .await + .unwrap(), + maestro_daemon::session::QueueAcpPrompt::Queued + )); + assert_eq!( + fx.manager + .snapshot() + .await + .into_iter() + .find(|candidate| candidate.id == session.id) + .unwrap() + .activity, + ActivityState::Active + ); + wait_for_async(Duration::from_secs(2), || async { + fx.manager + .snapshot() + .await + .into_iter() + .find(|candidate| candidate.id == session.id) + .is_some_and(|candidate| candidate.activity == ActivityState::Error) + }) + .await; + + assert!(matches!( + fx.manager + .queue_acp_prompt(session.id, "succeed".into()) + .await + .unwrap(), + maestro_daemon::session::QueueAcpPrompt::Queued + )); + assert_eq!( + fx.manager + .snapshot() + .await + .into_iter() + .find(|candidate| candidate.id == session.id) + .unwrap() + .activity, + ActivityState::Active + ); + wait_for_async(Duration::from_secs(2), || async { + fx.manager + .snapshot() + .await + .into_iter() + .find(|candidate| candidate.id == session.id) + .is_some_and(|candidate| candidate.activity == ActivityState::Idle) + }) + .await; + + fx.manager.kill(session.id, false).await.unwrap(); +} + #[tokio::test] async fn projection_snapshot_and_live_stream_share_one_fold() { let fx = DaemonFixture::new(); @@ -476,6 +548,16 @@ async fn recovery_loads_the_acp_session_without_duplicating_history() { revived.status(session.id).await, Some(SessionStatus::Running) ); + assert_eq!( + revived + .snapshot() + .await + .into_iter() + .find(|candidate| candidate.id == session.id) + .unwrap() + .activity, + ActivityState::Idle + ); let log = fake.log(); assert!( log.contains(&format!("recv session/load sessionId={acp_id}")), @@ -925,6 +1007,16 @@ async fn adapter_exit_marks_the_session_dead_and_keeps_it_listed() { fx.manager.status(session.id).await == Some(SessionStatus::Dead) }) .await; + assert_eq!( + fx.manager + .snapshot() + .await + .into_iter() + .find(|candidate| candidate.id == session.id) + .unwrap() + .activity, + ActivityState::Error + ); assert!(fx.manager.has(session.id).await); assert!( !fx.sidecar_dir() diff --git a/crates/maestro-daemon/tests/local_api.rs b/crates/maestro-daemon/tests/local_api.rs index fadfac6..7203cff 100644 --- a/crates/maestro-daemon/tests/local_api.rs +++ b/crates/maestro-daemon/tests/local_api.rs @@ -11,7 +11,8 @@ use common::DaemonFixture; use futures_util::{SinkExt, StreamExt}; use maestro_daemon::local_api; use maestro_protocol::{ - ApiError, CreateSessionRequest, Session, SessionStatus, SessionView, WorktreeSpec, + ActivityState, ApiError, CreateSessionRequest, Session, SessionStatus, SessionView, + WorktreeSpec, }; use tempfile::TempDir; use tokio::net::TcpListener; @@ -167,6 +168,29 @@ async fn structured_session_with_env(h: &Harness, env: HashMap) .id } +async fn wait_for_session_activity(h: &Harness, id: Uuid, expected: ActivityState) { + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let sessions: Vec = reqwest::get(url(h.addr, "/api/sessions")) + .await + .unwrap() + .json() + .await + .unwrap(); + if sessions + .iter() + .find(|session| session.id == id) + .is_some_and(|session| session.activity == expected) + { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap_or_else(|_| panic!("session {id} did not reach {expected:?}")); +} + #[tokio::test] async fn structured_ws_sends_snapshot_then_live_projection() { let h = spawn("naps-laptop").await; @@ -318,6 +342,7 @@ async fn structured_approval_is_secret_single_use_and_race_safe() { }) .await .unwrap(); + wait_for_session_activity(&h, id, ActivityState::AwaitingInput).await; assert!( !fake.log().contains(&nonce), "daemon nonce leaked to ACP agent" @@ -373,6 +398,7 @@ async fn structured_approval_is_secret_single_use_and_race_safe() { }) .await .expect("resolved approval clears the reduced state"); + wait_for_session_activity(&h, id, ActivityState::Idle).await; assert_eq!( client .post(url( @@ -429,6 +455,7 @@ async fn structured_prompt_failure_clears_the_turn() { }) .await .expect("failed turn clears promptly"); + wait_for_session_activity(&h, id, ActivityState::Error).await; let second = client .post(url(h.addr, &path)) .json(&serde_json::json!({"prompt":"available"})) @@ -1350,6 +1377,73 @@ async fn events_ws_sends_a_snapshot_then_streams_deltas() { } } +#[tokio::test] +async fn events_ws_streams_structured_activity_without_hook_overrides() { + let h = spawn("konishi").await; + let fake = FakeAcp::new(); + let mut env = fake.env(); + env.insert("FAKE_ACP_DELAY_PROMPT_MS".into(), "500".into()); + let (mut ws, _) = connect_async(ws_url(h.addr, "/api/events")) + .await + .expect("events ws"); + assert!(matches!( + next_event(&mut ws).await, + maestro_protocol::CatalogEvent::Snapshot { .. } + )); + + let id = structured_session_with_env(&h, env).await; + let maestro_protocol::CatalogEvent::SessionAdded { session } = next_event(&mut ws).await else { + panic!("expected structured SessionAdded") + }; + assert_eq!(session.activity, ActivityState::Idle); + + let client = reqwest::Client::new(); + let prompt = client + .post(url( + h.addr, + &format!("/api/sessions/{id}/structured/prompt"), + )) + .json(&serde_json::json!({"prompt": "hello"})) + .send() + .await + .unwrap(); + assert_eq!(prompt.status(), reqwest::StatusCode::NO_CONTENT); + let report = client + .post(url(h.addr, &format!("/api/sessions/{id}/activity"))) + .json(&serde_json::json!({"state": "Error"})) + .send() + .await + .unwrap(); + assert_eq!(report.status(), reqwest::StatusCode::NO_CONTENT); + wait_for_session_activity(&h, id, ActivityState::Active).await; + + let mut activities = Vec::new(); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + if let maestro_protocol::CatalogEvent::SessionUpdated { session } = + next_event(&mut ws).await + { + activities.push(session.activity); + if session.activity == ActivityState::Idle { + return; + } + } + } + }) + .await + .expect("structured turn returns to idle"); + assert!( + activities.contains(&ActivityState::Active), + "{activities:?}" + ); + assert!( + !activities.contains(&ActivityState::Error), + "{activities:?}" + ); + + h.manager.kill(id, false).await.unwrap(); +} + /// Each subscriber has its own lag domain now that the publisher task is gone: /// two clients connected at different times both get their own Snapshot. #[tokio::test] diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index 3151597..39536c6 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -156,9 +156,9 @@ pub enum SessionStatus { Dead, } -/// Inferred session activity, orthogonal to [`SessionStatus`] (NAP-340). The -/// daemon derives this from tmux pane state, PTY quiescence, and `/proc` -/// inspection — never by parsing the terminal byte stream. +/// Session activity, orthogonal to [`SessionStatus`] (NAP-340). Terminal +/// sessions use pane/process inference and agent reports; structured sessions +/// use their ACP turn lifecycle (docs/SIDEBAR.md). /// /// Deserializes leniently (#110, SIDEBAR.md Precedence): an unknown variant /// from a newer daemon becomes [`ActivityState::Idle`] instead of failing the @@ -166,15 +166,14 @@ pub enum SessionStatus { /// forgiven. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)] pub enum ActivityState { - /// Producing output, or the foreground process is busy. + /// Producing output, the foreground process is busy, or an ACP turn is active. Active, /// Foreground process alive but quiescent. The forward-compatible default. #[default] Idle, - /// Blocked on a `read()` from the pty — waiting on the user. + /// Blocked on terminal input or waiting for an ACP approval. AwaitingInput, - /// The agent running in the session reported a failure (#102). Never - /// inferred by the daemon; only set when an agent reports it (#105). + /// An agent reported a failure, or a structured session/turn failed. Error, } diff --git a/docs/SIDEBAR.md b/docs/SIDEBAR.md index 4c34bbf..4ccc9eb 100644 --- a/docs/SIDEBAR.md +++ b/docs/SIDEBAR.md @@ -231,7 +231,24 @@ cutover points land within one tick. ## Precedence -An agent-reported `ActivityState` (#105) overrides the daemon's inference for +### Activity source by session kind + +The daemon supplies one `activity` field to both clients, but its source depends +on the session kind: + +| Session kind | Source | +|---|---| +| Terminal | The existing agent-report override and five-minute TTL, falling back to PTY quiescence, foreground process and `/proc` input-block inference. | +| Structured | ACP state directly: a pending approval is `AwaitingInput`; otherwise an in-flight turn is `Active`; no turn is `Idle`. A failed turn or dead structured session is `Error`, and the next turn clears a failed-turn error. | + +Structured sessions never enter the terminal inference path and ignore activity +reports inherited through agent hooks. They have no PTY whose output or blocked +read could describe their state. This is a second source, not a replacement for +the terminal source. + +### Terminal report override + +For terminal sessions, an agent-reported `ActivityState` (#105) overrides the daemon's inference for **five minutes** (the TTL is fixed by the daemon; re-reporting extends it). When the TTL expires, inference resumes and the session's reported detail — if any — clears with the report. Clients never blend the two: whichever source @@ -281,10 +298,10 @@ session is genuinely `Active` again — new work, not merely a refocus. Only after that fresh `Active` cycle does the *next* `AwaitingInput` glow again. Unfocusing and refocusing alone never re-arms it. -This applies uniformly to `AwaitingInput` from either source — the daemon's -own inference or an agent's report (#105) — since the rail draws them -identically. It does not touch `Error`; that state's own TTL (below) is -unrelated and unaffected by focus. +This applies uniformly to `AwaitingInput` from terminal inference, an agent +report (#105), or a structured session's pending approval, since the rail draws +them identically. It does not touch `Error`; terminal reports keep their TTL, +while a structured failed-turn error clears when the next turn starts. Each client reports its own pane's focus with `POST /api/sessions/:id/focus` (`{"focused": bool}`) whenever the pane it is showing changes — on attach, on diff --git a/docs/STRUCTURED-VIEW.md b/docs/STRUCTURED-VIEW.md index 1885d0e..e207079 100644 --- a/docs/STRUCTURED-VIEW.md +++ b/docs/STRUCTURED-VIEW.md @@ -343,7 +343,7 @@ never reached disk), and only then reopens the log for further appends. What the adapter streams back while its own `session/load` is in flight is discarded rather than appended, so the replayed history is not duplicated (see [The ACP handshake](#the-acp-handshake)). Success flips the session back -to `Running`/`Active`; failure (no stored `acp_session_id`, or the handshake +to `Running`/`Idle`; failure (no stored `acp_session_id`, or the handshake itself failing) leaves it `Dead` with the failure reason as `activity_detail` — the same "tell the operator what to fix" pattern as a missing adapter binary.