diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index 7e83d5f..9748e62 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -536,6 +536,29 @@ pub struct SpawnPreset { pub resume_command: Option, } +/// Placeholder a preset's `resume_command` may carry for the agent's own +/// conversation id (#165), substituted from [`Session::agent_session_id`]. +pub const AGENT_SESSION_PLACEHOLDER: &str = "{agent_session}"; + +impl SpawnPreset { + /// The command that resumes a session spawned from this preset, with + /// [`AGENT_SESSION_PLACEHOLDER`] filled in from `agent_session`. + /// + /// `None` when the preset has no `resume_command` — a plain shell has + /// nothing to resume — and also when the template asks for an agent + /// session id the session never reported: `claude --resume` with an empty + /// id resumes nothing, so the caller is better off respawning the original + /// command than running a broken one. + pub fn resume_command_for(&self, agent_session: Option<&str>) -> Option { + let template = self.resume_command.as_deref()?; + if !template.contains(AGENT_SESSION_PLACEHOLDER) { + return Some(template.to_string()); + } + let id = agent_session.filter(|s| !s.is_empty())?; + Some(template.replace(AGENT_SESSION_PLACEHOLDER, id)) + } +} + /// Build provenance of the running daemon: the commit it was compiled /// from and when that commit was authored. /// @@ -785,6 +808,47 @@ pub enum ClientControl { mod tests { use super::*; + fn preset(resume: Option<&str>) -> SpawnPreset { + SpawnPreset { + id: "claude".into(), + label: "Claude".into(), + command: "claude".into(), + resume_command: resume.map(str::to_string), + } + } + + #[test] + fn resume_command_substitutes_the_agent_session() { + let p = preset(Some("claude --resume {agent_session}")); + assert_eq!( + p.resume_command_for(Some("abc-123")).as_deref(), + Some("claude --resume abc-123") + ); + } + + #[test] + fn resume_command_without_a_placeholder_ignores_the_agent_session() { + let p = preset(Some("opencode --continue")); + assert_eq!( + p.resume_command_for(None).as_deref(), + Some("opencode --continue") + ); + } + + /// A template that wants an id the session never reported would run + /// `claude --resume` against nothing; the caller respawns instead. + #[test] + fn resume_command_is_none_without_the_id_it_asks_for() { + let p = preset(Some("claude --resume {agent_session}")); + assert!(p.resume_command_for(None).is_none()); + assert!(p.resume_command_for(Some("")).is_none()); + } + + #[test] + fn a_preset_without_a_resume_command_has_none() { + assert!(preset(None).resume_command_for(Some("abc")).is_none()); + } + #[test] fn version_compatibility_is_major_only() { assert!(is_compatible(PROTOCOL_VERSION)); diff --git a/crates/maestro-protocol/src/routes.rs b/crates/maestro-protocol/src/routes.rs index 1594df8..0a4baf4 100644 --- a/crates/maestro-protocol/src/routes.rs +++ b/crates/maestro-protocol/src/routes.rs @@ -66,6 +66,12 @@ pub fn session_focus(id: Uuid) -> String { format!("{SESSIONS}/{id}/focus") } +/// `POST /api/sessions/:id/resume` (#166): respawn a dormant session under its +/// own id, keeping its sidecar, name, worktree and `created_at`. +pub fn session_resume(id: Uuid) -> String { + format!("{SESSIONS}/{id}/resume") +} + /// `WS /api/attach/:id` — raw PTY bytes. pub fn attach(id: Uuid) -> String { format!("{API_PREFIX}/attach/{id}") @@ -79,6 +85,7 @@ pub mod patterns { pub const SESSION_INPUT: &str = "/api/sessions/:id/input"; pub const SESSION_PROMPT: &str = "/api/sessions/:id/prompt"; pub const SESSION_FOCUS: &str = "/api/sessions/:id/focus"; + pub const SESSION_RESUME: &str = "/api/sessions/:id/resume"; pub const ATTACH: &str = "/api/attach/:id"; } @@ -202,6 +209,10 @@ mod tests { session_focus(id()), "/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/focus" ); + assert_eq!( + session_resume(id()), + "/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/resume" + ); assert_eq!( attach(id()), "/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50" @@ -236,6 +247,10 @@ mod tests { patterns::SESSION_FOCUS.trim_end_matches(":id/focus"), session(id()).trim_end_matches(&id().to_string()) ); + assert_eq!( + patterns::SESSION_RESUME.trim_end_matches(":id/resume"), + session(id()).trim_end_matches(&id().to_string()) + ); assert_eq!( patterns::ATTACH.trim_end_matches(":id"), attach(id()).trim_end_matches(&id().to_string())