From c0da16658aef61cff0a7171e9b539e49adac96df Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Fri, 4 Sep 2026 20:06:12 +0100 Subject: [PATCH] feat(maestro): add acp_session_id to the session sidecar (#134) A Structured session's adapter hands back an ACP session id from session/new; a respawn will pass it to session/load (#135). This is a new field rather than #161's agent_session_id: that one is the Claude Code conversation id the activity hook reports and `claude --resume` takes, minted by Claude Code itself. The ACP id is minted by the adapter, only exists for Structured sessions, and is what the adapter takes back. Overloading one field with two ids from two processes would leave a respawn unable to tell which kind it holds. --- crates/maestro-cli/src/sessions.rs | 1 + crates/maestro-daemon/src/session.rs | 2 ++ crates/maestro-daemon/tests/sidecar.rs | 1 + crates/maestro-protocol/src/display.rs | 1 + crates/maestro-protocol/src/lib.rs | 33 ++++++++++++++++++++++++++ crates/maestro-testing/src/lib.rs | 1 + crates/maestro-tui/src/app.rs | 6 +++++ crates/maestro-tui/src/catalog.rs | 1 + crates/maestro-tui/src/command_bar.rs | 1 + crates/maestro-tui/src/sidebar.rs | 2 ++ web/src/types.ts | 4 ++++ 11 files changed, 53 insertions(+) diff --git a/crates/maestro-cli/src/sessions.rs b/crates/maestro-cli/src/sessions.rs index b5a8652..21296a5 100644 --- a/crates/maestro-cli/src/sessions.rs +++ b/crates/maestro-cli/src/sessions.rs @@ -254,6 +254,7 @@ mod tests { current_cwd: None, blocked_work: None, last_focused_at: None, + acp_session_id: None, } } diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index 7d17d38..e8f6a4e 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -478,6 +478,7 @@ impl SessionManager { // fresh tree has nothing in the way anyway. blocked_work: None, last_focused_at: None, + acp_session_id: None, view: req.view, }; @@ -684,6 +685,7 @@ impl SessionManager { current_cwd: None, blocked_work: None, last_focused_at: None, + acp_session_id: None, // Unknown on adoption without a sidecar; matches every // other field's forward-compat default. view: maestro_protocol::SessionView::Terminal, diff --git a/crates/maestro-daemon/tests/sidecar.rs b/crates/maestro-daemon/tests/sidecar.rs index 4aa7ff2..f942e2d 100644 --- a/crates/maestro-daemon/tests/sidecar.rs +++ b/crates/maestro-daemon/tests/sidecar.rs @@ -32,6 +32,7 @@ fn sample(id: Uuid) -> Session { current_cwd: Some(PathBuf::from("/tmp/work/crates")), blocked_work: None, last_focused_at: None, + acp_session_id: None, view: SessionView::Terminal, } } diff --git a/crates/maestro-protocol/src/display.rs b/crates/maestro-protocol/src/display.rs index f7d38b8..d535c69 100644 --- a/crates/maestro-protocol/src/display.rs +++ b/crates/maestro-protocol/src/display.rs @@ -285,6 +285,7 @@ mod tests { current_cwd: None, blocked_work: None, last_focused_at: None, + acp_session_id: None, } } diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index ead867b..ad38b97 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -123,6 +123,15 @@ pub struct Session { /// daemon that predates the field keeps working untouched. #[serde(default)] pub view: SessionView, + /// The ACP session id the `claude-code-acp` adapter handed back from + /// `session/new` (#134). Only a [`SessionView::Structured`] session has + /// one, and only once its handshake succeeded; a later respawn passes it + /// to `session/load` so the conversation continues (#135). Distinct from + /// the hook-reported Claude Code conversation id (#161): that one is what + /// `claude --resume` takes, this one is what the ACP adapter takes, and + /// the two are minted by different processes with different lifetimes. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub acp_session_id: Option, } /// A session's process shape (#133): a plain terminal, or a structured agent @@ -886,6 +895,7 @@ mod tests { blocked_work: None, last_focused_at: None, view: SessionView::Terminal, + acp_session_id: None, } } @@ -1134,6 +1144,29 @@ mod tests { assert_eq!(structured, round_trip(&structured)); } + #[test] + fn acp_session_id_defaults_to_none_and_round_trips() { + // A payload from before #134 carries no `acp_session_id`; it must + // read as `None`, stay off the wire while unset, and round-trip + // once the adapter has handed one back. + 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.acp_session_id, None); + let serialized = serde_json::to_value(&s).unwrap(); + assert!(serialized.get("acp_session_id").is_none()); + + let mut structured = sample_session(); + structured.view = SessionView::Structured; + structured.acp_session_id = Some("sess_01HZX".into()); + assert_eq!(structured, round_trip(&structured)); + } + /// A client that predates the new/existing discriminator sends no /// `create_branch`; the daemon must read that as "create a new branch" — /// exactly what every payload meant before the field existed (#56). diff --git a/crates/maestro-testing/src/lib.rs b/crates/maestro-testing/src/lib.rs index 35c233d..30c8535 100644 --- a/crates/maestro-testing/src/lib.rs +++ b/crates/maestro-testing/src/lib.rs @@ -48,6 +48,7 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session { current_cwd: None, blocked_work: None, last_focused_at: None, + acp_session_id: None, } } diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index b52d4fd..d62a605 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -1899,6 +1899,7 @@ mod tests { current_cwd: None, blocked_work: None, last_focused_at: None, + acp_session_id: None, }) .collect(), }); @@ -2081,6 +2082,7 @@ mod tests { current_cwd: None, blocked_work: None, last_focused_at: None, + acp_session_id: None, }], }); app.dispatch(Action::SidebarNext); @@ -2109,6 +2111,7 @@ mod tests { current_cwd: None, blocked_work: None, last_focused_at: None, + acp_session_id: None, } } @@ -3219,6 +3222,7 @@ mod tests { current_cwd: None, blocked_work: None, last_focused_at: focused, + acp_session_id: None, }; let older = Utc::now() - chrono::Duration::seconds(60); app.catalog.apply_event(CatalogEvent::Snapshot { @@ -3498,6 +3502,7 @@ mod tests { current_cwd: None, blocked_work: None, last_focused_at: None, + acp_session_id: None, }], }); app.dispatch(Action::KillHighlighted); @@ -3534,6 +3539,7 @@ mod tests { current_cwd: None, blocked_work: None, last_focused_at: None, + acp_session_id: None, }], }); } diff --git a/crates/maestro-tui/src/catalog.rs b/crates/maestro-tui/src/catalog.rs index 9a78c84..4a42e4f 100644 --- a/crates/maestro-tui/src/catalog.rs +++ b/crates/maestro-tui/src/catalog.rs @@ -224,6 +224,7 @@ mod tests { current_cwd: None, blocked_work: None, last_focused_at: None, + acp_session_id: None, } } diff --git a/crates/maestro-tui/src/command_bar.rs b/crates/maestro-tui/src/command_bar.rs index 883e3d1..095dee7 100644 --- a/crates/maestro-tui/src/command_bar.rs +++ b/crates/maestro-tui/src/command_bar.rs @@ -810,6 +810,7 @@ mod tests { current_cwd: None, blocked_work: None, last_focused_at: None, + acp_session_id: None, } } diff --git a/crates/maestro-tui/src/sidebar.rs b/crates/maestro-tui/src/sidebar.rs index c22d3aa..c91e94f 100644 --- a/crates/maestro-tui/src/sidebar.rs +++ b/crates/maestro-tui/src/sidebar.rs @@ -501,6 +501,7 @@ mod tests { current_cwd: None, blocked_work: None, last_focused_at: None, + acp_session_id: None, } } @@ -1268,6 +1269,7 @@ mod tests { current_cwd: None, blocked_work: None, last_focused_at: None, + acp_session_id: None, }; let sb = Sidebar { sessions: vec![s], diff --git a/web/src/types.ts b/web/src/types.ts index 091883e..3870c7d 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -56,6 +56,10 @@ export interface Session { * how the command bar's empty query sorts a never-focused session after every * session that has one (`docs/COMMAND-BAR.md`). */ last_focused_at?: IsoTimestamp | null; + /** The ACP session id the `claude-code-acp` adapter returned from + * `session/new` (#134). Only a `Structured` session whose handshake + * succeeded has one; a respawn passes it to `session/load` (#135). */ + acp_session_id?: string | null; } /** A session's daemon-created worktree (`SessionWorktree` on the wire). */