diff --git a/docs/next/api/herdr-api.schema.json b/docs/next/api/herdr-api.schema.json index bdf0caab4c0..25162a281c3 100644 --- a/docs/next/api/herdr-api.schema.json +++ b/docs/next/api/herdr-api.schema.json @@ -2438,6 +2438,25 @@ ], "type": "string" }, + "PaneBindRuntimeParams": { + "properties": { + "attachment": { + "$ref": "#/schemas/request/$defs/RuntimeAttachment" + }, + "binding_id": { + "type": "string" + }, + "pane_id": { + "type": "string" + } + }, + "required": [ + "pane_id", + "binding_id", + "attachment" + ], + "type": "object" + }, "PaneClearAgentAuthorityParams": { "properties": { "pane_id": { @@ -3169,6 +3188,47 @@ ], "type": "object" }, + "PaneReportRuntimeParams": { + "properties": { + "binding_id": { + "type": "string" + }, + "label": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "pane_id": { + "type": "string" + }, + "seq": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "state": { + "$ref": "#/schemas/request/$defs/RuntimeState" + }, + "ttl_ms": { + "default": null, + "format": "uint64", + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "pane_id", + "binding_id", + "seq", + "state" + ], + "type": "object" + }, "PaneResizeParams": { "properties": { "amount": { @@ -3426,6 +3486,21 @@ ], "type": "object" }, + "PaneUnbindRuntimeParams": { + "properties": { + "binding_id": { + "type": "string" + }, + "pane_id": { + "type": "string" + } + }, + "required": [ + "pane_id", + "binding_id" + ], + "type": "object" + }, "PaneWaitForOutputParams": { "properties": { "lines": { @@ -3941,6 +4016,51 @@ ], "type": "object" }, + "RuntimeAttachment": { + "additionalProperties": false, + "properties": { + "attach_argv": { + "items": { + "type": "string" + }, + "type": "array" + }, + "home_id": { + "type": "string" + }, + "host_id": { + "type": "string" + }, + "location": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "session_id": { + "type": "string" + } + }, + "required": [ + "provider", + "home_id", + "session_id", + "location", + "host_id", + "attach_argv" + ], + "type": "object" + }, + "RuntimeState": { + "enum": [ + "idle", + "working", + "blocked", + "done", + "unknown" + ], + "type": "string" + }, "ServerLiveHandoffParams": { "properties": { "expected_protocol": { @@ -6062,6 +6182,70 @@ ], "type": "object" }, + { + "properties": { + "method": { + "const": "pane.bind_runtime", + "type": "string" + }, + "params": { + "$ref": "#/schemas/request/$defs/PaneBindRuntimeParams" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + { + "properties": { + "method": { + "const": "pane.get_runtime", + "type": "string" + }, + "params": { + "$ref": "#/schemas/request/$defs/PaneTarget" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + { + "properties": { + "method": { + "const": "pane.report_runtime", + "type": "string" + }, + "params": { + "$ref": "#/schemas/request/$defs/PaneReportRuntimeParams" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + { + "properties": { + "method": { + "const": "pane.unbind_runtime", + "type": "string" + }, + "params": { + "$ref": "#/schemas/request/$defs/PaneUnbindRuntimeParams" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, { "properties": { "method": { @@ -9319,6 +9503,12 @@ "minimum": 0, "type": "integer" }, + "session_name": { + "type": [ + "string", + "null" + ] + }, "type": { "const": "pong", "type": "string" @@ -9673,6 +9863,34 @@ ], "type": "object" }, + { + "properties": { + "applied": { + "type": [ + "boolean", + "null" + ] + }, + "binding": { + "anyOf": [ + { + "$ref": "#/schemas/success_response/$defs/RuntimeBinding" + }, + { + "type": "null" + } + ] + }, + "type": { + "const": "pane_runtime", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, { "properties": { "pane": { @@ -10654,6 +10872,98 @@ } ] }, + "RuntimeAttachment": { + "additionalProperties": false, + "properties": { + "attach_argv": { + "items": { + "type": "string" + }, + "type": "array" + }, + "home_id": { + "type": "string" + }, + "host_id": { + "type": "string" + }, + "location": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "session_id": { + "type": "string" + } + }, + "required": [ + "provider", + "home_id", + "session_id", + "location", + "host_id", + "attach_argv" + ], + "type": "object" + }, + "RuntimeBinding": { + "properties": { + "attachment": { + "$ref": "#/schemas/success_response/$defs/RuntimeAttachment" + }, + "binding_id": { + "type": "string" + }, + "fresh": { + "type": "boolean" + }, + "label": { + "type": [ + "string", + "null" + ] + }, + "pane_id": { + "type": "string" + }, + "seq": { + "format": "uint64", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "state": { + "$ref": "#/schemas/success_response/$defs/RuntimeState" + }, + "ttl_ms": { + "format": "uint64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "pane_id", + "binding_id", + "attachment", + "state", + "ttl_ms", + "fresh" + ], + "type": "object" + }, + "RuntimeState": { + "enum": [ + "idle", + "working", + "blocked", + "done", + "unknown" + ], + "type": "string" + }, "ServerCapabilities": { "properties": { "detached_server_daemon": { @@ -10682,6 +10992,12 @@ "live_handoff": { "type": "boolean" }, + "runtime_attachment_methods": { + "items": { + "type": "string" + }, + "type": "array" + }, "ssh_agent_registration": { "default": false, "description": "Supports connection-scoped `server.ssh_agent.register` on the local JSON API.", diff --git a/src/api/client.rs b/src/api/client.rs index 15d3a8844f2..d80be7bf674 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -111,6 +111,7 @@ impl ApiClient { version, protocol, capabilities, + .. } => Ok(crate::api::RuntimeStatus { version: Some(version), protocol: Some(protocol), diff --git a/src/api/mod.rs b/src/api/mod.rs index bd2983e4228..5f25ebf665a 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -71,6 +71,9 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool { | Method::PaneGraphicsStreamDirect(_) | Method::PaneGraphicsStreamOpen(_) | Method::PaneGraphicsStreamClose(_) + | Method::PaneBindRuntime(_) + | Method::PaneReportRuntime(_) + | Method::PaneUnbindRuntime(_) | Method::PaneReportAgent(_) | Method::PaneReportAgentSession(_) | Method::PaneReportMetadata(_) diff --git a/src/api/schema.rs b/src/api/schema.rs index d7e1a950853..389420bba29 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -8,6 +8,7 @@ pub mod integrations; pub mod panes; pub mod plugins; pub mod response; +pub mod runtime; pub mod server; pub mod session; pub mod tabs; @@ -22,6 +23,7 @@ pub use integrations::*; pub use panes::*; pub use plugins::*; pub use response::*; +pub use runtime::*; pub use server::*; pub use session::*; pub use tabs::*; @@ -222,6 +224,14 @@ pub enum Method { #[serde(skip)] #[schemars(skip)] PaneGraphicsStreamClose(PaneGraphicsStreamParams), + #[serde(rename = "pane.bind_runtime")] + PaneBindRuntime(PaneBindRuntimeParams), + #[serde(rename = "pane.get_runtime")] + PaneGetRuntime(PaneTarget), + #[serde(rename = "pane.report_runtime")] + PaneReportRuntime(PaneReportRuntimeParams), + #[serde(rename = "pane.unbind_runtime")] + PaneUnbindRuntime(PaneUnbindRuntimeParams), #[serde(rename = "pane.report_agent")] PaneReportAgent(PaneReportAgentParams), #[serde(rename = "pane.report_agent_session")] diff --git a/src/api/schema/response.rs b/src/api/schema/response.rs index 5cc66bdc256..8a3c1a86c8a 100644 --- a/src/api/schema/response.rs +++ b/src/api/schema/response.rs @@ -45,6 +45,8 @@ pub enum ResponseResult { Pong { version: String, protocol: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + session_name: Option, #[serde(default)] capabilities: Option, }, @@ -114,6 +116,11 @@ pub enum ResponseResult { #[serde(default, skip_serializing_if = "Option::is_none")] label: Option, }, + PaneRuntime { + #[serde(default, skip_serializing_if = "Option::is_none")] + applied: Option, + binding: Option, + }, PaneInfo { pane: PaneInfo, }, diff --git a/src/api/schema/runtime.rs b/src/api/schema/runtime.rs new file mode 100644 index 00000000000..686f5408b7f --- /dev/null +++ b/src/api/schema/runtime.rs @@ -0,0 +1,59 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RuntimeAttachment { + pub provider: String, + pub home_id: String, + pub session_id: String, + pub location: String, + pub host_id: String, + pub attach_argv: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeState { + Idle, + Working, + Blocked, + Done, + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct RuntimeBinding { + pub pane_id: String, + pub binding_id: String, + pub attachment: RuntimeAttachment, + pub seq: Option, + pub state: RuntimeState, + pub label: Option, + pub ttl_ms: u64, + pub fresh: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct PaneBindRuntimeParams { + pub pane_id: String, + pub binding_id: String, + pub attachment: RuntimeAttachment, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct PaneReportRuntimeParams { + pub pane_id: String, + pub binding_id: String, + pub seq: u64, + pub state: RuntimeState, + #[serde(default)] + pub label: Option, + #[serde(default)] + pub ttl_ms: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct PaneUnbindRuntimeParams { + pub pane_id: String, + pub binding_id: String, +} diff --git a/src/api/schema/server.rs b/src/api/schema/server.rs index fa9af172e44..793164e83d7 100644 --- a/src/api/schema/server.rs +++ b/src/api/schema/server.rs @@ -21,6 +21,8 @@ pub struct ServerSshAgentRegisterParams { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct ServerCapabilities { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub runtime_attachment_methods: Vec, pub live_handoff: bool, /// Direct controllers yield to explicit shell focus/selection and pane input. #[serde(default)] diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs index a0235a589eb..c7b34cc44c2 100644 --- a/src/api/schema/tests.rs +++ b/src/api/schema/tests.rs @@ -720,7 +720,12 @@ fn success_response_round_trips() { result: ResponseResult::Pong { version: "0.1.2".into(), protocol: 6, + session_name: Some("native-test".into()), capabilities: Some(ServerCapabilities { + runtime_attachment_methods: crate::runtime_attachment::METHODS + .iter() + .map(|method| (*method).into()) + .collect(), live_handoff: true, direct_control_yields_to_shell: true, detached_server_daemon: true, @@ -1460,3 +1465,20 @@ fn pane_link_resolve_round_trips() { result ); } + +#[test] +fn legacy_ping_response_omits_optional_peer_session_identity() { + let response: SuccessResponse = serde_json::from_str( + r#"{"id":"legacy","result":{"type":"pong","version":"0.1.2","protocol":22}}"#, + ) + .unwrap(); + assert!(matches!( + &response.result, + ResponseResult::Pong { + session_name: None, + .. + } + )); + let json = serde_json::to_value(response).unwrap(); + assert!(json["result"].get("session_name").is_none()); +} diff --git a/src/api/server.rs b/src/api/server.rs index 6ff655de836..ebd30c66adc 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -69,6 +69,10 @@ pub(crate) fn start_server_with_stop_control( fn default_capabilities() -> Option { Some(ServerCapabilities { + runtime_attachment_methods: crate::runtime_attachment::METHODS + .iter() + .map(|method| (*method).into()) + .collect(), live_handoff: crate::platform::capabilities().live_handoff, direct_control_yields_to_shell: true, detached_server_daemon: crate::platform::current_process_is_detached_server_daemon(), @@ -445,6 +449,10 @@ fn handle_request( result: ResponseResult::Pong { version: crate::build_info::version(), protocol: crate::protocol::PROTOCOL_VERSION, + session_name: Some( + crate::session::active_name() + .unwrap_or_else(|| crate::session::DEFAULT_SESSION_NAME.into()), + ), capabilities, }, }) @@ -570,6 +578,10 @@ pub(crate) fn api_method_name(method: &Method) -> &'static str { Method::PaneGraphicsStreamDirect(_) => "pane.graphics.stream.direct", Method::PaneGraphicsStreamOpen(_) => "pane.graphics.stream.open", Method::PaneGraphicsStreamClose(_) => "pane.graphics.stream.close", + Method::PaneBindRuntime(_) => "pane.bind_runtime", + Method::PaneGetRuntime(_) => "pane.get_runtime", + Method::PaneReportRuntime(_) => "pane.report_runtime", + Method::PaneUnbindRuntime(_) => "pane.unbind_runtime", Method::PaneReportAgent(_) => "pane.report_agent", Method::PaneReportAgentSession(_) => "pane.report_agent_session", Method::PaneReportMetadata(_) => "pane.report_metadata", @@ -1332,6 +1344,10 @@ mod tests { }, &tx, Some(ServerCapabilities { + runtime_attachment_methods: crate::runtime_attachment::METHODS + .iter() + .map(|method| (*method).into()) + .collect(), live_handoff: true, direct_control_yields_to_shell: true, detached_server_daemon: true, diff --git a/src/api/status.rs b/src/api/status.rs index 209b93df337..3c2201ab917 100644 --- a/src/api/status.rs +++ b/src/api/status.rs @@ -48,6 +48,7 @@ pub fn read_runtime_status_at( version, protocol, capabilities, + .. } => Ok(Some(RuntimeStatus { version: Some(version), protocol: Some(protocol), diff --git a/src/app/actions.rs b/src/app/actions.rs index 7c0f13fcea1..5330408a110 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -1614,7 +1614,11 @@ impl AppState { } } - fn update_terminal_state(&mut self, pane_id: PaneId, update: F) -> Option + pub(crate) fn update_terminal_state( + &mut self, + pane_id: PaneId, + update: F, + ) -> Option where F: FnOnce(&mut crate::terminal::TerminalState) -> Option, { @@ -1761,6 +1765,20 @@ impl AppState { update.agent_released.then_some(update) } + pub(crate) fn publish_runtime_completion(&mut self, ws_idx: usize, pane_id: PaneId) { + let Some(terminal) = self.workspaces[ws_idx] + .pane_state(pane_id) + .and_then(|pane| self.terminals.get(&pane.attached_terminal_id)) + else { + return; + }; + let mut change = terminal.unchanged_effective_state_change_at(Instant::now()); + // Native Done can observe work completed between two Idle reports. + // Reuse the existing completion delivery policy for that semantic edge. + change.previous_state = AgentState::Working; + self.apply_pane_state_change(ws_idx, pane_id, &change, false); + } + fn apply_pane_state_change( &mut self, ws_idx: usize, diff --git a/src/app/agent_view.rs b/src/app/agent_view.rs index e10d638a383..155763e2e47 100644 --- a/src/app/agent_view.rs +++ b/src/app/agent_view.rs @@ -113,6 +113,22 @@ impl AgentViewEntry for AppAgentViewEntry<'_> { } fn status(&self) -> &'static str { + if let Some(terminal) = self + .app + .workspaces + .get(self.entry.ws_idx) + .and_then(|workspace| workspace.tabs.get(self.entry.tab_idx)) + .and_then(|tab| tab.panes.get(&self.entry.pane_id)) + .and_then(|pane| self.app.terminals.get(&pane.attached_terminal_id)) + { + return match terminal.runtime_agent_status(self.entry.seen) { + crate::api::schema::AgentStatus::Idle => "idle", + crate::api::schema::AgentStatus::Working => "working", + crate::api::schema::AgentStatus::Blocked => "blocked", + crate::api::schema::AgentStatus::Done => "done", + crate::api::schema::AgentStatus::Unknown => "unknown", + }; + } status_name(self.entry.state, self.entry.seen) } @@ -381,6 +397,71 @@ mod tests { crate::ui::agent_panel_entries_from(state, &crate::terminal::TerminalRuntimeRegistry::new()) } + #[test] + fn runtime_attachment_done_status_preserves_actual_seen_filter() { + let mut state = state_with_agents(); + let pane_id = state.workspaces[0].tabs[0].root_pane; + let terminal_id = state.workspaces[0].tabs[0].panes[&pane_id] + .attached_terminal_id + .clone(); + let terminal = state.terminals.get_mut(&terminal_id).unwrap(); + terminal.runtime_binding = Some(crate::api::schema::RuntimeBinding { + pane_id: "pane".into(), + binding_id: "native".into(), + attachment: crate::api::schema::RuntimeAttachment { + provider: "shuvcode".into(), + home_id: "home".into(), + session_id: "ses_done".into(), + location: "/tmp".into(), + host_id: "local".into(), + attach_argv: vec![], + }, + seq: Some(1), + state: crate::api::schema::RuntimeState::Done, + label: None, + ttl_ms: 30_000, + fresh: true, + }); + terminal.runtime_observed_at = Some(std::time::Instant::now()); + state.workspaces[0].tabs[0] + .panes + .get_mut(&pane_id) + .unwrap() + .seen = true; + state.agent_view_override = Some(AgentViewSetParams { + source: "native.seen".into(), + label: None, + filter: Some(AgentViewFilter::All { + filters: vec![ + AgentViewFilter::Eq { + field: AgentViewField::Builtin(AgentViewBuiltinField::Status), + value: AgentViewValue::String("done".into()), + }, + AgentViewFilter::Eq { + field: AgentViewField::Builtin(AgentViewBuiltinField::Seen), + value: AgentViewValue::Bool(true), + }, + ], + }), + sort: vec![], + }); + let entries = projected_entries(&state); + assert_eq!(entries.len(), 1); + assert!(entries[0].seen); + let entry = AppAgentViewEntry { + app: &state, + entry: &entries[0], + }; + assert_eq!(entry.status(), "done"); + assert_eq!(entry.attention(), 1); + state.workspaces[0].tabs[0] + .panes + .get_mut(&pane_id) + .unwrap() + .seen = false; + assert!(projected_entries(&state).is_empty()); + } + fn current_workspace_view() -> AgentViewSetParams { AgentViewSetParams { source: "example.views".to_string(), diff --git a/src/app/agents.rs b/src/app/agents.rs index fd2a58edb8f..a90a00e703c 100644 --- a/src/app/agents.rs +++ b/src/app/agents.rs @@ -194,7 +194,11 @@ impl App { let shell_name = available_shell_name(runtime) .ok_or_else(|| AgentStartError::TargetBusy(params.pane_id.clone()))?; - let mut argv = vec![crate::detect::interactive_agent_executable(kind).to_string()]; + let distribution = crate::detect::distinct_distribution_label(kind, ¶ms.kind); + let mut argv = vec![distribution + .as_deref() + .unwrap_or_else(|| crate::detect::interactive_agent_executable(kind)) + .to_string()]; argv.extend(params.args); let command = crate::platform::interactive_shell_command(&argv, &shell_name) .ok_or(AgentStartError::InvalidArgument)?; @@ -215,6 +219,7 @@ impl App { .get_mut(&terminal_id) .ok_or_else(|| AgentStartError::TargetUnavailable(params.pane_id.clone()))?; terminal.begin_managed_agent(name.clone(), kind, now, AGENT_START_SETTLE_DELAY, timeout); + terminal.set_managed_agent_distribution_label(distribution); if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) { terminal.clear_agent_name(); return Err(AgentStartError::InputFailed(err.to_string())); diff --git a/src/app/api.rs b/src/app/api.rs index 1b5bad7192e..0260ae3fd3a 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -9,6 +9,7 @@ mod pane_graphics; mod panes; pub(crate) mod plugins; pub(super) mod responses; +mod runtime_attachment; mod session; mod tabs; mod workspaces; @@ -668,7 +669,12 @@ impl App { .workspaces .get(update.ws_idx) .and_then(|ws| ws.pane_state(update.pane_id)) - .map(|pane| pane_agent_status(update.state, pane.seen)) + .and_then(|pane| { + self.state + .terminals + .get(&pane.attached_terminal_id) + .map(|terminal| terminal.runtime_agent_status(pane.seen)) + }) .unwrap_or_else(|| pane_agent_status(update.state, update.seen)); if previous_agent_status != agent_status @@ -1162,6 +1168,18 @@ impl App { Method::PaneGraphicsStreamClose(params) => { return self.handle_pane_graphics_stream_close(request.id, params); } + Method::PaneBindRuntime(params) => { + return self.handle_pane_bind_runtime(request.id, params) + } + Method::PaneGetRuntime(params) => { + return self.handle_pane_get_runtime(request.id, params) + } + Method::PaneReportRuntime(params) => { + return self.handle_pane_report_runtime(request.id, params) + } + Method::PaneUnbindRuntime(params) => { + return self.handle_pane_unbind_runtime(request.id, params) + } Method::PaneReportAgent(params) => { return self.handle_pane_report_agent(request.id, params); } diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index d019de7f71c..b3576f0f7b8 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -1275,6 +1275,30 @@ impl App { None }; + if let Some(public_id) = self.public_pane_id(target_ws_idx, moved_pane_id) { + for owner in self.state.runtime_binding_owners.values_mut() { + if owner.pane_id == previous_pane_id { + owner.pane_id = public_id.clone(); + } + } + if let Some(terminal_id) = self.state.workspaces[target_ws_idx] + .terminal_id(moved_pane_id) + .cloned() + { + if let Some(binding) = self + .state + .terminals + .get_mut(&terminal_id) + .and_then(|terminal| terminal.runtime_binding.as_mut()) + { + // Explicit movement changes the presentation ID of the same physical pane. + binding.pane_id = public_id; + self.state + .runtime_binding_owners + .insert(binding.binding_id.clone(), binding.clone()); + } + } + } self.state.remove_alias_shadowed_by_new_pane(moved_pane_id); self.state.mark_session_dirty(); self.schedule_session_save(); diff --git a/src/app/api/runtime_attachment.rs b/src/app/api/runtime_attachment.rs new file mode 100644 index 00000000000..7e35134f795 --- /dev/null +++ b/src/app/api/runtime_attachment.rs @@ -0,0 +1,659 @@ +use std::time::Instant; + +use crate::api::schema::{ + PaneBindRuntimeParams, PaneReportRuntimeParams, PaneTarget, PaneUnbindRuntimeParams, + ResponseResult, RuntimeBinding, RuntimeState, +}; +use crate::app::App; +use crate::terminal::TerminalId; + +use super::responses::{encode_error, encode_success}; + +impl App { + fn runtime_binding_target( + &self, + pane_id: &str, + ) -> Option<(usize, crate::layout::PaneId, TerminalId)> { + let (ws_idx, id) = self.parse_current_public_pane_id(pane_id)?; + Some(( + ws_idx, + id, + self.state.workspaces[ws_idx].terminal_id(id)?.clone(), + )) + } + + pub(super) fn handle_pane_bind_runtime( + &mut self, + id: String, + params: PaneBindRuntimeParams, + ) -> String { + let Some((ws_idx, pane_id, terminal_id)) = self.runtime_binding_target(¶ms.pane_id) + else { + return encode_error(id, "pane_not_found", "exact pane identity not found"); + }; + if params.binding_id.is_empty() + || params.binding_id.len() > 512 + || params.binding_id.chars().any(char::is_control) + { + return encode_error(id, "invalid_runtime_binding", "invalid binding ID"); + } + if let Err(message) = params.attachment.validate() { + return encode_error(id, "invalid_runtime_attachment", message); + } + let terminal = &self.state.terminals[&terminal_id]; + let current = terminal.runtime_binding.as_ref(); + let owner = self.state.runtime_binding_owners.get(¶ms.binding_id); + if current.is_some_and(|binding| { + binding.binding_id != params.binding_id || binding.attachment != params.attachment + }) || owner.is_some_and(|binding| { + binding.pane_id != params.pane_id || binding.attachment != params.attachment + }) { + return encode_error( + id, + "runtime_binding_conflict", + "binding identity is immutable; explicitly unbind the exact current binding", + ); + } + if current.is_some() { + return encode_success( + id, + ResponseResult::PaneRuntime { + applied: Some(true), + binding: terminal.runtime_binding_snapshot(Instant::now()), + }, + ); + } + let binding = RuntimeBinding { + pane_id: params.pane_id, + binding_id: params.binding_id, + attachment: params.attachment, + seq: owner.and_then(|binding| binding.seq), + state: RuntimeState::Unknown, + label: None, + ttl_ms: crate::runtime_attachment::DEFAULT_TTL_MS, + fresh: false, + }; + self.state + .runtime_binding_owners + .insert(binding.binding_id.clone(), binding.clone()); + if let Some(update) = self.state.update_terminal_state(pane_id, |terminal| { + Some(terminal.mutate_runtime_binding(Some(binding.clone()), None)) + }) { + self.emit_pane_state_update(&update); + } + self.schedule_session_save(); + self.emit_pane_updated(ws_idx, pane_id); + encode_success( + id, + ResponseResult::PaneRuntime { + applied: Some(true), + binding: Some(binding), + }, + ) + } + + pub(super) fn handle_pane_get_runtime(&mut self, id: String, params: PaneTarget) -> String { + let Some((_, _, terminal_id)) = self.runtime_binding_target(¶ms.pane_id) else { + return encode_error(id, "pane_not_found", "exact pane identity not found"); + }; + encode_success( + id, + ResponseResult::PaneRuntime { + applied: None, + binding: self.state.terminals[&terminal_id] + .runtime_binding_snapshot(Instant::now()), + }, + ) + } + + pub(super) fn handle_pane_report_runtime( + &mut self, + id: String, + params: PaneReportRuntimeParams, + ) -> String { + let Some((ws_idx, pane_id, terminal_id)) = self.runtime_binding_target(¶ms.pane_id) + else { + return encode_error(id, "pane_not_found", "exact pane identity not found"); + }; + let ttl_ms = params + .ttl_ms + .unwrap_or(crate::runtime_attachment::DEFAULT_TTL_MS); + if !(1..=86_400_000).contains(&ttl_ms) + || params + .label + .as_ref() + .is_some_and(|label| label.len() > 512 || label.chars().any(char::is_control)) + { + return encode_error( + id, + "invalid_runtime_report", + "invalid observation TTL or label", + ); + } + let terminal = &self.state.terminals[&terminal_id]; + let Some(mut binding) = terminal.runtime_binding.clone().filter(|binding| { + binding.binding_id == params.binding_id + && binding.pane_id == params.pane_id + && binding.seq.is_none_or(|seq| params.seq > seq) + }) else { + return encode_success( + id, + ResponseResult::PaneRuntime { + applied: Some(false), + binding: terminal.runtime_binding_snapshot(Instant::now()), + }, + ); + }; + let previous_runtime_state = binding.state; + let previous_detect_state = terminal.state; + binding.seq = Some(params.seq); + binding.state = params.state; + binding.label = params.label; + binding.ttl_ms = ttl_ms; + binding.fresh = true; + self.state + .runtime_binding_owners + .insert(binding.binding_id.clone(), binding.clone()); + let now = Instant::now(); + let update = self.state.update_terminal_state(pane_id, |terminal| { + Some(terminal.mutate_runtime_binding(Some(binding.clone()), Some(now))) + }); + let same_detect_transition = previous_runtime_state != binding.state + && previous_detect_state == binding.state.detect(); + if same_detect_transition { + self.state.next_agent_state_change_seq += 1; + if let Some(terminal) = self.state.terminals.get_mut(&terminal_id) { + terminal.last_agent_state_change_seq = Some(self.state.next_agent_state_change_seq); + terminal.last_agent_completion_seq = (binding.state == RuntimeState::Done) + .then_some(self.state.next_agent_state_change_seq); + } + if binding.state == RuntimeState::Done { + self.state.publish_runtime_completion(ws_idx, pane_id); + } + } + if let Some(update) = &update { + self.emit_pane_state_update(update); + } + if same_detect_transition && update.is_none() { + if let Some(pane) = self.pane_info(ws_idx, pane_id) { + self.emit_event(crate::api::schema::EventEnvelope { + event: crate::api::schema::EventKind::PaneAgentStatusChanged, + data: crate::api::schema::EventData::PaneAgentStatusChanged { + pane_id: binding.pane_id.clone(), + workspace_id: pane.workspace_id, + agent_status: pane.agent_status, + agent: pane.agent, + title: pane.title, + display_agent: pane.display_agent, + state_labels: pane.state_labels, + }, + }); + } + } + self.sync_agent_metadata_deadline(); + self.schedule_session_save(); + self.emit_pane_updated(ws_idx, pane_id); + encode_success( + id, + ResponseResult::PaneRuntime { + applied: Some(true), + binding: Some(binding), + }, + ) + } + + pub(super) fn handle_pane_unbind_runtime( + &mut self, + id: String, + params: PaneUnbindRuntimeParams, + ) -> String { + let Some((ws_idx, pane_id, terminal_id)) = self.runtime_binding_target(¶ms.pane_id) + else { + return encode_error(id, "pane_not_found", "exact pane identity not found"); + }; + let terminal = &self.state.terminals[&terminal_id]; + let exact = terminal + .runtime_binding + .as_ref() + .is_some_and(|binding| binding.binding_id == params.binding_id); + let repeat = terminal.runtime_binding.is_none() + && self + .state + .runtime_binding_owners + .get(¶ms.binding_id) + .is_some_and(|binding| binding.pane_id == params.pane_id); + if !exact && !repeat { + return encode_success( + id, + ResponseResult::PaneRuntime { + applied: Some(false), + binding: terminal.runtime_binding_snapshot(Instant::now()), + }, + ); + } + if exact { + if let Some(update) = self.state.update_terminal_state(pane_id, |terminal| { + Some(terminal.mutate_runtime_binding(None, None)) + }) { + self.emit_pane_state_update(&update); + } + self.sync_agent_metadata_deadline(); + self.schedule_session_save(); + self.emit_pane_updated(ws_idx, pane_id); + } + encode_success( + id, + ResponseResult::PaneRuntime { + applied: Some(true), + binding: None, + }, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::schema::{RuntimeAttachment, SuccessResponse}; + + fn app() -> App { + let (_, rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &crate::config::Config::default(), + crate::app::AppPolicy::TEST, + None, + rx, + crate::api::EventHub::default(), + ); + app.state = crate::app::AppState::test_with_adversarial_identity_state(); + app.state.ensure_test_terminals(); + app + } + + fn attachment() -> RuntimeAttachment { + let root = std::env::temp_dir(); + let location = root.join("herdr-native-project").display().to_string(); + RuntimeAttachment { + provider: "shuvcode".into(), + home_id: "home-a".into(), + session_id: "ses_a".into(), + location: location.clone(), + host_id: "local".into(), + attach_argv: [ + root.join("shuvcode").display().to_string(), + "supervisor".into(), + "attach".into(), + "--home".into(), + root.join("herdr-native-home").display().to_string(), + "--home-id".into(), + "home-a".into(), + "--session".into(), + "ses_a".into(), + "--location".into(), + location, + ] + .into(), + } + } + + #[test] + fn runtime_attachment_identity_sequences_and_unbind_preserve_topology() { + let mut app = app(); + let pane_id = app + .public_pane_id(0, app.state.workspaces[0].tabs[0].root_pane) + .unwrap(); + let other = app + .public_pane_id(0, app.state.workspaces[0].tabs[1].root_pane) + .unwrap(); + let bind = PaneBindRuntimeParams { + pane_id: pane_id.clone(), + binding_id: "binding-a".into(), + attachment: attachment(), + }; + assert!(app + .handle_pane_bind_runtime("bind".into(), bind.clone()) + .contains("\"applied\":true")); + assert!(app + .handle_pane_bind_runtime("retry".into(), bind.clone()) + .contains("\"applied\":true")); + let mut conflict = bind.clone(); + conflict.pane_id = other; + assert!(app + .handle_pane_bind_runtime("collision".into(), conflict) + .contains("runtime_binding_conflict")); + let report = PaneReportRuntimeParams { + pane_id: pane_id.clone(), + binding_id: "binding-a".into(), + seq: 10, + state: RuntimeState::Working, + label: Some("native work".into()), + ttl_ms: Some(5_000), + }; + assert!(app + .handle_pane_report_runtime("report".into(), report.clone()) + .contains("\"applied\":true")); + assert!(app + .handle_pane_report_runtime("stale".into(), report.clone()) + .contains("\"applied\":false")); + let mut wrong = report.clone(); + wrong.binding_id = "binding-b".into(); + wrong.seq = 11; + assert!(app + .handle_pane_report_runtime("foreign".into(), wrong) + .contains("\"applied\":false")); + let unbind = PaneUnbindRuntimeParams { + pane_id: pane_id.clone(), + binding_id: "binding-a".into(), + }; + assert!(app + .handle_pane_unbind_runtime("unbind".into(), unbind.clone()) + .contains("\"applied\":true")); + assert!(app + .handle_pane_unbind_runtime("retry-unbind".into(), unbind) + .contains("\"applied\":true")); + assert!(app + .handle_pane_bind_runtime("rebind".into(), bind) + .contains("\"applied\":true")); + assert!(app + .handle_pane_report_runtime("old-after-rebind".into(), report) + .contains("\"applied\":false")); + let response: SuccessResponse = serde_json::from_str( + &app.handle_pane_get_runtime("get".into(), PaneTarget { pane_id }), + ) + .unwrap(); + let ResponseResult::PaneRuntime { + binding: Some(binding), + .. + } = response.result + else { + panic!("binding missing") + }; + assert_eq!(binding.seq, Some(10)); + assert_eq!(binding.state, RuntimeState::Unknown); + app.state.assert_invariants_for_test(); + } + + #[test] + fn runtime_attachment_observation_survives_tui_navigation_and_expires() { + let mut app = app(); + let pane_id = app + .public_pane_id(0, app.state.workspaces[0].tabs[0].root_pane) + .unwrap(); + app.handle_pane_bind_runtime( + "bind".into(), + PaneBindRuntimeParams { + pane_id: pane_id.clone(), + binding_id: "binding-a".into(), + attachment: attachment(), + }, + ); + app.handle_pane_report_runtime( + "report".into(), + PaneReportRuntimeParams { + pane_id: pane_id.clone(), + binding_id: "binding-a".into(), + seq: 1, + state: RuntimeState::Done, + label: None, + ttl_ms: Some(1000), + }, + ); + let (_, _, terminal_id) = app.runtime_binding_target(&pane_id).unwrap(); + let terminal = app.state.terminals.get_mut(&terminal_id).unwrap(); + terminal.set_hook_authority( + "custom:tui".into(), + "pi".into(), + crate::detect::AgentState::Working, + None, + Some(1), + ); + terminal.set_detected_state( + Some(crate::detect::Agent::Codex), + crate::detect::AgentState::Blocked, + ); + assert_eq!( + terminal.runtime_agent_status(true), + crate::api::schema::AgentStatus::Done + ); + assert_eq!( + terminal + .runtime_binding + .as_ref() + .unwrap() + .attachment + .session_id, + "ses_a" + ); + let deadline = terminal.runtime_expiry().unwrap(); + terminal.expire_agent_metadata_at(deadline, deadline); + assert_eq!(terminal.state, crate::detect::AgentState::Unknown); + let current = terminal.runtime_binding_snapshot(deadline).unwrap(); + assert!(!current.fresh); + assert_eq!(current.state, RuntimeState::Unknown); + let saved = terminal.runtime_binding.clone(); + terminal.restore_runtime_binding(saved.as_ref()); + assert_eq!(terminal.runtime_binding.as_ref().unwrap().seq, Some(1)); + assert_eq!( + terminal.runtime_agent_status(false), + crate::api::schema::AgentStatus::Unknown + ); + app.state.assert_invariants_for_test(); + } + + #[test] + fn runtime_attachment_rejects_credentials_and_changed_attach_identity() { + let mut valid = attachment(); + assert!(valid.validate().is_ok()); + valid + .attach_argv + .extend(["--password".into(), "secret".into()]); + assert!(valid.validate().is_err()); + let mut changed = attachment(); + changed.session_id = "ses_b".into(); + assert!(changed.validate().is_err()); + } + #[test] + fn runtime_attachment_done_heartbeats_preserve_completion_sequence() { + let mut app = app(); + app.state.outer_terminal_focus = Some(false); + app.state.toast_config.delay_seconds = 1; + let pane_id = app + .public_pane_id(0, app.state.workspaces[0].tabs[0].root_pane) + .unwrap(); + app.handle_pane_bind_runtime( + "bind".into(), + PaneBindRuntimeParams { + pane_id: pane_id.clone(), + binding_id: "binding-a".into(), + attachment: attachment(), + }, + ); + let report = |seq, state| PaneReportRuntimeParams { + pane_id: pane_id.clone(), + binding_id: "binding-a".into(), + seq, + state, + label: None, + ttl_ms: None, + }; + app.handle_pane_report_runtime("idle".into(), report(1, RuntimeState::Idle)); + let (_, raw, terminal_id) = app.runtime_binding_target(&pane_id).unwrap(); + let before = app.state.terminals[&terminal_id].last_agent_state_change_seq; + app.handle_pane_report_runtime("done".into(), report(2, RuntimeState::Done)); + let completion = app.state.terminals[&terminal_id].last_agent_completion_seq; + assert!(completion > before); + let notification = app.state.pending_agent_notifications[&raw].clone(); + let seen = app.state.workspaces[0].pane_state(raw).unwrap().seen; + app.handle_pane_report_runtime("heartbeat".into(), report(3, RuntimeState::Done)); + assert_eq!( + app.state.terminals[&terminal_id].last_agent_completion_seq, + completion + ); + assert_eq!( + app.state.terminals[&terminal_id].last_agent_state_change_seq, + completion + ); + assert_eq!(app.state.workspaces[0].pane_state(raw).unwrap().seen, seen); + assert_eq!( + app.state.pending_agent_notifications[&raw].deadline, + notification.deadline + ); + assert_eq!( + app.pane_info(0, raw).unwrap().agent_status, + crate::api::schema::AgentStatus::Done + ); + assert!(!app.state.terminals[&terminal_id].runtime_presentation_seen(true)); + app.state.assert_invariants_for_test(); + } + + #[test] + fn runtime_attachment_restored_or_expired_done_does_not_replay_notifications() { + let mut app = app(); + app.state.outer_terminal_focus = Some(false); + app.state.toast_config.delay_seconds = 1; + let pane_id = app + .public_pane_id(0, app.state.workspaces[0].tabs[0].root_pane) + .unwrap(); + app.handle_pane_bind_runtime( + "bind".into(), + PaneBindRuntimeParams { + pane_id: pane_id.clone(), + binding_id: "binding-a".into(), + attachment: attachment(), + }, + ); + let (_, raw, terminal_id) = app.runtime_binding_target(&pane_id).unwrap(); + for seq in [1, 2] { + app.handle_pane_report_runtime( + "done".into(), + PaneReportRuntimeParams { + pane_id: pane_id.clone(), + binding_id: "binding-a".into(), + seq, + state: RuntimeState::Done, + label: None, + ttl_ms: None, + }, + ); + assert!(app.state.pending_agent_notifications.is_empty()); + assert_eq!( + app.pane_info(0, raw).unwrap().agent_status, + crate::api::schema::AgentStatus::Done + ); + app.state.terminals.get_mut(&terminal_id).unwrap().state = + crate::detect::AgentState::Unknown; + } + } + + #[test] + fn runtime_attachment_moves_with_same_physical_pane() { + let mut app = app(); + let pane_id = app + .public_pane_id(0, app.state.workspaces[0].tabs[0].root_pane) + .unwrap(); + app.handle_pane_bind_runtime( + "bind".into(), + PaneBindRuntimeParams { + pane_id: pane_id.clone(), + binding_id: "binding-a".into(), + attachment: attachment(), + }, + ); + let (_, _, terminal_id) = app.runtime_binding_target(&pane_id).unwrap(); + let moved: SuccessResponse = serde_json::from_str(&app.handle_pane_move( + "move".into(), + crate::api::schema::PaneMoveParams { + pane_id: pane_id.clone(), + destination: crate::api::schema::PaneMoveDestination::NewWorkspace { + label: None, + tab_label: None, + }, + focus: false, + }, + )) + .unwrap(); + let ResponseResult::PaneMove { move_result } = moved.result else { + panic!("move failed") + }; + let new_id = move_result.pane.pane_id.clone(); + assert_ne!(pane_id, new_id); + assert_eq!(app.runtime_binding_target(&new_id).unwrap().2, terminal_id); + assert_eq!( + app.state.runtime_binding_owners["binding-a"].pane_id, + new_id + ); + assert!(app + .handle_pane_get_runtime("old".into(), PaneTarget { pane_id }) + .contains("pane_not_found")); + assert!(app + .handle_pane_report_runtime( + "moved-report".into(), + PaneReportRuntimeParams { + pane_id: new_id, + binding_id: "binding-a".into(), + seq: 1, + state: RuntimeState::Working, + label: None, + ttl_ms: None + } + ) + .contains("\"applied\":true")); + app.state.assert_invariants_for_test(); + } + + #[test] + fn runtime_attachment_unbound_owner_follows_same_physical_pane_move() { + let mut app = app(); + let pane_id = app + .public_pane_id(0, app.state.workspaces[0].tabs[0].root_pane) + .unwrap(); + let bind = PaneBindRuntimeParams { + pane_id: pane_id.clone(), + binding_id: "binding-a".into(), + attachment: attachment(), + }; + assert!(app + .handle_pane_bind_runtime("bind".into(), bind.clone()) + .contains("\"applied\":true")); + assert!(app + .handle_pane_unbind_runtime( + "unbind".into(), + PaneUnbindRuntimeParams { + pane_id: pane_id.clone(), + binding_id: "binding-a".into(), + } + ) + .contains("\"applied\":true")); + let terminal_id = app.runtime_binding_target(&pane_id).unwrap().2; + let moved: SuccessResponse = serde_json::from_str(&app.handle_pane_move( + "move".into(), + crate::api::schema::PaneMoveParams { + pane_id, + destination: crate::api::schema::PaneMoveDestination::NewWorkspace { + label: None, + tab_label: None, + }, + focus: false, + }, + )) + .unwrap(); + let ResponseResult::PaneMove { move_result } = moved.result else { + panic!("move failed") + }; + let mut moved_bind = bind; + moved_bind.pane_id = move_result.pane.pane_id; + assert_eq!( + app.runtime_binding_target(&moved_bind.pane_id).unwrap().2, + terminal_id + ); + assert!(app + .handle_pane_bind_runtime("rebind".into(), moved_bind.clone()) + .contains("\"applied\":true")); + moved_bind.attachment.home_id = "changed-home".into(); + moved_bind.attachment.attach_argv[6] = "changed-home".into(); + assert!(app + .handle_pane_bind_runtime("replace".into(), moved_bind) + .contains("runtime_binding_conflict")); + app.state.assert_invariants_for_test(); + } +} diff --git a/src/app/api_helpers.rs b/src/app/api_helpers.rs index c973346fadd..2b052ead0de 100644 --- a/src/app/api_helpers.rs +++ b/src/app/api_helpers.rs @@ -93,7 +93,7 @@ pub(super) fn detect_state_from_api( } } -pub(super) fn pane_agent_status( +pub(crate) fn pane_agent_status( state: crate::detect::AgentState, seen: bool, ) -> crate::api::schema::AgentStatus { diff --git a/src/app/creation.rs b/src/app/creation.rs index 05736fe5e78..64d8536c990 100644 --- a/src/app/creation.rs +++ b/src/app/creation.rs @@ -213,7 +213,12 @@ impl App { self.state .terminals .get(&pane.attached_terminal_id) - .map(|terminal| (terminal.state, pane.seen)) + .map(|terminal| { + ( + terminal.state, + terminal.runtime_presentation_seen(pane.seen), + ) + }) }) .max_by_key(|(state, seen)| tab_attention_priority(*state, *seen)) .unwrap_or((crate::detect::AgentState::Unknown, true)); @@ -347,7 +352,7 @@ impl App { terminal_title: terminal.terminal_title.clone(), terminal_title_stripped: terminal.terminal_title_stripped(), display_agent: presentation.display_agent, - agent_status: pane_agent_status(terminal.state, pane.seen), + agent_status: terminal.runtime_agent_status(pane.seen), state_labels: presentation.state_labels, tokens: terminal.metadata_tokens.values(), agent_session: terminal_agent_session_info(terminal), @@ -407,6 +412,14 @@ impl App { fn terminal_agent_session_info( terminal: &crate::terminal::TerminalState, ) -> Option { + if let Some(binding) = &terminal.runtime_binding { + return Some(crate::api::schema::AgentSessionInfo { + source: "herdr:shuvcode".into(), + agent: binding.attachment.provider.clone(), + kind: crate::agent_resume::AgentSessionRefKind::Id, + value: binding.attachment.session_id.clone(), + }); + } if let Some(authority) = terminal.hook_authority.as_ref() { if let Some(session_ref) = authority.session_ref.as_ref() { return Some(crate::api::schema::AgentSessionInfo { diff --git a/src/app/mod.rs b/src/app/mod.rs index e24034a08a6..522bdfa69dd 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -11,7 +11,7 @@ pub(crate) use agents::{AGENT_START_SETTLE_DELAY, MAX_AGENT_START_TIMEOUT}; mod api; #[cfg(test)] pub(crate) use api::test_support::exiting_test_command; -mod api_helpers; +pub(crate) mod api_helpers; pub(crate) use api_helpers::limit_snapshot_lines; mod creation; mod custom_commands; @@ -375,6 +375,10 @@ impl App { let session_writer = Arc::new(std::sync::Mutex::new(crate::persist::SessionWriter::new( policy.restore_session && snapshot.is_none(), ))); + let runtime_binding_owners = snapshot + .as_ref() + .map(|snap| snap.runtime_binding_owners.clone()) + .unwrap_or_default(); let (workspaces, active, selected) = if let Some(snap) = snapshot { let history = config .experimental @@ -445,6 +449,7 @@ impl App { let (theme_palette, theme_name) = resolve_effective_theme(&theme_runtime, None); let mut state = AppState { + runtime_binding_owners, terminals: std::collections::HashMap::new(), direct_attach_resize_locks: std::collections::HashSet::new(), pane_id_aliases: std::collections::HashMap::new(), @@ -666,6 +671,7 @@ impl App { app.state.workspaces = workspaces; app.state.terminals = terminals; app.terminal_runtimes = runtimes.into(); + app.state.runtime_binding_owners = snapshot.runtime_binding_owners.clone(); app.state.active = snapshot .active .filter(|&idx| idx < app.state.workspaces.len()); diff --git a/src/app/session.rs b/src/app/session.rs index 509d4050f53..bac22adea6e 100644 --- a/src/app/session.rs +++ b/src/app/session.rs @@ -5,7 +5,7 @@ use super::{App, SESSION_SAVE_DEBOUNCE}; enum SessionSaveJob { Clear, Save { - snapshot: crate::persist::SessionSnapshot, + snapshot: Box, history: Option, }, } @@ -38,16 +38,17 @@ impl App { } fn capture_session_save_job(&self) -> SessionSaveJob { - if self.state.workspaces.is_empty() { + if self.state.workspaces.is_empty() && self.state.runtime_binding_owners.is_empty() { SessionSaveJob::Clear } else { - let snapshot = crate::persist::capture( + let mut snapshot = crate::persist::capture( &self.state.workspaces, &self.state.terminals, &self.terminal_runtimes, self.state.active, self.state.selected, ); + snapshot.runtime_binding_owners = self.state.runtime_binding_owners.clone(); let history = self.persist_pane_history.then(|| { crate::persist::capture_history( &snapshot, @@ -55,7 +56,10 @@ impl App { &self.terminal_runtimes, ) }); - SessionSaveJob::Save { snapshot, history } + SessionSaveJob::Save { + snapshot: Box::new(snapshot), + history, + } } } diff --git a/src/app/state.rs b/src/app/state.rs index 8a2a4a644c9..f85e88e4546 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -812,6 +812,8 @@ pub enum TabBarStatusSegment { } pub struct AppState { + pub runtime_binding_owners: + std::collections::HashMap, pub terminals: std::collections::HashMap, /// Terminal ids whose size is currently owned by a direct attach client. @@ -1046,6 +1048,7 @@ impl AppState { /// Create an AppState for testing — no channels, no PTYs. pub fn test_new() -> Self { Self { + runtime_binding_owners: std::collections::HashMap::new(), terminals: std::collections::HashMap::new(), direct_attach_resize_locks: std::collections::HashSet::new(), pane_id_aliases: std::collections::HashMap::new(), diff --git a/src/app/terminal_targets.rs b/src/app/terminal_targets.rs index a8fe394a5f8..1eb0ce241b9 100644 --- a/src/app/terminal_targets.rs +++ b/src/app/terminal_targets.rs @@ -1,4 +1,4 @@ -use super::{api_helpers::pane_agent_status, App}; +use super::App; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct TerminalTarget { @@ -189,7 +189,7 @@ impl App { cwd: ws.tabs[tab_idx] .cwd_for_pane(pane_id, &self.state.terminals, &self.terminal_runtimes) .map(|cwd| cwd.display().to_string()), - agent_status: pane_agent_status(terminal.state, pane.seen), + agent_status: terminal.runtime_agent_status(pane.seen), }) } } diff --git a/src/cli/status.rs b/src/cli/status.rs index 2e92c5ee53c..0f81b0aecc6 100644 --- a/src/cli/status.rs +++ b/src/cli/status.rs @@ -424,6 +424,10 @@ mod tests { version: version.map(str::to_owned), protocol: Some(crate::protocol::PROTOCOL_VERSION), capabilities: Some(crate::api::schema::ServerCapabilities { + runtime_attachment_methods: crate::runtime_attachment::METHODS + .iter() + .map(|method| (*method).into()) + .collect(), live_handoff: true, direct_control_yields_to_shell: true, detached_server_daemon: true, diff --git a/src/client/endpoint/control.rs b/src/client/endpoint/control.rs index f2fe1b76d50..fed364e6ac0 100644 --- a/src/client/endpoint/control.rs +++ b/src/client/endpoint/control.rs @@ -89,6 +89,7 @@ mod tests { boot_id: "boot".into(), revision: 3, completions: [("pane".into(), 7)].into_iter().collect(), + managed_statuses: Default::default(), }; let crate::protocol::ServerMessage::EndpointControl { kind, data } = crate::protocol::endpoint::agent_completions_message(&projection).unwrap() diff --git a/src/client/shell/endpoint_agent_state.rs b/src/client/shell/endpoint_agent_state.rs index 1ebcd35df26..13009b4ca27 100644 --- a/src/client/shell/endpoint_agent_state.rs +++ b/src/client/shell/endpoint_agent_state.rs @@ -9,6 +9,8 @@ pub(super) struct EndpointAgentPresentation { acknowledged: HashMap, completed: HashMap, working: HashSet, + managed_statuses: std::collections::BTreeMap, + managed_revision: Option<(Option, u64)>, pending_completions: Option<( Option, crate::protocol::endpoint::EndpointAgentCompletions, @@ -50,6 +52,8 @@ impl EndpointAgentPresentation { self.acknowledged.clear(); self.completed.clear(); self.working.clear(); + self.managed_statuses.clear(); + self.managed_revision = None; self.acknowledged.extend( snapshot .agents @@ -68,7 +72,7 @@ impl EndpointAgentPresentation { .retain(|pane_id, _| pane_ids.contains(pane_id.as_str())); self.working .retain(|pane_id| pane_ids.contains(pane_id.as_str())); - let completions = self + let projection = self .pending_completions .take() .filter(|(received_generation, projection)| { @@ -76,7 +80,15 @@ impl EndpointAgentPresentation { && projection.boot_id == snapshot.boot_id && projection.revision == snapshot.revision }) - .map(|(_, projection)| projection.completions); + .map(|(_, projection)| projection); + if let Some(projection) = &projection { + self.managed_statuses = projection.managed_statuses.clone(); + self.managed_revision = Some((generation, snapshot.revision)); + } else if self.managed_revision != Some((generation, snapshot.revision)) { + self.managed_statuses.clear(); + self.managed_revision = None; + } + let completions = projection.map(|projection| projection.completions); for agent in &mut snapshot.agents { match agent.agent_status { AgentStatus::Working => { @@ -162,6 +174,9 @@ impl EndpointAgentPresentation { } fn projected_status(&self, agent: &ClientShellAgent) -> AgentStatus { + if let Some(status) = self.managed_statuses.get(&agent.pane_id) { + return *status; + } match agent.agent_status { AgentStatus::Idle | AgentStatus::Done => { if self.seen(agent) { @@ -283,6 +298,59 @@ mod tests { assert_eq!(snapshot.agents[0].agent_status, AgentStatus::Idle); } + fn native_completions(revision: u64, seq: u64) -> EndpointAgentCompletions { + serde_json::from_value(serde_json::json!({ + "boot_id": "endpoint-boot", + "revision": revision, + "completions": { "agent-pane": seq }, + "managed_statuses": { "agent-pane": "done" } + })) + .unwrap() + } + + #[test] + fn native_runtime_done_is_visible_after_unknown_baseline() { + let mut presentation = EndpointAgentPresentation::default(); + let mut initial = snapshot(AgentStatus::Unknown, 1, 1); + presentation.project_snapshot(&mut initial); + presentation.receive_completions(None, native_completions(2, 2)); + let mut completed = snapshot(AgentStatus::Done, 2, 2); + presentation.project_snapshot(&mut completed); + assert_eq!(completed.agents[0].agent_status, AgentStatus::Done); + assert!(presentation.acknowledge_surface(&mut completed, &surface(2), Some(true))); + assert!(presentation.seen(&completed.agents[0])); + assert_eq!(completed.agents[0].agent_status, AgentStatus::Done); + } + + #[test] + fn native_runtime_done_is_visible_in_first_snapshot() { + let mut presentation = EndpointAgentPresentation::default(); + presentation.receive_completions(None, native_completions(1, 2)); + let mut completed = snapshot(AgentStatus::Done, 2, 1); + presentation.project_snapshot(&mut completed); + assert!(presentation.seen(&completed.agents[0])); + assert_eq!(completed.agents[0].agent_status, AgentStatus::Done); + } + + #[test] + fn native_runtime_authority_requires_a_coherent_optional_companion() { + for (boot, revision, generation) in [ + ("old-boot", 2, Some(1)), + ("endpoint-boot", 1, Some(1)), + ("endpoint-boot", 2, Some(0)), + ] { + let mut presentation = EndpointAgentPresentation::default(); + let mut initial = snapshot(AgentStatus::Idle, 1, 1); + presentation.project_snapshot_for_generation(&mut initial, Some(1)); + let mut projection = native_completions(revision, 2); + projection.boot_id = boot.into(); + presentation.receive_completions(generation, projection); + let mut current = snapshot(AgentStatus::Done, 2, 2); + presentation.project_snapshot_for_generation(&mut current, Some(1)); + assert_eq!(current.agents[0].agent_status, AgentStatus::Idle); + } + } + fn assert_idle_sequence(states: &[(AgentStatus, u64)]) { let mut presentation = EndpointAgentPresentation::default(); let mut projected = AgentStatus::Unknown; @@ -302,6 +370,7 @@ mod tests { .map(|seq| ("agent-pane".into(), seq)) .into_iter() .collect(), + managed_statuses: Default::default(), } } diff --git a/src/main.rs b/src/main.rs index 7ecec3547b5..2af4421bab9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -48,6 +48,7 @@ mod release_notes; mod remote; mod render_prof; mod render_signal; +mod runtime_attachment; mod selection; mod server; mod session; diff --git a/src/persist/io.rs b/src/persist/io.rs index b483b264d85..de000b4353f 100644 --- a/src/persist/io.rs +++ b/src/persist/io.rs @@ -192,6 +192,7 @@ mod tests { sidebar_width: Some(26), sidebar_section_split: Some(0.5), collapsed_space_keys: std::collections::HashSet::new(), + runtime_binding_owners: Default::default(), } } diff --git a/src/persist/restore.rs b/src/persist/restore.rs index e7d48a554b9..f89ded9408c 100644 --- a/src/persist/restore.rs +++ b/src/persist/restore.rs @@ -32,6 +32,7 @@ struct PaneRestoreStartup<'a> { initial_history_ansi: Option<&'a str>, duplicate_agent_session: bool, reserved_agent_session: Option, + restore_error: Option<&'static str>, } struct RestoreRuntimeContext<'a> { @@ -267,6 +268,18 @@ fn restore_with_imports_and_failures( render_notify: Arc, render_dirty: Arc, ) -> RestoreFailures { + // Closed attachment owners still reserve public identity after an empty restart. + crate::workspace::reserve_public_workspace_ids( + snapshot + .runtime_binding_owners + .values() + .filter_map(|binding| { + binding + .pane_id + .split_once(':') + .map(|(workspace, _)| workspace) + }), + ); let history = history.filter(|history| { let matches = history.layout_fingerprint.is_some() && history.layout_fingerprint == super::snapshot::layout_fingerprint(snapshot); @@ -452,6 +465,7 @@ fn unavailable_restored_terminal( if let Some(pane) = pane { terminal.manual_label = pane.label.clone(); terminal.launch_argv = pane.launch_argv.clone(); + terminal.restore_runtime_binding(pane.runtime_binding.as_ref()); if let Some(session) = restored_terminal_agent_session(pane.agent_session.as_ref(), false) { terminal.set_persisted_agent_session(session); } @@ -508,7 +522,12 @@ fn restore_tab( let old_id = reverse_id_map.get(id); let saved_pane = old_id.and_then(|old_id| snap.panes.get(old_id)); let saved_cwd = saved_pane - .map(|p| p.cwd.clone()) + .map(|p| { + p.runtime_binding + .as_ref() + .map(|binding| PathBuf::from(&binding.attachment.location)) + .unwrap_or_else(|| p.cwd.clone()) + }) .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into())); let cwd = saved_cwd; @@ -531,7 +550,10 @@ fn restore_tab( .and_then(|pane| pane.managed_agent_kind.as_deref()) .and_then(crate::detect::parse_canonical_agent_label); let saved_launch_argv = saved_pane.and_then(|p| p.launch_argv.clone()); - let saved_agent_session = saved_pane.and_then(|p| p.agent_session.as_ref()); + let saved_runtime_binding = saved_pane.and_then(|p| p.runtime_binding.as_ref()); + let saved_agent_session = saved_pane + .filter(|p| p.runtime_binding.is_none()) + .and_then(|p| p.agent_session.as_ref()); let saved_history = old_id.and_then(|old_id| history.and_then(|history| history.panes.get(old_id))); let startup = { @@ -539,7 +561,12 @@ fn restore_tab( enabled: runtime_context.resume_agents_on_restore, resumed_sessions: resumed_agent_sessions, }; - pane_restore_startup(saved_agent_session, saved_history, &mut agent_restore) + pane_restore_startup( + saved_agent_session, + saved_runtime_binding, + saved_history, + &mut agent_restore, + ) }; let restored_agent_session = restored_terminal_agent_session(saved_agent_session, startup.duplicate_agent_session); @@ -572,6 +599,7 @@ fn restore_tab( let terminal_id = TerminalId::alloc(); let mut terminal = TerminalState::new(terminal_id.clone(), cwd.clone()) .with_pending_agent_resume_plan(plan); + terminal.restore_runtime_binding(saved_runtime_binding); if let Some(label) = saved_label { terminal.set_manual_label(label); } @@ -669,6 +697,8 @@ fn restore_tab( terminal = terminal.with_launch_argv(argv).with_respawn_shell_on_exit(); } } + terminal.restore_runtime_binding(saved_runtime_binding); + terminal.restore_error = startup.restore_error.map(str::to_owned); if let Some(label) = saved_label { terminal.set_manual_label(label); } @@ -781,9 +811,43 @@ fn restore_tab( fn pane_restore_startup<'a>( session: Option<&PaneAgentSessionSnapshot>, + runtime: Option<&crate::api::schema::RuntimeBinding>, history: Option<&'a PaneHistorySnapshot>, agent_restore: &mut AgentRestoreState<'_>, ) -> PaneRestoreStartup<'a> { + if let Some(binding) = runtime { + // Ordinary agent resume controls execution admission. Native view restoration is + // independent: the trusted descriptor only attaches to an existing native Session. + // It cannot replace the missing Session or fall back to inferred execution resume. + let validation = binding.attachment.validate(); + let plan = validation + .as_ref() + .ok() + .map(|_| crate::agent_resume::AgentResumePlan { + agent: binding.attachment.provider.clone(), + argv: binding.attachment.attach_argv.clone(), + dedupe_key: format!("runtime:{}", binding.binding_id), + }); + let duplicate = plan.as_ref().is_some_and(|plan| { + !agent_restore + .resumed_sessions + .insert(plan.dedupe_key.clone()) + }); + let reserved_agent_session = plan + .as_ref() + .filter(|_| !duplicate) + .map(|plan| plan.dedupe_key.clone()); + return PaneRestoreStartup { + restore_plan: plan.filter(|_| !duplicate), + initial_history_ansi: validation + .is_err() + .then(|| history.map(|history| history.ansi.as_str())) + .flatten(), + duplicate_agent_session: duplicate, + reserved_agent_session, + restore_error: validation.err(), + }; + } // Native agent resume owns the conversation history. If a pane has a // resumable agent session and resume is enabled, do not replay saved pane // presentation history into that terminal, even when this pane is a @@ -821,6 +885,7 @@ fn pane_restore_startup<'a>( }, duplicate_agent_session, reserved_agent_session, + restore_error: None, } } @@ -1132,7 +1197,8 @@ mod tests { resumed_sessions: &mut resumed, }; - let startup = pane_restore_startup(Some(&session), Some(&history), &mut agent_restore); + let startup = + pane_restore_startup(Some(&session), None, Some(&history), &mut agent_restore); assert!(startup.restore_plan.is_some()); assert!(startup.initial_history_ansi.is_none()); @@ -1157,8 +1223,9 @@ mod tests { resumed_sessions: &mut resumed, }; - let first = pane_restore_startup(Some(&session), Some(&history), &mut agent_restore); - let duplicate = pane_restore_startup(Some(&session), Some(&history), &mut agent_restore); + let first = pane_restore_startup(Some(&session), None, Some(&history), &mut agent_restore); + let duplicate = + pane_restore_startup(Some(&session), None, Some(&history), &mut agent_restore); assert!(first.restore_plan.is_some()); assert!(first.initial_history_ansi.is_none()); @@ -1185,7 +1252,8 @@ mod tests { resumed_sessions: &mut resumed, }; - let startup = pane_restore_startup(Some(&session), Some(&history), &mut agent_restore); + let startup = + pane_restore_startup(Some(&session), None, Some(&history), &mut agent_restore); assert!(startup.restore_plan.is_none()); assert_eq!(startup.initial_history_ansi, Some("RESTORED_HISTORY\r\n")); @@ -1340,6 +1408,7 @@ mod tests { kind: crate::agent_resume::AgentSessionRefKind::Id, value: "opencode-session".into(), }), + runtime_binding: None, launch_argv: None, }, )]), @@ -1354,6 +1423,7 @@ mod tests { sidebar_width: None, sidebar_section_split: None, collapsed_space_keys: Default::default(), + runtime_binding_owners: Default::default(), }; let (events, _event_rx) = mpsc::channel(4); @@ -1421,6 +1491,7 @@ mod tests { agent_name: None, managed_agent_kind: None, agent_session: None, + runtime_binding: None, launch_argv: None, }, ), @@ -1432,6 +1503,7 @@ mod tests { agent_name: None, managed_agent_kind: None, agent_session: None, + runtime_binding: None, launch_argv: None, }, ), @@ -1447,6 +1519,7 @@ mod tests { sidebar_width: None, sidebar_section_split: None, collapsed_space_keys: Default::default(), + runtime_binding_owners: Default::default(), }; let (events, _event_rx) = mpsc::channel(4); @@ -1485,6 +1558,7 @@ mod tests { agent_name: None, managed_agent_kind: None, agent_session: None, + runtime_binding: None, launch_argv: None, }, ) @@ -1500,6 +1574,7 @@ mod tests { kind: crate::agent_resume::AgentSessionRefKind::Id, value: "codex-session".into(), }), + runtime_binding: None, launch_argv: None, }; let snapshot = SessionSnapshot { @@ -1554,6 +1629,7 @@ mod tests { sidebar_width: None, sidebar_section_split: None, collapsed_space_keys: Default::default(), + runtime_binding_owners: Default::default(), }; let (events, _event_rx) = mpsc::channel(4); @@ -1651,6 +1727,7 @@ mod tests { kind: crate::agent_resume::AgentSessionRefKind::Id, value: "codex-session".into(), }), + runtime_binding: None, launch_argv: None, }, )]), @@ -1665,6 +1742,7 @@ mod tests { sidebar_width: None, sidebar_section_split: None, collapsed_space_keys: Default::default(), + runtime_binding_owners: Default::default(), }; let (events, _event_rx) = mpsc::channel(4); @@ -1854,6 +1932,7 @@ mod tests { agent_name: None, managed_agent_kind: None, agent_session: None, + runtime_binding: None, launch_argv: None, }, ); @@ -1902,8 +1981,85 @@ mod tests { sidebar_width: Some(26), sidebar_section_split: Some(0.5), collapsed_space_keys: Default::default(), + runtime_binding_owners: Default::default(), }; history.layout_fingerprint = super::super::snapshot::layout_fingerprint(&snapshot); (snapshot, history) } + #[test] + fn runtime_attachment_restore_uses_exact_native_argv_over_bare_session() { + let location = test_session_path("native-project"); + let attachment = crate::api::schema::RuntimeAttachment { + provider: "shuvcode".into(), + home_id: "home-a".into(), + session_id: "ses_exact".into(), + location: location.clone(), + host_id: "local".into(), + attach_argv: [ + test_session_path("bun"), + "--no-env-file".into(), + "--preload".into(), + test_session_path("preload.js"), + test_session_path("index.ts"), + "supervisor".into(), + "attach".into(), + "--home".into(), + test_session_path("native-home"), + "--home-id".into(), + "home-a".into(), + "--session".into(), + "ses_exact".into(), + "--location".into(), + location, + ] + .into(), + }; + let mut binding = crate::api::schema::RuntimeBinding { + pane_id: "w1:p1".into(), + binding_id: "binding-a".into(), + attachment, + seq: Some(9), + state: crate::api::schema::RuntimeState::Working, + label: None, + ttl_ms: 30_000, + fresh: true, + }; + let session = PaneAgentSessionSnapshot { + source: "herdr:shuvcode".into(), + agent: "shuvcode".into(), + kind: crate::agent_resume::AgentSessionRefKind::Id, + value: "ses_wrong".into(), + }; + let mut resumed = HashSet::new(); + let mut state = AgentRestoreState { + enabled: false, + resumed_sessions: &mut resumed, + }; + let startup = pane_restore_startup(Some(&session), Some(&binding), None, &mut state); + let reservation = startup.reserved_agent_session.clone().unwrap(); + assert_eq!( + startup.restore_plan.unwrap().argv, + binding.attachment.attach_argv + ); + let duplicate = pane_restore_startup(Some(&session), Some(&binding), None, &mut state); + assert!(duplicate.restore_plan.is_none()); + assert!(duplicate.reserved_agent_session.is_none()); + state.resumed_sessions.remove(&reservation); + let retry = pane_restore_startup(Some(&session), Some(&binding), None, &mut state); + assert!(retry.restore_plan.is_some()); + binding.attachment.attach_argv.push("--password".into()); + state.enabled = true; + let history = PaneHistorySnapshot { + ansi: "KEEP_HISTORY\r\n".into(), + lines: 1, + }; + let rejected = + pane_restore_startup(Some(&session), Some(&binding), Some(&history), &mut state); + assert_eq!(rejected.initial_history_ansi, Some("KEEP_HISTORY\r\n")); + assert!(rejected.restore_error.is_some()); + assert!( + rejected.restore_plan.is_none(), + "invalid exact attachment must not fall back to bare Session resume" + ); + } } diff --git a/src/persist/snapshot.rs b/src/persist/snapshot.rs index d7db3e97748..727d4188453 100644 --- a/src/persist/snapshot.rs +++ b/src/persist/snapshot.rs @@ -26,6 +26,8 @@ pub struct SessionSnapshot { pub sidebar_section_split: Option, #[serde(default)] pub collapsed_space_keys: std::collections::HashSet, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub runtime_binding_owners: HashMap, } #[derive(Serialize, Deserialize)] @@ -108,6 +110,8 @@ pub struct PaneSnapshot { #[serde(default, skip_serializing_if = "Option::is_none")] pub agent_session: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime_binding: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub launch_argv: Option>, } @@ -186,6 +190,8 @@ struct RawSessionSnapshot { sidebar_section_split: Option, #[serde(default)] collapsed_space_keys: std::collections::HashSet, + #[serde(default)] + runtime_binding_owners: HashMap, } fn migrate_snapshot(raw: RawSessionSnapshot) -> Result { @@ -201,6 +207,7 @@ fn migrate_snapshot(raw: RawSessionSnapshot) -> Result sidebar_width: raw.sidebar_width, sidebar_section_split: raw.sidebar_section_split, collapsed_space_keys: raw.collapsed_space_keys, + runtime_binding_owners: raw.runtime_binding_owners, }) } @@ -272,6 +279,7 @@ pub fn capture( sidebar_width: None, sidebar_section_split: None, collapsed_space_keys: std::collections::HashSet::new(), + runtime_binding_owners: Default::default(), } } @@ -370,6 +378,7 @@ fn capture_tab( agent_name, managed_agent_kind, agent_session, + runtime_binding: terminal.and_then(|terminal| terminal.runtime_binding.clone()), launch_argv, }, ); @@ -646,6 +655,7 @@ mod tests { sidebar_width: Some(26), sidebar_section_split: Some(0.5), collapsed_space_keys: std::collections::HashSet::new(), + runtime_binding_owners: Default::default(), }; let json = serde_json::to_string(&snap).unwrap(); let restored = parse_snapshot(&json).unwrap(); @@ -688,6 +698,7 @@ mod tests { agent_name: None, managed_agent_kind: None, agent_session: None, + runtime_binding: None, launch_argv: None, }, ); @@ -699,6 +710,7 @@ mod tests { agent_name: None, managed_agent_kind: None, agent_session: None, + runtime_binding: None, launch_argv: None, }, ); @@ -733,6 +745,7 @@ mod tests { sidebar_width: Some(26), sidebar_section_split: Some(0.5), collapsed_space_keys: std::collections::HashSet::new(), + runtime_binding_owners: Default::default(), version: SNAPSHOT_VERSION, }; @@ -1350,6 +1363,7 @@ mod tests { agent_name: None, managed_agent_kind: None, agent_session: None, + runtime_binding: None, launch_argv: None, }, ); @@ -1363,6 +1377,7 @@ mod tests { agent_name: None, managed_agent_kind: None, agent_session: None, + runtime_binding: None, launch_argv: None, }, ); @@ -1398,6 +1413,7 @@ mod tests { sidebar_width: Some(26), sidebar_section_split: Some(0.5), collapsed_space_keys: std::collections::HashSet::new(), + runtime_binding_owners: Default::default(), }; let json = serde_json::to_string(&snap).unwrap(); diff --git a/src/protocol/endpoint.rs b/src/protocol/endpoint.rs index cc3d6cf4928..36462d6759e 100644 --- a/src/protocol/endpoint.rs +++ b/src/protocol/endpoint.rs @@ -38,6 +38,9 @@ pub struct EndpointAgentCompletions { pub boot_id: String, pub revision: u64, pub completions: std::collections::BTreeMap, + /// Optional runtime authority, independent of each client's acknowledgement. + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub managed_statuses: std::collections::BTreeMap, } fn default_true() -> bool { diff --git a/src/runtime_attachment.rs b/src/runtime_attachment.rs new file mode 100644 index 00000000000..844c68cad70 --- /dev/null +++ b/src/runtime_attachment.rs @@ -0,0 +1,207 @@ +use std::path::Path; +use std::time::{Duration, Instant}; + +use crate::api::schema::{RuntimeAttachment, RuntimeBinding, RuntimeState}; +use crate::detect::AgentState; +use crate::terminal::{TerminalState, TerminalStateMutation}; + +pub(crate) const METHODS: &[&str] = &[ + "pane.bind_runtime", + "pane.get_runtime", + "pane.report_runtime", + "pane.unbind_runtime", +]; +pub(crate) const DEFAULT_TTL_MS: u64 = 30_000; + +impl RuntimeAttachment { + // Only the read-only native attachment entrypoint may be persisted for restore. + // The local command API already grants terminal input authority. This validates + // the trusted caller's attach descriptor shape, not executable/script contents. + // Source and compiled commands remain caller-owned; credential flags are excluded. + pub(crate) fn validate(&self) -> Result<(), &'static str> { + if self.provider != "shuvcode" + || [ + self.home_id.as_str(), + self.session_id.as_str(), + self.host_id.as_str(), + ] + .iter() + .any(|value| { + value.is_empty() || value.len() > 512 || value.chars().any(char::is_control) + }) + || !Path::new(&self.location).is_absolute() + || self + .attach_argv + .iter() + .any(|arg| arg.is_empty() || arg.len() > 4096 || arg.chars().any(char::is_control)) + { + return Err("invalid runtime attachment identity"); + } + let Some(at) = self + .attach_argv + .windows(2) + .position(|args| args == ["supervisor", "attach"]) + else { + return Err("restore requires exact supervisor attach argv"); + }; + let prefix = &self.attach_argv[..at]; + let absolute = |value: &String| Path::new(value).is_absolute(); + let valid_prefix = match prefix { + [executable] => absolute(executable), + [bun, entrypoint] => absolute(bun) && absolute(entrypoint), + [bun, no_env, preload_flag, preload, entrypoint] => { + absolute(bun) + && no_env == "--no-env-file" + && preload_flag == "--preload" + && absolute(preload) + && absolute(entrypoint) + } + _ => false, + }; + let suffix = &self.attach_argv[at + 2..]; + if !valid_prefix + || suffix.len() != 8 + || suffix[0] != "--home" + || !Path::new(&suffix[1]).is_absolute() + || suffix[2] != "--home-id" + || suffix[3] != self.home_id + || suffix[4] != "--session" + || suffix[5] != self.session_id + || suffix[6] != "--location" + || suffix[7] != self.location + { + return Err("restore requires exact credential-free native attach argv"); + } + Ok(()) + } +} + +impl RuntimeState { + pub(crate) fn detect(self) -> AgentState { + match self { + Self::Idle | Self::Done => AgentState::Idle, + Self::Working => AgentState::Working, + Self::Blocked => AgentState::Blocked, + Self::Unknown => AgentState::Unknown, + } + } + pub(crate) fn label(self) -> &'static str { + match self { + Self::Idle => "idle", + Self::Working => "working", + Self::Blocked => "blocked", + Self::Done => "done", + Self::Unknown => "unknown", + } + } +} + +impl TerminalState { + pub(crate) fn runtime_agent_status(&self, seen: bool) -> crate::api::schema::AgentStatus { + if let Some(binding) = &self.runtime_binding { + if self + .runtime_expiry() + .is_none_or(|deadline| Instant::now() >= deadline) + { + return crate::api::schema::AgentStatus::Unknown; + } + return match binding.state { + RuntimeState::Idle => crate::api::schema::AgentStatus::Idle, + RuntimeState::Working => crate::api::schema::AgentStatus::Working, + RuntimeState::Blocked => crate::api::schema::AgentStatus::Blocked, + RuntimeState::Done => crate::api::schema::AgentStatus::Done, + RuntimeState::Unknown => crate::api::schema::AgentStatus::Unknown, + }; + } + crate::app::api_helpers::pane_agent_status(self.state, seen) + } + + // Scalar-only projection for existing internal attention/status paths. + // The pane's actual acknowledgement remains independent of managed state. + pub(crate) fn runtime_presentation_seen(&self, seen: bool) -> bool { + let Some(binding) = &self.runtime_binding else { + return seen; + }; + if self + .runtime_expiry() + .is_none_or(|deadline| Instant::now() >= deadline) + { + return seen; + } + match binding.state { + RuntimeState::Idle => true, + RuntimeState::Done => false, + _ => seen, + } + } + + pub(crate) fn runtime_expiry(&self) -> Option { + Some( + self.runtime_observed_at? + + Duration::from_millis(self.runtime_binding.as_ref()?.ttl_ms), + ) + } + + pub(crate) fn runtime_state_at(&self, now: Instant) -> AgentState { + if let Some(binding) = &self.runtime_binding { + if self.runtime_expiry().is_some_and(|deadline| now < deadline) { + return binding.state.detect(); + } + } + AgentState::Unknown + } + + pub(crate) fn runtime_binding_snapshot(&self, now: Instant) -> Option { + let mut binding = self.runtime_binding.clone()?; + binding.fresh = self.runtime_expiry().is_some_and(|deadline| now < deadline); + if !binding.fresh { + binding.state = RuntimeState::Unknown; + binding.label = None; + } + Some(binding) + } + + pub(crate) fn mutate_runtime_binding( + &mut self, + binding: Option, + observed_at: Option, + ) -> TerminalStateMutation { + let previous = self.unchanged_effective_state_change_at(Instant::now()); + let identity_changed = self + .runtime_binding + .as_ref() + .map(|binding| (&binding.binding_id, &binding.attachment)) + != binding + .as_ref() + .map(|binding| (&binding.binding_id, &binding.attachment)); + if binding.is_none() && self.runtime_binding.is_some() { + self.pending_agent_resume_plan = None; + } + self.runtime_binding = binding; + self.runtime_observed_at = observed_at; + TerminalStateMutation { + effective_state_change: self.recompute_effective_state( + previous.agent_label, + previous.known_agent, + previous.state, + previous.presentation, + Instant::now(), + ), + session_ref_changed: identity_changed, + agent_released: false, + } + } + + pub(crate) fn restore_runtime_binding(&mut self, binding: Option<&RuntimeBinding>) { + self.runtime_binding = binding.cloned().map(|mut binding| { + binding.state = RuntimeState::Unknown; + binding.label = None; + binding.fresh = false; + binding + }); + self.runtime_observed_at = None; + if self.runtime_binding.is_some() { + self.state = AgentState::Unknown; + } + } +} diff --git a/src/server/client_commands.rs b/src/server/client_commands.rs index 87824c85d6a..e53a73274d4 100644 --- a/src/server/client_commands.rs +++ b/src/server/client_commands.rs @@ -18,6 +18,7 @@ const CLIENT_SHELL_METHODS: &[&str] = &[ "integration.install", "integration.list", "layout.set_split_ratio", + "pane.bind_runtime", "pane.clear", "pane.close", "pane.copy_motion", @@ -25,15 +26,18 @@ const CLIENT_SHELL_METHODS: &[&str] = &[ "pane.edit_scrollback", "pane.focus", "pane.focus_direction", + "pane.get_runtime", "pane.input.set", "pane.link.activate", "pane.link.resolve", "pane.rename", + "pane.report_runtime", "pane.resize", "pane.scroll", "pane.selection.read", "pane.split", "pane.swap", + "pane.unbind_runtime", "pane.zoom", "product_announcement.dismiss", "release_notes.dismiss", @@ -298,6 +302,22 @@ mod tests { Some("f5e4a3e01453ae7b188f127ce951c12c20e0bebcc17cc364eeb6d1a01fd5bf81") ); + assert_eq!( + actual.remove("pane.bind_runtime").as_deref(), + Some("d8844d81243a877e09787c33bebe233150b5b606a10b3059f2d995fe3a1d6522") + ); + assert_eq!( + actual.remove("pane.get_runtime").as_deref(), + Some("27d42b0cbf27f53793f0ccb05e07551667e06a1164327cc61d80a16d7ce08e76") + ); + assert_eq!( + actual.remove("pane.report_runtime").as_deref(), + Some("b31b389ad16d208726cb21c7ec94e9b765cfdc6fc3405578e094710554c62215") + ); + assert_eq!( + actual.remove("pane.unbind_runtime").as_deref(), + Some("dce43dc8cb245e22535169dc4b454d72506ade1ea137663af0966a05c1d97df5") + ); assert_eq!( actual, expected, "an existing endpoint method changed shape; add load-bearing behavior as a new advertised method or explicitly gate new fields" diff --git a/src/server/client_shell.rs b/src/server/client_shell.rs index 554e81677d7..ff3ed957445 100644 --- a/src/server/client_shell.rs +++ b/src/server/client_shell.rs @@ -33,6 +33,17 @@ pub(super) fn snapshot_with_completions( .iter() .filter_map(|agent| agent.completion_seq.map(|seq| (agent.pane_id.clone(), seq))) .collect(), + managed_statuses: app + .state + .terminals + .values() + .filter_map(|terminal| { + terminal + .runtime_binding + .as_ref() + .map(|binding| (binding.pane_id.clone(), terminal.runtime_agent_status(true))) + }) + .collect(), }; let focused_workspace_id = location .and_then(|location| location.focused_workspace_id.clone()) diff --git a/src/server/handoff.rs b/src/server/handoff.rs index aaa87f382ba..28245adddb9 100644 --- a/src/server/handoff.rs +++ b/src/server/handoff.rs @@ -538,6 +538,7 @@ mod tests { sidebar_width: None, sidebar_section_split: None, collapsed_space_keys: Default::default(), + runtime_binding_owners: Default::default(), } } diff --git a/src/server/headless/lifecycle.rs b/src/server/headless/lifecycle.rs index f7ad87dd97a..71d2dfc557f 100644 --- a/src/server/headless/lifecycle.rs +++ b/src/server/headless/lifecycle.rs @@ -69,13 +69,14 @@ impl HeadlessServer { } } - let snapshot = crate::persist::capture( + let mut snapshot = crate::persist::capture( &self.app.state.workspaces, &self.app.state.terminals, &self.app.terminal_runtimes, self.app.state.active, self.app.state.selected, ); + snapshot.runtime_binding_owners = self.app.state.runtime_binding_owners.clone(); let mut handoff_entries = Vec::new(); for (terminal_id, runtime) in self.app.terminal_runtimes.iter() { diff --git a/src/terminal/metadata.rs b/src/terminal/metadata.rs index e82a5a3af9b..3270c57f2be 100644 --- a/src/terminal/metadata.rs +++ b/src/terminal/metadata.rs @@ -308,6 +308,9 @@ impl TerminalState { pub fn next_agent_metadata_expiry(&self) -> Option { let now = Instant::now(); + let runtime_deadline = self + .runtime_expiry() + .filter(|_| self.state != AgentState::Unknown); self.agent_metadata .values() .filter(|metadata| self.agent_metadata_matches_guards(metadata)) @@ -320,6 +323,7 @@ impl TerminalState { && self.agent_metadata_expiry(metadata) == Some(*deadline) }) }) + .chain(runtime_deadline) .min() } @@ -347,7 +351,11 @@ impl TerminalState { for source in stale_sources { self.agent_metadata.remove(&source); } - if expired_sources.is_empty() { + let runtime_expired = self + .runtime_expiry() + .is_some_and(|deadline| deadline <= now) + && self.state != AgentState::Unknown; + if expired_sources.is_empty() && !runtime_expired { return None; } @@ -402,6 +410,16 @@ impl TerminalState { presentation.title = self.newest_metadata_title(now, enforce_ttl); presentation.display_agent = self.newest_metadata_display_agent(now, enforce_ttl); presentation.state_labels = self.effective_metadata_state_labels(now, enforce_ttl); + if let Some(binding) = &self.runtime_binding { + presentation.state_labels.clear(); + if self.runtime_state_at(now) != AgentState::Unknown { + if let Some(label) = &binding.label { + presentation + .state_labels + .insert(binding.state.label().into(), label.clone()); + } + } + } presentation } diff --git a/src/terminal/state.rs b/src/terminal/state.rs index be7f4048ba0..75b2df5b3b9 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -131,6 +131,8 @@ pub struct TerminalState { pub hook_authority: Option, pub agent_metadata: HashMap, pub metadata_tokens: crate::metadata_tokens::MetadataTokens, + pub runtime_binding: Option, + pub runtime_observed_at: Option, pub persisted_agent_session: Option, pub terminal_title: Option, pub manual_label: Option, @@ -169,6 +171,8 @@ impl TerminalState { hook_authority: None, agent_metadata: HashMap::new(), metadata_tokens: crate::metadata_tokens::MetadataTokens::default(), + runtime_binding: None, + runtime_observed_at: None, persisted_agent_session: None, terminal_title: None, manual_label: None, @@ -689,6 +693,9 @@ impl TerminalState { seq: Option, now: Instant, ) -> Option { + if self.runtime_binding.is_some() { + return None; + } if crate::detect::session_identity_only_integration(&source, &agent_label) { return None; } @@ -1463,6 +1470,9 @@ impl TerminalState { seq: Option, session_start_source: Option, ) -> Option { + if self.runtime_binding.is_some() { + return None; + } let session_ref = session_ref?; let known_agent = crate::detect::parse_agent_label(&agent_label); if self.known_agent_label_conflicts_with_detected_agent(&agent_label) { @@ -1897,6 +1907,9 @@ impl TerminalState { } pub fn effective_agent_label(&self) -> Option<&str> { + if let Some(binding) = &self.runtime_binding { + return Some(&binding.attachment.provider); + } self.hook_authority .as_ref() .filter(|authority| self.hook_authority_is_effective(authority)) @@ -2033,6 +2046,12 @@ impl TerminalState { }) } + pub(crate) fn set_managed_agent_distribution_label(&mut self, label: Option) { + if let (Some(label), Some(owner)) = (label, self.agent_name_owner.as_mut()) { + owner.agent_label = label; + } + } + pub fn managed_agent_interactive_ready(&self) -> bool { self.managed_agent .is_some_and(|managed| matches!(managed.phase, ManagedAgentPhase::Active)) @@ -2241,7 +2260,7 @@ impl TerminalState { }) } - fn recompute_effective_state( + pub(crate) fn recompute_effective_state( &mut self, previous_agent_label: Option, previous_known_agent: Option, @@ -2249,7 +2268,9 @@ impl TerminalState { previous_presentation: EffectivePresentation, now: Instant, ) -> Option { - let state = if self.visible_blocker_overrides_hook() { + let state = if self.runtime_binding.is_some() { + self.runtime_state_at(now) + } else if self.visible_blocker_overrides_hook() { AgentState::Blocked } else { self.hook_authority @@ -2364,6 +2385,28 @@ mod tests { assert_eq!(terminal.agent_name, None); } + #[test] + fn managed_agent_retains_normalized_distribution_name_owner() { + let mut terminal = test_terminal(); + terminal.begin_managed_agent( + "native".into(), + Agent::OpenCode, + Instant::now(), + Duration::ZERO, + Duration::from_secs(1), + ); + terminal.set_managed_agent_distribution_label(crate::detect::distinct_distribution_label( + Agent::OpenCode, + "Shuvcode", + )); + terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle); + assert_eq!(terminal.agent_name.as_deref(), Some("native")); + assert_eq!( + terminal.agent_name_owner.as_ref().unwrap().agent_label, + "shuvcode" + ); + } + #[test] fn managed_agent_mismatch_and_timeout_release_name() { let now = Instant::now(); diff --git a/src/update.rs b/src/update.rs index 939fe669766..da76deafe14 100644 --- a/src/update.rs +++ b/src/update.rs @@ -2947,6 +2947,10 @@ mod tests { version: Some("0.5.5".to_string()), protocol: Some(2), capabilities: Some(crate::api::schema::ServerCapabilities { + runtime_attachment_methods: crate::runtime_attachment::METHODS + .iter() + .map(|method| (*method).into()) + .collect(), live_handoff: true, direct_control_yields_to_shell: false, detached_server_daemon: true, @@ -3023,6 +3027,10 @@ mod tests { version: Some("0.6.2".to_string()), protocol: Some(76), capabilities: Some(crate::api::schema::ServerCapabilities { + runtime_attachment_methods: crate::runtime_attachment::METHODS + .iter() + .map(|method| (*method).into()) + .collect(), live_handoff: true, direct_control_yields_to_shell: false, detached_server_daemon: true, @@ -3283,6 +3291,10 @@ mod tests { version: Some("9.8.6".to_string()), protocol: Some(76), capabilities: Some(crate::api::schema::ServerCapabilities { + runtime_attachment_methods: crate::runtime_attachment::METHODS + .iter() + .map(|method| (*method).into()) + .collect(), live_handoff: true, direct_control_yields_to_shell: false, detached_server_daemon: true, diff --git a/src/workspace.rs b/src/workspace.rs index 8944b884f73..92dac7f79f5 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -149,9 +149,13 @@ pub(crate) fn public_tab_id_for_number(workspace_id: &str, tab_number: usize) -> } pub(crate) fn reserve_workspace_ids(workspaces: &[Workspace]) { - let Some(next) = workspaces - .iter() - .filter_map(|workspace| public_workspace_number(&workspace.id)) + reserve_public_workspace_ids(workspaces.iter().map(|workspace| workspace.id.as_str())); +} + +pub(crate) fn reserve_public_workspace_ids<'a>(ids: impl IntoIterator) { + let Some(next) = ids + .into_iter() + .filter_map(public_workspace_number) .max() .and_then(|max| u64::try_from(max.checked_add(1)?).ok()) else { diff --git a/src/workspace/aggregate.rs b/src/workspace/aggregate.rs index 3fd45fcafba..ecdc99f3c85 100644 --- a/src/workspace/aggregate.rs +++ b/src/workspace/aggregate.rs @@ -66,9 +66,12 @@ impl Workspace { .iter() .flat_map(|tab| tab.panes.values()) .filter_map(|pane| { - terminals - .get(&pane.attached_terminal_id) - .map(|terminal| (terminal.state, pane.seen)) + terminals.get(&pane.attached_terminal_id).map(|terminal| { + ( + terminal.state, + terminal.runtime_presentation_seen(pane.seen), + ) + }) }) .max_by_key(|(state, seen)| pane_attention_priority(*state, *seen)) .unwrap_or((AgentState::Unknown, true)) diff --git a/tests/api_ping.rs b/tests/api_ping.rs index 848059bdfeb..c38e2c7dac5 100644 --- a/tests/api_ping.rs +++ b/tests/api_ping.rs @@ -314,6 +314,7 @@ fn ping_over_socket_returns_version() { assert_eq!(value["id"], "req_1"); assert_eq!(value["result"]["type"], "pong"); assert_eq!(value["result"]["version"], expected_build_version()); + assert_eq!(value["result"]["session_name"], "default"); // Intentionally hardcoded so wire protocol bumps require updating this test. // Changing this value means old clients/servers are no longer compatible. assert_eq!(value["result"]["protocol"], 22); @@ -2623,3 +2624,266 @@ fn metadata_status_subscription_filter_and_ttl_expiry_are_observable() { cleanup_spawned_herdr(child, base); } + +fn spawn_native_runtime_test(base: &Path) -> (SpawnedHerdr, PathBuf) { + let config = base.join("config"); + let runtime = base.join("runtime"); + fs::create_dir_all(config.join("herdr-dev")).unwrap(); + fs::create_dir_all(&runtime).unwrap(); + fs::write(config.join("herdr-dev/config.toml"), "onboarding = false\n").unwrap(); + register_runtime_dir(&runtime); + let pair = native_pty_system() + .openpty(PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + }) + .unwrap(); + let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); + cmd.env_clear(); + cmd.args(["--session", "native-runtime-test", "server"]); + cmd.env("HOME", base.join("home")); + cmd.env("XDG_CONFIG_HOME", &config); + cmd.env("XDG_DATA_HOME", base.join("data")); + cmd.env("XDG_STATE_HOME", base.join("state")); + cmd.env("XDG_CACHE_HOME", base.join("cache")); + cmd.env("XDG_RUNTIME_DIR", &runtime); + cmd.env( + "PATH", + format!("{}:/usr/bin:/bin", base.join("bin").display()), + ); + cmd.env("SHELL", "/bin/sh"); + cmd.env("TERM", "xterm-256color"); + cmd.cwd(base); + let child = pair.slave.spawn_command(cmd).unwrap(); + register_spawned_herdr_pid(child.process_id()); + ( + SpawnedHerdr { + _master: pair.master, + child, + }, + config.join("herdr-dev/sessions/native-runtime-test/herdr.sock"), + ) +} + +#[test] +fn native_runtime_attachment_public_json_persists_identity_and_rejects_stale_reports() { + let _lock = test_lock(); + let base = unique_test_dir(); + fs::create_dir_all(&base).unwrap(); + let (mut child, socket) = spawn_native_runtime_test(&base); + wait_for_socket(&socket, Duration::from_secs(8)); + let request = |method: &str, params: serde_json::Value| { + send_request( + &socket, + &serde_json::json!({"id":"native", "method":method, "params":params}).to_string(), + ) + }; + let ping = request("ping", serde_json::json!({})); + assert_eq!(ping["result"]["session_name"], "native-runtime-test"); + for method in [ + "pane.bind_runtime", + "pane.get_runtime", + "pane.report_runtime", + "pane.unbind_runtime", + ] { + assert!(ping["result"]["capabilities"]["runtime_attachment_methods"] + .as_array() + .unwrap() + .iter() + .any(|value| value == method)); + } + let workspace = request( + "workspace.create", + serde_json::json!({"cwd":base,"focus":false}), + ); + let pane = workspace["result"]["root_pane"]["pane_id"] + .as_str() + .unwrap(); + let attachment = serde_json::json!({"provider":"shuvcode", "home_id":"home-a", "session_id":"ses_a", "location":base, "host_id":"local", "attach_argv":["/bin/shuvcode", "supervisor", "attach", "--home",base.join("native-home"), "--home-id","home-a","--session","ses_a","--location",base]}); + let bind = serde_json::json!({"pane_id":pane,"binding_id":"binding-a","attachment":attachment}); + assert_eq!( + request("pane.bind_runtime", bind.clone())["result"]["applied"], + true + ); + assert_eq!( + request("pane.bind_runtime", bind.clone())["result"]["applied"], + true + ); + let other_workspace = request( + "workspace.create", + serde_json::json!({"cwd":base,"focus":false}), + ); + let other_pane = &other_workspace["result"]["root_pane"]["pane_id"]; + let mut collision = bind.clone(); + collision["pane_id"] = other_pane.clone(); + assert_eq!( + request("pane.bind_runtime", collision)["error"]["code"], + "runtime_binding_conflict" + ); + let report = serde_json::json!({"pane_id":pane,"binding_id":"binding-a","seq":9,"state":"working","ttl_ms":50}); + assert_eq!( + request("pane.report_runtime", report.clone())["result"]["applied"], + true + ); + assert_eq!( + request("pane.report_runtime", report.clone())["result"]["applied"], + false + ); + let mut foreign = report.clone(); + foreign["binding_id"] = "foreign".into(); + foreign["seq"] = 10.into(); + assert_eq!( + request("pane.report_runtime", foreign)["result"]["applied"], + false + ); + request( + "pane.report_agent", + serde_json::json!({"pane_id":pane,"source":"custom:tui","agent":"pi","state":"blocked","seq":999}), + ); + let deadline = Instant::now() + Duration::from_secs(2); + loop { + let current = request("pane.get_runtime", serde_json::json!({"pane_id":pane})); + if current["result"]["binding"]["state"] == "unknown" { + break; + } + assert!(Instant::now() < deadline); + thread::sleep(Duration::from_millis(20)); + } + assert_eq!( + request("pane.get", serde_json::json!({"pane_id":pane}))["result"]["pane"]["agent_status"], + "unknown" + ); + request("server.stop", serde_json::json!({})); + child.child.wait().unwrap(); + drop(child); + let (mut restarted, restarted_socket) = spawn_native_runtime_test(&base); + wait_for_socket(&restarted_socket, Duration::from_secs(8)); + let restored = send_request( + &restarted_socket, + &serde_json::json!({"id":"restored","method":"pane.get_runtime","params":{"pane_id":pane}}) + .to_string(), + ); + assert_eq!(restored["result"]["binding"]["seq"], 9); + assert_eq!(restored["result"]["binding"]["state"], "unknown"); + assert_eq!(restored["result"]["binding"]["attachment"], attachment); + let stale = send_request( + &restarted_socket, + &serde_json::json!({"id":"stale","method":"pane.report_runtime","params":report}) + .to_string(), + ); + assert_eq!(stale["result"]["applied"], false); + let snapshot: serde_json::Value = serde_json::from_slice( + &fs::read(base.join("config/herdr-dev/sessions/native-runtime-test/session.json")).unwrap(), + ) + .unwrap(); + assert_eq!( + snapshot["runtime_binding_owners"]["binding-a"]["pane_id"], + pane + ); + send_request( + &restarted_socket, + r#"{"id":"stop","method":"server.stop","params":{}}"#, + ); + restarted.child.wait().unwrap(); + cleanup_spawned_herdr(restarted, base); +} + +#[test] +fn native_runtime_attachment_closed_owner_reserves_identity_after_empty_restart() { + let _lock = test_lock(); + let base = unique_test_dir(); + fs::create_dir_all(&base).unwrap(); + let (mut child, socket) = spawn_native_runtime_test(&base); + wait_for_socket(&socket, Duration::from_secs(8)); + let request = |socket: &Path, method: &str, params: serde_json::Value| { + send_request( + socket, + &serde_json::json!({"id":"closed-owner", "method":method, "params":params}).to_string(), + ) + }; + let workspace = request( + &socket, + "workspace.create", + serde_json::json!({"cwd":base,"focus":false}), + ); + let pane = workspace["result"]["root_pane"]["pane_id"] + .as_str() + .unwrap(); + let attachment = serde_json::json!({"provider":"shuvcode", "home_id":"home-a", "session_id":"ses_a", "location":base, "host_id":"local", "attach_argv":["/bin/shuvcode", "supervisor", "attach", "--home",base.join("native-home"), "--home-id","home-a","--session","ses_a","--location",base]}); + let mut bind = + serde_json::json!({"pane_id":pane,"binding_id":"closed-binding","attachment":attachment}); + assert_eq!( + request(&socket, "pane.bind_runtime", bind.clone())["result"]["applied"], + true + ); + request(&socket, "pane.close", serde_json::json!({"pane_id":pane})); + request(&socket, "server.stop", serde_json::json!({})); + child.child.wait().unwrap(); + drop(child); + let (mut restarted, socket) = spawn_native_runtime_test(&base); + wait_for_socket(&socket, Duration::from_secs(8)); + let replacement = request( + &socket, + "workspace.create", + serde_json::json!({"cwd":base,"focus":false}), + ); + let replacement_pane = &replacement["result"]["root_pane"]["pane_id"]; + assert_ne!(replacement_pane, pane); + bind["pane_id"] = replacement_pane.clone(); + assert_eq!( + request(&socket, "pane.bind_runtime", bind)["error"]["code"], + "runtime_binding_conflict" + ); + request(&socket, "server.stop", serde_json::json!({})); + restarted.child.wait().unwrap(); + cleanup_spawned_herdr(restarted, base); +} + +#[test] +fn native_runtime_attachment_agent_start_preserves_shuvcode_distribution() { + use std::os::unix::fs::PermissionsExt; + let _lock = test_lock(); + let base = unique_test_dir(); + fs::create_dir_all(base.join("bin")).unwrap(); + for name in ["shuvcode", "opencode"] { + let executable = base.join("bin").join(name); + fs::write( + &executable, + format!( + "#!/bin/sh\nprintf '%s' '{name}' > '{}'\nsleep 30\n", + base.join("invoked").display() + ), + ) + .unwrap(); + fs::set_permissions(executable, fs::Permissions::from_mode(0o755)).unwrap(); + } + let (mut child, socket) = spawn_native_runtime_test(&base); + wait_for_socket(&socket, Duration::from_secs(8)); + let request = |method: &str, params: serde_json::Value| { + send_request( + &socket, + &serde_json::json!({"id":"native-start", "method":method, "params":params}).to_string(), + ) + }; + let workspace = request( + "workspace.create", + serde_json::json!({"cwd":base,"focus":false}), + ); + let pane = &workspace["result"]["root_pane"]["pane_id"]; + thread::sleep(Duration::from_millis(500)); + let started = request( + "agent.start", + serde_json::json!({"pane_id":pane,"name":"native-test","kind":"Shuvcode"}), + ); + assert_eq!(started["result"]["argv"][0], "shuvcode", "{started}"); + support::wait_for_file(&base.join("invoked"), Duration::from_secs(3)); + assert_eq!( + fs::read_to_string(base.join("invoked")).unwrap(), + "shuvcode" + ); + request("server.stop", serde_json::json!({})); + child.child.wait().unwrap(); + cleanup_spawned_herdr(child, base); +}