diff --git a/crates/maestro-cli/src/sessions.rs b/crates/maestro-cli/src/sessions.rs index ed51946..12fa99b 100644 --- a/crates/maestro-cli/src/sessions.rs +++ b/crates/maestro-cli/src/sessions.rs @@ -176,6 +176,7 @@ mod tests { 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, diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index 4070b24..82c1a94 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -379,6 +379,7 @@ impl SessionManager { // activity watcher refines `foreground`/`activity` on its next tick. foreground: None, activity: ActivityState::Active, + activity_detail: None, last_activity_at: Utc::now(), activity_since: chrono::Utc::now(), @@ -602,6 +603,7 @@ impl SessionManager { // recovered session had already been sitting idle. foreground: None, activity: ActivityState::Idle, + activity_detail: None, last_activity_at: Utc::now(), activity_since: chrono::Utc::now(), diff --git a/crates/maestro-daemon/tests/sidecar.rs b/crates/maestro-daemon/tests/sidecar.rs index 2d88ff4..0274844 100644 --- a/crates/maestro-daemon/tests/sidecar.rs +++ b/crates/maestro-daemon/tests/sidecar.rs @@ -26,6 +26,7 @@ fn sample(id: Uuid) -> Session { .with_timezone(&Utc), foreground: None, activity: Default::default(), + activity_detail: None, last_activity_at: chrono::Utc::now(), activity_since: chrono::Utc::now(), current_cwd: Some(PathBuf::from("/tmp/work/crates")), diff --git a/crates/maestro-protocol/src/client.rs b/crates/maestro-protocol/src/client.rs index 61bcffd..f7d2dab 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, - KillOutcome, Project, RecentDirs, RetainedWorktree, Session, + KillOutcome, Project, RecentDirs, ReportActivityRequest, RetainedWorktree, Session, }; /// Time budget for establishing a TCP connection to the daemon. @@ -134,6 +134,21 @@ impl DaemonClient { json(resp).await } + /// `POST /api/sessions/:id/activity` (#105): report an agent-observed + /// state. The daemon holds it above its own inference for a TTL, then + /// resumes inference. Answers `204 No Content`. + pub async fn report_activity( + &self, + id: Uuid, + req: &ReportActivityRequest, + ) -> Result<(), ClientError> { + let resp = self + .send(Method::POST, &routes::session_activity(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/lib.rs b/crates/maestro-protocol/src/lib.rs index e960175..e1e7973 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -75,6 +75,12 @@ pub struct Session { /// byte stream. #[serde(default)] pub activity: ActivityState, + /// Short human-readable string an agent reported alongside its activity + /// (#105) — e.g. why it is in `Error`. Shown on a sidebar card's meta line + /// while the report is in force; cleared when the report lapses or is + /// replaced. `None` unless an agent reported one. + #[serde(default)] + pub activity_detail: Option, /// Wall-clock time of the session's most recent observed PTY output. /// Drives "idle for Ns" displays. Defaults to the Unix epoch on payloads /// from daemons that predate the field. @@ -131,6 +137,18 @@ pub enum ActivityState { Error, } +/// HTTP `POST /api/sessions/:id/activity` body (#105): an agent's report of +/// its own state. The daemon holds the reported state above its own inference +/// for a fixed TTL, then resumes inference. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReportActivityRequest { + pub state: ActivityState, + /// Short human string shown next to the state (e.g. an error message). + /// The daemon caps its length server-side. + #[serde(default)] + pub detail: Option, +} + /// Optional worktree request attached to a `CreateSessionRequest`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct WorktreeSpec { @@ -544,6 +562,7 @@ mod tests { .with_timezone(&Utc), foreground: Some("claude".into()), activity: ActivityState::Active, + activity_detail: None, last_activity_at: DateTime::parse_from_rfc3339("2026-05-24T12:05:00Z") .unwrap() .with_timezone(&Utc), @@ -639,6 +658,49 @@ mod tests { assert_eq!(ActivityState::default(), ActivityState::Idle); } + #[test] + fn activity_detail_defaults_to_none_on_legacy_payload() { + // #105: a daemon predating the detail field omits it; it must + // deserialize to `None` rather than failing, and a present value + // round-trips. + let json = serde_json::json!({ + "id": Uuid::from_u128(1), + "cwd": "/tmp", + "command": "bash", + "status": "Running", + "created_at": "2026-05-24T12:00:00Z" + }); + let s: Session = serde_json::from_value(json).unwrap(); + assert_eq!(s.activity_detail, None); + + let mut reported = sample_session(); + reported.activity = ActivityState::Error; + reported.activity_detail = Some("build failed".into()); + assert_eq!(reported.clone(), round_trip(&reported)); + } + + #[test] + fn report_activity_request_defaults_detail_and_rejects_unknown_state() { + let bare: ReportActivityRequest = + serde_json::from_value(serde_json::json!({ "state": "Error" })).unwrap(); + assert_eq!(bare.state, ActivityState::Error); + assert_eq!(bare.detail, None); + + let full = ReportActivityRequest { + state: ActivityState::AwaitingInput, + detail: Some("waiting on permission".into()), + }; + assert_eq!(full, round_trip(&full)); + + // Unknown state values are rejected rather than coerced (#105). + assert!( + serde_json::from_value::(serde_json::json!({ + "state": "Teleported" + })) + .is_err() + ); + } + #[test] fn session_round_trips() { let s = sample_session(); diff --git a/crates/maestro-protocol/src/routes.rs b/crates/maestro-protocol/src/routes.rs index 7b3e024..29671b5 100644 --- a/crates/maestro-protocol/src/routes.rs +++ b/crates/maestro-protocol/src/routes.rs @@ -47,6 +47,11 @@ pub fn session(id: Uuid) -> String { format!("{SESSIONS}/{id}") } +/// `POST /api/sessions/:id/activity` (#105): an agent reports its own state. +pub fn session_activity(id: Uuid) -> String { + format!("{SESSIONS}/{id}/activity") +} + /// `DELETE /api/worktrees/:id` — reclaim one retained worktree (#80). pub fn worktree(id: Uuid) -> String { format!("{WORKTREES}/{id}") @@ -61,6 +66,7 @@ pub fn attach(id: Uuid) -> String { /// registers the same paths [`session`] and [`attach`] build. pub mod patterns { pub const SESSION: &str = "/api/sessions/:id"; + pub const SESSION_ACTIVITY: &str = "/api/sessions/:id/activity"; pub const ATTACH: &str = "/api/attach/:id"; pub const WORKTREE: &str = "/api/worktrees/:id"; } @@ -169,6 +175,10 @@ mod tests { session(id()), "/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50" ); + assert_eq!( + session_activity(id()), + "/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/activity" + ); assert_eq!( attach(id()), "/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50" @@ -191,6 +201,10 @@ mod tests { patterns::SESSION.trim_end_matches(":id"), session(id()).trim_end_matches(&id().to_string()) ); + assert_eq!( + patterns::SESSION_ACTIVITY.trim_end_matches(":id/activity"), + 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 d5a6d47..8defccf 100644 --- a/crates/maestro-testing/src/lib.rs +++ b/crates/maestro-testing/src/lib.rs @@ -41,6 +41,7 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session { created_at: Utc::now(), foreground: None, activity: Default::default(), + activity_detail: None, last_activity_at: Utc::now(), activity_since: chrono::Utc::now(), current_cwd: None, diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 3034aa4..245a628 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -1126,6 +1126,7 @@ mod tests { created_at: Utc::now(), foreground: None, activity: Default::default(), + activity_detail: None, last_activity_at: Utc::now(), activity_since: chrono::Utc::now(), current_cwd: None, @@ -1644,6 +1645,7 @@ mod tests { created_at: Utc::now(), foreground: None, activity: Default::default(), + activity_detail: None, last_activity_at: chrono::Utc::now(), activity_since: chrono::Utc::now(), diff --git a/crates/maestro-tui/src/catalog.rs b/crates/maestro-tui/src/catalog.rs index 178a559..e3466f0 100644 --- a/crates/maestro-tui/src/catalog.rs +++ b/crates/maestro-tui/src/catalog.rs @@ -193,6 +193,7 @@ mod tests { created_at: Utc::now(), foreground: None, activity: Default::default(), + activity_detail: None, last_activity_at: chrono::Utc::now(), activity_since: chrono::Utc::now(), current_cwd: None, diff --git a/crates/maestro-tui/src/sidebar.rs b/crates/maestro-tui/src/sidebar.rs index 4a02ca2..2737d12 100644 --- a/crates/maestro-tui/src/sidebar.rs +++ b/crates/maestro-tui/src/sidebar.rs @@ -271,6 +271,7 @@ mod tests { created_at: Utc::now(), foreground: None, activity: Default::default(), + activity_detail: None, last_activity_at: Utc::now(), activity_since: Utc::now(), current_cwd: None, @@ -579,6 +580,7 @@ mod tests { created_at: Utc::now(), foreground: None, activity: Default::default(), + activity_detail: None, last_activity_at: Utc::now(), activity_since: Utc::now(), current_cwd: None,