diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index b26cfc80..93a3e0b0 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed - Pane applications that query OSC 4 palette colors now inherit the host terminal palette. (#1752) - Ctrl-clicking a pane URL no longer forwards an unmatched mouse release to alternate-screen applications, preventing duplicate browser tabs. (#1761) +- Known-agent integrations now leave pane ownership to confirmed process exit, so restarting Pi with the same saved session restores lifecycle state even with custom working UI. (#1792) - OMP integration install, status, and uninstall now respect `PI_CONFIG_DIR` when `PI_CODING_AGENT_DIR` is not set, and installation refuses extension-directory collisions with Pi. (#1696) - Physical Escape key records on native Windows now bypass raw VT report framing, so pane applications receive Escape immediately and reliably. (#1736) diff --git a/src/agent_resume.rs b/src/agent_resume.rs index facb9cf1..1cb0f9dd 100644 --- a/src/agent_resume.rs +++ b/src/agent_resume.rs @@ -203,7 +203,7 @@ pub fn dedupe_key(source: &str, agent: &str, session_ref: &AgentSessionRef) -> S ) } -fn is_official_agent_source(source: &str, agent: &str) -> bool { +pub(crate) fn is_official_agent_source(source: &str, agent: &str) -> bool { matches!( (source, agent), ("herdr:claude", "claude") diff --git a/src/app/actions.rs b/src/app/actions.rs index b7d335b1..ff786846 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -2793,7 +2793,7 @@ impl AppState { seq, .. } => { - if crate::agent_resume::is_reserved_native_state_source(&source, &agent_label) { + if crate::agent_resume::is_official_agent_source(&source, &agent_label) { Vec::new() } else { self.update_terminal_state(pane_id, |terminal| { @@ -2890,8 +2890,16 @@ impl AppState { previous_state: change.previous_state, previous_seen, previous_presentation: change.previous_presentation.clone(), - agent_label: change.agent_label.clone(), - known_agent: change.known_agent, + agent_label: if agent_released { + change.previous_agent_label.clone() + } else { + change.agent_label.clone() + }, + known_agent: if agent_released { + change.previous_known_agent + } else { + change.known_agent + }, state: change.state, seen, presentation: change.presentation.clone(), @@ -3011,7 +3019,11 @@ impl AppState { return None; } - let agent_label = change.agent_label.clone()?; + let agent_label = change + .agent_label + .clone() + .or_else(|| change.previous_agent_label.clone())?; + let known_agent = change.known_agent.or(change.previous_known_agent); let kind = client_notification_kind.unwrap_or(match sound { Some(crate::sound::Sound::Request) => ToastKind::NeedsAttention, Some(crate::sound::Sound::Done) | None => ToastKind::Finished, @@ -3024,7 +3036,7 @@ impl AppState { pane_id, workspace_id, agent_label, - change.known_agent, + known_agent, kind, change.state, ); @@ -3036,7 +3048,7 @@ impl AppState { pane_id, workspace_id, agent_label, - known_agent: change.known_agent, + known_agent, kind, state: change.state, deadline: { @@ -3071,7 +3083,10 @@ impl AppState { if terminal_state.state != expected_state { return None; } - if terminal_state.effective_agent_label() != Some(agent_label.as_str()) { + if terminal_state + .effective_agent_label() + .is_some_and(|current| current != agent_label) + { return None; } @@ -4978,7 +4993,7 @@ mod tests { } #[test] - fn reserved_native_release_report_does_not_clear_screen_state() { + fn official_release_preserves_process_owned_agent_identity() { let mut state = app_with_workspaces(&["active"]); let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); let terminal_id = state.workspaces[0] @@ -4990,24 +5005,51 @@ mod tests { state.handle_app_event(AppEvent::StateChanged { pane_id, - agent: Some(Agent::Claude), + agent: Some(Agent::Pi), state: AgentState::Working, visible_blocker: false, visible_working: true, process_exited: false, observed_at: std::time::Instant::now(), }); - state.handle_app_event(AppEvent::HookAgentReleased { + let terminal = state.terminals.get_mut(&terminal_id).unwrap(); + terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { + source: "herdr:pi".into(), + agent: "pi".into(), + session_ref: crate::agent_resume::AgentSessionRef::path( + std::env::current_dir() + .unwrap() + .join("release-session.jsonl") + .display() + .to_string(), + ) + .unwrap(), + }); + terminal.set_hook_authority( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + Some(1), + ); + terminal.set_agent_name("reviewer".into()); + state.session_dirty = false; + + let updates = state.handle_app_event(AppEvent::HookAgentReleased { pane_id, - source: "herdr:claude".into(), - agent_label: "claude".into(), - known_agent: Some(Agent::Claude), - seq: Some(1), + source: "herdr:pi".into(), + agent_label: "pi".into(), + known_agent: Some(Agent::Pi), + seq: Some(2), }); - let terminal = state.terminals.get(&terminal_id).unwrap(); + assert!(updates.is_empty()); + let terminal = &state.terminals[&terminal_id]; assert_eq!(terminal.state, AgentState::Working); - assert_eq!(terminal.detected_agent, Some(Agent::Claude)); + assert_eq!(terminal.detected_agent, Some(Agent::Pi)); + assert_eq!(terminal.agent_name.as_deref(), Some("reviewer")); + assert!(terminal.full_lifecycle_hook_authority_active()); + assert!(!state.session_dirty); } #[test] @@ -5081,7 +5123,7 @@ mod tests { } #[test] - fn releasing_an_agent_alias_marks_the_session_dirty() { + fn custom_release_clears_report_owned_agent() { let mut state = app_with_workspaces(&["active"]); let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); let terminal_id = state.workspaces[0] @@ -5089,21 +5131,29 @@ mod tests { .unwrap() .attached_terminal_id .clone(); - let terminal = state.terminals.get_mut(&terminal_id).unwrap(); - terminal.set_detected_state(Some(Agent::Pi), AgentState::Working); - terminal.set_agent_name("reviewer".into()); - state.session_dirty = false; + state + .terminals + .get_mut(&terminal_id) + .unwrap() + .set_hook_authority( + "custom:agent".into(), + "custom-agent".into(), + AgentState::Working, + None, + Some(1), + ); state.handle_app_event(AppEvent::HookAgentReleased { pane_id, - source: "herdr:pi".into(), - agent_label: "pi".into(), - known_agent: Some(Agent::Pi), - seq: Some(1), + source: "custom:agent".into(), + agent_label: "custom-agent".into(), + known_agent: None, + seq: Some(2), }); - assert!(state.terminals[&terminal_id].agent_name.is_none()); - assert!(state.session_dirty); + let terminal = &state.terminals[&terminal_id]; + assert!(terminal.hook_authority.is_none()); + assert_eq!(terminal.state, AgentState::Unknown); } #[test] diff --git a/src/app/api.rs b/src/app/api.rs index 0cf55fd5..02a61067 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -1529,7 +1529,7 @@ mod tests { } #[tokio::test] - async fn agent_explain_reports_hook_only_full_lifecycle_authority() { + async fn agent_explain_rejects_hook_only_full_lifecycle_authority() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), @@ -1567,17 +1567,7 @@ mod tests { }); let response: serde_json::Value = serde_json::from_str(&response).unwrap(); - assert_eq!(response["result"]["type"], "agent_explain"); - assert_eq!(response["result"]["explain"]["agent"], "omp"); - assert_eq!(response["result"]["explain"]["state"], "working"); - assert_eq!( - response["result"]["explain"]["screen_detection_skip_reason"], - "full_lifecycle_hook_authority" - ); - assert_eq!( - response["result"]["explain"]["matched_rule"], - serde_json::Value::Null - ); + assert_eq!(response["error"]["code"], "agent_not_found"); } #[tokio::test] @@ -1941,7 +1931,7 @@ mod tests { } #[test] - fn stale_detector_exit_does_not_release_a_newer_hook_owned_agent() { + fn process_exit_releases_a_newer_hook_owned_agent() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( @@ -1983,9 +1973,9 @@ mod tests { }); let terminal = &app.state.terminals[&terminal_id]; - assert_eq!(terminal.state, AgentState::Working); - assert_eq!(terminal.agent_name.as_deref(), Some("reviewer")); - assert!(!event_hub.events_after(0).iter().any(|(_, event)| matches!( + assert_eq!(terminal.state, AgentState::Idle); + assert!(terminal.agent_name.is_none()); + assert!(event_hub.events_after(0).iter().any(|(_, event)| matches!( event.data, crate::api::schema::EventData::PaneAgentDetected { released: true, .. } ))); diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index 2da6d308..43206d1e 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -1357,9 +1357,21 @@ impl App { let Some(terminal) = self.state.terminals.get_mut(&terminal_id) else { return pane_not_found(id, ¶ms.pane_id); }; + if terminal.metadata_report_blocked_by_process_exit( + &source, + agent_label.as_deref(), + applies_to_source.as_deref(), + ) { + return encode_success(id, ResponseResult::Ok {}); + } if !terminal.metadata_report_sequence_is_fresh(&source, params.seq) { return encode_success(id, ResponseResult::Ok {}); } + let metadata_agent = crate::terminal::TerminalState::metadata_report_agent( + &source, + agent_label.as_deref(), + applies_to_source.as_deref(), + ); if let Some(tokens) = tokens.as_ref() { if terminal.metadata_tokens.key_count_after_patch(tokens) > MAX_METADATA_TOKEN_KEYS_PER_RESOURCE @@ -1373,7 +1385,8 @@ impl App { ); } } - match terminal.accept_metadata_report(&source, params.seq, tokens.is_some()) { + match terminal.accept_metadata_report(&source, params.seq, tokens.is_some(), metadata_agent) + { Ok(true) => {} Ok(false) => return encode_success(id, ResponseResult::Ok {}), Err(()) => { @@ -3818,6 +3831,133 @@ mod tests { .is_empty()); } + #[test] + fn pane_metadata_ignored_after_process_exit_does_not_poison_sequence() { + let (mut app, pane_id) = app_with_test_workspace(); + let (_, internal_pane_id) = app.parse_pane_id(&pane_id).unwrap(); + let terminal_id = app.state.workspaces[0] + .pane_state(internal_pane_id) + .unwrap() + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&terminal_id) + .unwrap() + .set_detected_state(Some(Agent::Pi), AgentState::Idle); + + let mut initial = metadata_params(pane_id.clone()); + initial.source = "custom:pi-metadata".into(); + initial.agent = Some("pi".into()); + initial.seq = Some(100); + let response = app.handle_pane_report_metadata("initial".into(), initial); + let _: SuccessResponse = serde_json::from_str(&response).unwrap(); + + let mut initial_tokens = metadata_params(pane_id.clone()); + initial_tokens.source = "custom:pi-tokens".into(); + initial_tokens.agent = Some("pi".into()); + initial_tokens.title = None; + initial_tokens.tokens = + std::collections::HashMap::from([("generation".into(), Some("old".into()))]); + initial_tokens.seq = Some(100); + let response = app.handle_pane_report_metadata("initial-tokens".into(), initial_tokens); + let _: SuccessResponse = serde_json::from_str(&response).unwrap(); + + let exit_at = std::time::Instant::now() + std::time::Duration::from_millis(1); + app.state + .terminals + .get_mut(&terminal_id) + .unwrap() + .set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Idle, + false, + false, + false, + true, + exit_at, + ); + app.state + .terminals + .get_mut(&terminal_id) + .unwrap() + .set_detected_state_with_screen_signals_at( + None, + AgentState::Unknown, + false, + false, + false, + false, + exit_at + std::time::Duration::from_millis(1), + ); + + let mut stale = metadata_params(pane_id.clone()); + stale.source = "custom:pi-metadata".into(); + stale.agent = Some("pi".into()); + stale.title = Some("stale".into()); + stale.seq = Some(200); + let response = app.handle_pane_report_metadata("stale".into(), stale); + let _: SuccessResponse = serde_json::from_str(&response).unwrap(); + + let mut official = metadata_params(pane_id.clone()); + official.source = "herdr:pi".into(); + official.seq = Some(200); + let response = app.handle_pane_report_metadata("official".into(), official); + let _: SuccessResponse = serde_json::from_str(&response).unwrap(); + + let terminal = &app.state.terminals[&terminal_id]; + assert!(terminal.metadata_report_sequence_is_fresh("custom:pi-metadata", Some(1))); + assert!(terminal.metadata_report_sequence_is_fresh("custom:pi-tokens", Some(1))); + assert!(terminal.metadata_report_sequence_is_fresh("herdr:pi", Some(1))); + + app.state + .terminals + .get_mut(&terminal_id) + .unwrap() + .set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Idle, + false, + false, + false, + false, + exit_at + std::time::Duration::from_millis(2), + ); + let mut fresh = metadata_params(pane_id.clone()); + fresh.source = "custom:pi-metadata".into(); + fresh.agent = Some("pi".into()); + fresh.title = Some("fresh".into()); + fresh.seq = Some(1); + let response = app.handle_pane_report_metadata("fresh".into(), fresh); + let _: SuccessResponse = serde_json::from_str(&response).unwrap(); + + let mut fresh_tokens = metadata_params(pane_id); + fresh_tokens.source = "custom:pi-tokens".into(); + fresh_tokens.agent = Some("pi".into()); + fresh_tokens.title = None; + fresh_tokens.tokens = + std::collections::HashMap::from([("generation".into(), Some("new".into()))]); + fresh_tokens.seq = Some(1); + let response = app.handle_pane_report_metadata("fresh-tokens".into(), fresh_tokens); + let _: SuccessResponse = serde_json::from_str(&response).unwrap(); + + let terminal = &app.state.terminals[&terminal_id]; + assert_eq!( + terminal.agent_metadata["custom:pi-metadata"] + .title + .as_deref(), + Some("fresh") + ); + assert_eq!( + terminal + .metadata_tokens + .values() + .get("generation") + .map(String::as_str), + Some("new") + ); + } + #[test] fn pane_report_metadata_accepts_documented_source_chars_and_max_ttl() { let (mut app, pane_id) = app_with_test_workspace(); diff --git a/src/integration/assets/hermes/__init__.py b/src/integration/assets/hermes/__init__.py index e6f6971d..5474e5e3 100644 --- a/src/integration/assets/hermes/__init__.py +++ b/src/integration/assets/hermes/__init__.py @@ -1,7 +1,7 @@ """Hermes plugin installed by Herdr to report agent lifecycle state.""" # HERDR_INTEGRATION_ID=hermes -# HERDR_INTEGRATION_VERSION=3 +# HERDR_INTEGRATION_VERSION=4 from __future__ import annotations @@ -71,6 +71,19 @@ def _report(state: str, **kwargs) -> None: _send("pane.report_agent", params) +def _session_started(**kwargs) -> None: + session_id = _session_id(kwargs) + if session_id: + _send( + "pane.report_agent_session", + { + "agent_session_id": session_id, + "session_start_source": "startup", + }, + ) + _idle(**kwargs) + + def _working(**kwargs) -> None: _report("working", **kwargs) @@ -84,7 +97,7 @@ def _idle(**kwargs) -> None: def register(ctx): - ctx.register_hook("on_session_start", _idle) + ctx.register_hook("on_session_start", _session_started) ctx.register_hook("pre_llm_call", _working) ctx.register_hook("pre_api_request", _working) ctx.register_hook("pre_tool_call", _working) diff --git a/src/integration/assets/kilo/herdr-agent-state.js b/src/integration/assets/kilo/herdr-agent-state.js index e8600ab1..8071670c 100644 --- a/src/integration/assets/kilo/herdr-agent-state.js +++ b/src/integration/assets/kilo/herdr-agent-state.js @@ -2,7 +2,7 @@ // managed by herdr; reinstalling or updating the integration overwrites this file. // add custom hooks/plugins beside this file instead of editing it. // HERDR_INTEGRATION_ID=kilo -// HERDR_INTEGRATION_VERSION=3 +// HERDR_INTEGRATION_VERSION=4 import net from "node:net"; @@ -88,7 +88,10 @@ function reportSession(sessionID) { if (!sessionID) { return Promise.resolve(); } - return request("pane.report_agent_session", { agent_session_id: sessionID }); + return request("pane.report_agent_session", { + agent_session_id: sessionID, + session_start_source: "startup", + }); } function reportState(state, sessionID) { diff --git a/src/integration/assets/kimi/herdr-agent-state.ps1 b/src/integration/assets/kimi/herdr-agent-state.ps1 index 16c9eb4d..c298dda4 100644 --- a/src/integration/assets/kimi/herdr-agent-state.ps1 +++ b/src/integration/assets/kimi/herdr-agent-state.ps1 @@ -2,7 +2,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=kimi -# HERDR_INTEGRATION_VERSION=5 +# HERDR_INTEGRATION_VERSION=6 param([string]$Action = "") @@ -23,7 +23,7 @@ $sessionId = if ($null -ne $payload -and -not [string]::IsNullOrWhiteSpace($payl try { if ($Action -eq "session") { if ([string]::IsNullOrWhiteSpace($sessionId)) { exit 0 } - & herdr pane report-agent-session $env:HERDR_PANE_ID --source herdr:kimi --agent kimi --agent-session-id $sessionId --seq $seq 2>$null | Out-Null + & herdr pane report-agent-session $env:HERDR_PANE_ID --source herdr:kimi --agent kimi --agent-session-id $sessionId --session-start-source startup --seq $seq 2>$null | Out-Null } else { if ([string]::IsNullOrWhiteSpace($sessionId)) { & herdr pane report-agent $env:HERDR_PANE_ID --source herdr:kimi --agent kimi --state $Action --seq $seq 2>$null | Out-Null diff --git a/src/integration/assets/kimi/herdr-agent-state.sh b/src/integration/assets/kimi/herdr-agent-state.sh index 7dac1b49..e919300f 100644 --- a/src/integration/assets/kimi/herdr-agent-state.sh +++ b/src/integration/assets/kimi/herdr-agent-state.sh @@ -1,7 +1,7 @@ #!/bin/sh # managed by herdr; reinstalling the integration replaces this file. # HERDR_INTEGRATION_ID=kimi -# HERDR_INTEGRATION_VERSION=5 +# HERDR_INTEGRATION_VERSION=6 action="${1:-}" case "$action" in @@ -42,6 +42,7 @@ if action == "session": if session_id is None: raise SystemExit(0) method = "pane.report_agent_session" + params["session_start_source"] = "startup" else: method = "pane.report_agent" params["state"] = action diff --git a/src/integration/assets/mastracode/herdr-agent-state.sh b/src/integration/assets/mastracode/herdr-agent-state.sh index b8e0a26a..39a8461b 100644 --- a/src/integration/assets/mastracode/herdr-agent-state.sh +++ b/src/integration/assets/mastracode/herdr-agent-state.sh @@ -3,7 +3,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=mastracode -# HERDR_INTEGRATION_VERSION=1 +# HERDR_INTEGRATION_VERSION=2 set -eu @@ -13,7 +13,7 @@ trap 'rm -f "$hook_input_file"' EXIT HUP INT TERM cat >"$hook_input_file" 2>/dev/null || true case "$action" in - working|idle|blocked|release) ;; + session|working|idle|blocked) ;; *) exit 0 ;; esac @@ -55,14 +55,18 @@ if isinstance(session_id, str) and session_id: agent_session_id = session_id else: agent_session_id = None -if action == "release": +if action == "session": + if not agent_session_id: + raise SystemExit(0) request = { "id": request_id, - "method": "pane.release_agent", + "method": "pane.report_agent_session", "params": { "pane_id": pane_id, "source": source, "agent": "mastracode", + "agent_session_id": agent_session_id, + "session_start_source": "startup", "seq": report_seq, }, } diff --git a/src/integration/assets/omp/herdr-agent-state.ts b/src/integration/assets/omp/herdr-agent-state.ts index 350cdea7..b3f88c93 100644 --- a/src/integration/assets/omp/herdr-agent-state.ts +++ b/src/integration/assets/omp/herdr-agent-state.ts @@ -2,7 +2,7 @@ // managed by herdr; reinstalling or updating the integration overwrites this file. // add custom hooks/plugins beside this file instead of editing it. // HERDR_INTEGRATION_ID=omp -// HERDR_INTEGRATION_VERSION=6 +// HERDR_INTEGRATION_VERSION=7 // @ts-nocheck import net from "node:net"; @@ -168,29 +168,6 @@ function sendState(state: AgentState, message?: string, seq = nextReportSeq()): }); } -function releaseAgent(): Promise { - return sendRequest({ - id: `${source}:release:${Date.now()}:${Math.random().toString(36).slice(2)}`, - method: "pane.release_agent", - params: { - pane_id: paneId, - source, - agent: "omp", - seq: nextReportSeq(), - }, - }); -} - -function shouldReleaseOnSessionShutdown(event: any): boolean { - // OMP tears down and rebinds extension runtimes for internal lifecycle actions - // such as /reload, /new, /resume, and /fork. Those do not mean the pane's - // agent process has exited, and releasing hook authority there can suppress - // legitimate reports from the replacement runtime. Only a user/process quit - // should release Herdr's full-lifecycle authority. - const reason = event?.reason; - return reason === "quit"; -} - let sendInFlight = false; let queuedState: QueuedState | undefined; @@ -470,13 +447,9 @@ export default function (pi) { scheduleIdle(); }); - pi.on("session_shutdown", async (event) => { - if (!rootSession) { - return; - } - clearPendingTimers(); - if (shouldReleaseOnSessionShutdown(event)) { - await releaseAgent(); + pi.on("session_shutdown", () => { + if (rootSession) { + clearPendingTimers(); } }); } diff --git a/src/integration/assets/pi/herdr-agent-state.ts b/src/integration/assets/pi/herdr-agent-state.ts index caa50ed9..2e79c7c6 100644 --- a/src/integration/assets/pi/herdr-agent-state.ts +++ b/src/integration/assets/pi/herdr-agent-state.ts @@ -2,7 +2,7 @@ // managed by herdr; reinstalling or updating the integration overwrites this file. // add custom hooks/plugins beside this file instead of editing it. // HERDR_INTEGRATION_ID=pi -// HERDR_INTEGRATION_VERSION=6 +// HERDR_INTEGRATION_VERSION=7 // @ts-nocheck import net from "node:net"; @@ -142,29 +142,6 @@ function sendState(state: AgentState, message?: string, seq = nextReportSeq()): }); } -function releaseAgent(): Promise { - return sendRequest({ - id: `${source}:release:${Date.now()}:${Math.random().toString(36).slice(2)}`, - method: "pane.release_agent", - params: { - pane_id: paneId, - source, - agent: "pi", - seq: nextReportSeq(), - }, - }); -} - -function shouldReleaseOnSessionShutdown(event: any): boolean { - // Pi tears down and rebinds extension runtimes for internal lifecycle actions - // such as /reload, /new, /resume, and /fork. Those do not mean the pane's - // agent process has exited, and releasing hook authority there can suppress - // legitimate reports from the replacement runtime. Only a user/process quit - // should release Herdr's full-lifecycle authority. - const reason = event?.reason; - return reason === "quit"; -} - let sendInFlight = false; let queuedState: QueuedState | undefined; @@ -275,13 +252,4 @@ export default function (pi) { agentActive = false; publishState(); }); - - pi.on("session_shutdown", async (event) => { - if (!rootSession) { - return; - } - if (shouldReleaseOnSessionShutdown(event)) { - await releaseAgent(); - } - }); } diff --git a/src/integration/mod.rs b/src/integration/mod.rs index 73c23fc6..d7bd8b3f 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -22,10 +22,10 @@ pub(crate) use types::{IntegrationRecommendation, IntegrationStatus, Integration const PI_EXTENSION_INSTALL_NAME: &str = "herdr-agent-state.ts"; const PI_EXTENSION_ASSET: &str = include_str!("assets/pi/herdr-agent-state.ts"); -const PI_INTEGRATION_VERSION: u32 = 6; +const PI_INTEGRATION_VERSION: u32 = 7; const OMP_EXTENSION_INSTALL_NAME: &str = "herdr-omp-agent-state.ts"; const OMP_EXTENSION_ASSET: &str = include_str!("assets/omp/herdr-agent-state.ts"); -const OMP_INTEGRATION_VERSION: u32 = 6; +const OMP_INTEGRATION_VERSION: u32 = 7; const CLAUDE_HOOK_INSTALL_NAME: &str = if cfg!(windows) { "herdr-agent-state.ps1" } else { @@ -58,7 +58,7 @@ const KIMI_HOOK_ASSET: &str = if cfg!(windows) { } else { include_str!("assets/kimi/herdr-agent-state.sh") }; -const KIMI_INTEGRATION_VERSION: u32 = 5; +const KIMI_INTEGRATION_VERSION: u32 = 6; const KIMI_CONFIG_BLOCK_BEGIN: &str = "# >>> herdr kimi integration"; const KIMI_CONFIG_BLOCK_END: &str = "# <<< herdr kimi integration"; const KIMI_MIN_VERSION: &str = "0.14.0"; @@ -160,13 +160,13 @@ const OPENCODE_PLUGIN_ASSET: &str = include_str!("assets/opencode/herdr-agent-st const OPENCODE_INTEGRATION_VERSION: u32 = 9; const KILO_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state.js"; const KILO_PLUGIN_ASSET: &str = include_str!("assets/kilo/herdr-agent-state.js"); -const KILO_INTEGRATION_VERSION: u32 = 3; +const KILO_INTEGRATION_VERSION: u32 = 4; const HERMES_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state"; const HERMES_PLUGIN_MANIFEST_INSTALL_NAME: &str = "plugin.yaml"; const HERMES_PLUGIN_INIT_INSTALL_NAME: &str = "__init__.py"; const HERMES_PLUGIN_MANIFEST_ASSET: &str = include_str!("assets/hermes/plugin.yaml"); const HERMES_PLUGIN_INIT_ASSET: &str = include_str!("assets/hermes/__init__.py"); -const HERMES_INTEGRATION_VERSION: u32 = 3; +const HERMES_INTEGRATION_VERSION: u32 = 4; const QODERCLI_HOOK_INSTALL_NAME: &str = if cfg!(windows) { "herdr-agent-state.ps1" } else { @@ -198,10 +198,12 @@ const CURSOR_HOOK_ASSET: &str = include_str!("assets/cursor/herdr-agent-state.sh const CURSOR_INTEGRATION_VERSION: u32 = 1; const MASTRACODE_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; const MASTRACODE_HOOK_ASSET: &str = include_str!("assets/mastracode/herdr-agent-state.sh"); -const MASTRACODE_INTEGRATION_VERSION: u32 = 1; +const MASTRACODE_INTEGRATION_VERSION: u32 = 2; const MASTRACODE_HOOK_TIMEOUT_MS: u64 = 10_000; -const MASTRACODE_HOOK_EVENTS: [(&str, &str); 12] = [ - ("SessionStart", "idle"), +const MASTRACODE_REMOVED_HOOK_EVENTS: [(&str, &str); 2] = + [("SessionStart", "idle"), ("SessionEnd", "release")]; +const MASTRACODE_HOOK_EVENTS: [(&str, &str); 11] = [ + ("SessionStart", "session"), ("UserPromptSubmit", "working"), ("AgentStart", "working"), ("PreToolUse", "working"), @@ -212,7 +214,6 @@ const MASTRACODE_HOOK_EVENTS: [(&str, &str); 12] = [ ("Interrupt", "idle"), ("AgentEnd", "idle"), ("Stop", "idle"), - ("SessionEnd", "release"), ]; const INTEGRATION_VERSION_MARKER: &str = "HERDR_INTEGRATION_VERSION="; diff --git a/src/integration/targets.rs b/src/integration/targets.rs index 5d5e730c..488b7032 100644 --- a/src/integration/targets.rs +++ b/src/integration/targets.rs @@ -39,10 +39,11 @@ use super::{ HERMES_PLUGIN_INIT_INSTALL_NAME, HERMES_PLUGIN_MANIFEST_ASSET, HERMES_PLUGIN_MANIFEST_INSTALL_NAME, KILO_PLUGIN_ASSET, KILO_PLUGIN_INSTALL_NAME, KIMI_HOOK_ASSET, KIMI_HOOK_INSTALL_NAME, MASTRACODE_HOOK_ASSET, MASTRACODE_HOOK_EVENTS, - MASTRACODE_HOOK_INSTALL_NAME, MASTRACODE_HOOK_TIMEOUT_MS, OMP_EXTENSION_ASSET, - OMP_EXTENSION_INSTALL_NAME, OPENCODE_PLUGIN_ASSET, OPENCODE_PLUGIN_INSTALL_NAME, - PI_EXTENSION_ASSET, PI_EXTENSION_INSTALL_NAME, QODERCLI_HOOK_ASSET, QODERCLI_HOOK_EVENTS, - QODERCLI_HOOK_INSTALL_NAME, QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS, + MASTRACODE_HOOK_INSTALL_NAME, MASTRACODE_HOOK_TIMEOUT_MS, MASTRACODE_REMOVED_HOOK_EVENTS, + OMP_EXTENSION_ASSET, OMP_EXTENSION_INSTALL_NAME, OPENCODE_PLUGIN_ASSET, + OPENCODE_PLUGIN_INSTALL_NAME, PI_EXTENSION_ASSET, PI_EXTENSION_INSTALL_NAME, + QODERCLI_HOOK_ASSET, QODERCLI_HOOK_EVENTS, QODERCLI_HOOK_INSTALL_NAME, + QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS, }; fn ensure_extension_dir(dir: &Path, agent: &str) -> io::Result<()> { @@ -1133,6 +1134,9 @@ pub(crate) fn install_mastracode() -> io::Result { })?; let quoted_hook_path = shell_single_quote(&hook_path.display().to_string()); + for (event, action) in MASTRACODE_REMOVED_HOOK_EVENTS { + remove_flat_command_hook(hooks, event, &format!("bash {quoted_hook_path} {action}"))?; + } for (event, action) in MASTRACODE_HOOK_EVENTS { ensure_flat_command_hook( hooks, @@ -1171,7 +1175,10 @@ pub(crate) fn uninstall_mastracode() -> io::Result { })?; let quoted_hook_path = shell_single_quote(&hook_path.display().to_string()); - for (event, action) in MASTRACODE_HOOK_EVENTS { + for (event, action) in MASTRACODE_HOOK_EVENTS + .into_iter() + .chain(MASTRACODE_REMOVED_HOOK_EVENTS) + { updated_hooks |= remove_flat_command_hook( hooks, event, diff --git a/src/integration/tests.rs b/src/integration/tests.rs index 78b728f9..076a22b6 100644 --- a/src/integration/tests.rs +++ b/src/integration/tests.rs @@ -2667,8 +2667,7 @@ fn bundled_integration_assets_report_session_refs() { assert!(PI_EXTENSION_ASSET.contains("pane.report_agent\"")); assert!(PI_EXTENSION_ASSET.contains("pi.on(\"agent_start\"")); assert!(PI_EXTENSION_ASSET.contains("pi.on(\"agent_settled\"")); - assert!(PI_EXTENSION_ASSET.contains("pane.release_agent")); - assert!(PI_EXTENSION_ASSET.contains("pi.on(\"session_shutdown\"")); + assert!(!PI_EXTENSION_ASSET.contains("pi.on(\"session_shutdown\"")); assert!(OMP_EXTENSION_ASSET.contains("agent_session_path")); assert!(OMP_EXTENSION_ASSET.contains("agent_session_id")); assert!(OMP_EXTENSION_ASSET.contains("ctx?.hasUI !== true")); @@ -2676,7 +2675,6 @@ fn bundled_integration_assets_report_session_refs() { assert!(OMP_EXTENSION_ASSET.contains("pane.report_agent\"")); assert!(OMP_EXTENSION_ASSET.contains("pi.on(\"agent_start\"")); assert!(OMP_EXTENSION_ASSET.contains("pi.on(\"agent_end\"")); - assert!(OMP_EXTENSION_ASSET.contains("pane.release_agent")); assert!(OMP_EXTENSION_ASSET.contains("pi.on(\"session_shutdown\"")); assert!( CLAUDE_HOOK_ASSET.contains("agent_session_id") @@ -2718,6 +2716,7 @@ fn bundled_integration_assets_report_session_refs() { assert!(KIMI_HOOK_ASSET.contains("source\": \"herdr:kimi")); assert!(KIMI_HOOK_ASSET.contains("agent_session_id")); assert!(KIMI_HOOK_ASSET.contains("method = \"pane.report_agent_session\"")); + assert!(KIMI_HOOK_ASSET.contains("params[\"session_start_source\"] = \"startup\"")); assert!(KIMI_HOOK_ASSET.contains("method = \"pane.report_agent\"")); assert!(KIMI_HOOK_ASSET.contains("params[\"state\"] = action")); assert!(!KIMI_HOOK_ASSET.contains("pane.release_agent")); @@ -2743,10 +2742,13 @@ fn bundled_integration_assets_report_session_refs() { assert!(KILO_PLUGIN_ASSET.contains("SOURCE = \"herdr:kilo\"")); assert!(KILO_PLUGIN_ASSET.contains("AGENT = \"kilo\"")); assert!(KILO_PLUGIN_ASSET.contains("pane.report_agent_session")); + assert!(KILO_PLUGIN_ASSET.contains("session_start_source: \"startup\"")); assert!(KILO_PLUGIN_ASSET.contains("reportState")); assert!(!KILO_PLUGIN_ASSET.contains("pane.release_agent")); assert!(HERMES_PLUGIN_INIT_ASSET.contains("session_id = _session_id(kwargs)")); assert!(HERMES_PLUGIN_INIT_ASSET.contains("agent_session_id")); + assert!(HERMES_PLUGIN_INIT_ASSET.contains("pane.report_agent_session\",")); + assert!(HERMES_PLUGIN_INIT_ASSET.contains("\"session_start_source\": \"startup\"")); assert!(HERMES_PLUGIN_INIT_ASSET.contains("pane.report_agent\",")); assert!(HERMES_PLUGIN_INIT_ASSET.contains("on_session_end")); assert!(!HERMES_PLUGIN_INIT_ASSET.contains("on_session_finalize")); @@ -2768,33 +2770,30 @@ fn bundled_integration_assets_report_session_refs() { assert!(!CURSOR_HOOK_ASSET.contains("\"state\":")); assert!(!CURSOR_HOOK_ASSET.contains("pane.release_agent")); assert!(MASTRACODE_HOOK_ASSET.contains("HERDR_INTEGRATION_ID=mastracode")); - assert!(MASTRACODE_HOOK_ASSET.contains("HERDR_INTEGRATION_VERSION=1")); + assert!(MASTRACODE_HOOK_ASSET.contains("HERDR_INTEGRATION_VERSION=2")); assert!(MASTRACODE_HOOK_ASSET.contains("session_id")); assert!(!MASTRACODE_HOOK_ASSET.contains("run_id")); assert!(MASTRACODE_HOOK_ASSET.contains("agent_session_id")); + assert!(MASTRACODE_HOOK_ASSET.contains("pane.report_agent_session")); + assert!(MASTRACODE_HOOK_ASSET.contains("session_start_source")); assert!(MASTRACODE_HOOK_ASSET.contains("pane.report_agent")); - assert!(MASTRACODE_HOOK_ASSET.contains("pane.release_agent")); } #[test] -fn pi_extension_releases_only_for_quit_session_shutdown() { - let release_policy = PI_EXTENSION_ASSET - .find("function shouldReleaseOnSessionShutdown") - .expect("pi extension should centralize session shutdown release policy"); - let quit_check = PI_EXTENSION_ASSET - .find("reason === \"quit\"") - .expect("pi extension should release only for true quit shutdowns"); - let shutdown_handler = PI_EXTENSION_ASSET - .find("pi.on(\"session_shutdown\", async (event)") - .expect("pi extension should inspect the session_shutdown event"); - let guarded_release = PI_EXTENSION_ASSET[shutdown_handler..] - .find("if (shouldReleaseOnSessionShutdown(event))") - .expect("pi extension should guard releaseAgent by shutdown reason"); - - assert!(release_policy < shutdown_handler); - assert!(release_policy < quit_check); - assert!(quit_check < shutdown_handler); - assert!(guarded_release > 0); +fn process_owned_integration_assets_do_not_report_release() { + for (name, asset) in [ + ("pi", PI_EXTENSION_ASSET), + ("omp", OMP_EXTENSION_ASSET), + ("mastracode", MASTRACODE_HOOK_ASSET), + ("kimi", KIMI_HOOK_ASSET), + ("kilo", KILO_PLUGIN_ASSET), + ("hermes", HERMES_PLUGIN_INIT_ASSET), + ] { + assert!( + !asset.contains("pane.release_agent"), + "{name} process exit should own lifecycle release" + ); + } } #[test] @@ -2817,27 +2816,6 @@ fn pi_extension_refreshes_session_ref_before_agent_start_state() { assert!(report_session < publish_state); } -#[test] -fn omp_extension_releases_only_for_quit_session_shutdown() { - let release_policy = OMP_EXTENSION_ASSET - .find("function shouldReleaseOnSessionShutdown") - .expect("omp extension should centralize session shutdown release policy"); - let quit_check = OMP_EXTENSION_ASSET - .find("reason === \"quit\"") - .expect("omp extension should release only for true quit shutdowns"); - let shutdown_handler = OMP_EXTENSION_ASSET - .find("pi.on(\"session_shutdown\", async (event)") - .expect("omp extension should inspect the session_shutdown event"); - let guarded_release = OMP_EXTENSION_ASSET[shutdown_handler..] - .find("if (shouldReleaseOnSessionShutdown(event))") - .expect("omp extension should guard releaseAgent by shutdown reason"); - - assert!(release_policy < shutdown_handler); - assert!(release_policy < quit_check); - assert!(quit_check < shutdown_handler); - assert!(guarded_release > 0); -} - #[test] fn omp_extension_refreshes_session_ref_before_agent_start_state() { let agent_start = OMP_EXTENSION_ASSET @@ -3353,6 +3331,57 @@ fn install_mastracode_writes_hook_and_updates_hooks_json() { let _ = fs::remove_dir_all(base); } +#[test] +fn install_mastracode_removes_v1_lifecycle_hooks() { + let _lock = integration_env_lock(); + let base = unique_base(); + let original_home = std::env::var_os("HOME"); + let mastracode_dir = base.join(".mastracode"); + let hook_path = mastracode_dir + .join("hooks") + .join(MASTRACODE_HOOK_INSTALL_NAME); + fs::create_dir_all(&mastracode_dir).unwrap(); + fs::write( + mastracode_dir.join("hooks.json"), + serde_json::to_string(&json!({ + "SessionStart": [{ + "type": "command", + "command": format!("bash '{}' idle", hook_path.display()), + "timeout": MASTRACODE_HOOK_TIMEOUT_MS + }], + "SessionEnd": [{ + "type": "command", + "command": format!("bash '{}' release", hook_path.display()), + "timeout": MASTRACODE_HOOK_TIMEOUT_MS + }] + })) + .unwrap(), + ) + .unwrap(); + std::env::set_var("HOME", &base); + + install_mastracode().unwrap(); + + let hooks_file: Value = + serde_json::from_str(&fs::read_to_string(mastracode_dir.join("hooks.json")).unwrap()) + .unwrap(); + let hooks = hooks_file.as_object().unwrap(); + assert!(!hooks.contains_key("SessionEnd")); + let session_start = hooks["SessionStart"].as_array().unwrap(); + assert_eq!(session_start.len(), 1); + assert!(session_start[0]["command"] + .as_str() + .unwrap() + .ends_with("session")); + + if let Some(home) = original_home { + std::env::set_var("HOME", home); + } else { + std::env::remove_var("HOME"); + } + let _ = fs::remove_dir_all(base); +} + #[test] fn install_mastracode_is_idempotent_for_hook_entries() { let _lock = integration_env_lock(); diff --git a/src/pane.rs b/src/pane.rs index ee3aee12..ea6a13fd 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -290,6 +290,7 @@ fn foreground_member_cwd_different_from_shell( enum ForegroundShellAgentAction { ObserveProbe, ReportProcessExit, + ReportReplacementProcess, ClearAgent, } @@ -299,12 +300,20 @@ fn foreground_shell_agent_action( foreground_is_pane_shell: bool, process_exit_reported: bool, ) -> ForegroundShellAgentAction { - if previous_agent.is_none() || new_agent.is_some() { + let Some(previous_agent) = previous_agent else { return ForegroundShellAgentAction::ObserveProbe; - } - + }; if process_exit_reported { - return ForegroundShellAgentAction::ClearAgent; + return if new_agent == Some(previous_agent) { + ForegroundShellAgentAction::ReportReplacementProcess + } else if new_agent.is_none() { + ForegroundShellAgentAction::ClearAgent + } else { + ForegroundShellAgentAction::ObserveProbe + }; + } + if new_agent.is_some() { + return ForegroundShellAgentAction::ObserveProbe; } if foreground_is_pane_shell { @@ -317,6 +326,38 @@ fn foreground_shell_agent_action( ForegroundShellAgentAction::ObserveProbe } +fn apply_foreground_shell_agent_action( + agent_presence: &mut AgentDetectionPresence, + action: ForegroundShellAgentAction, + previous_agent: Option, + new_agent: Option, + pending_foreground_shell_clear: &mut bool, + foreground_shell_exit_reported: &mut bool, +) -> bool { + match action { + ForegroundShellAgentAction::ReportReplacementProcess => { + *pending_foreground_shell_clear = false; + *foreground_shell_exit_reported = false; + agent_presence.observe_process_probe(previous_agent); + true + } + ForegroundShellAgentAction::ReportProcessExit => { + *pending_foreground_shell_clear = true; + false + } + ForegroundShellAgentAction::ClearAgent => { + *pending_foreground_shell_clear = false; + *foreground_shell_exit_reported = false; + agent_presence.clear_current_agent() + } + ForegroundShellAgentAction::ObserveProbe => { + *pending_foreground_shell_clear = false; + *foreground_shell_exit_reported = false; + agent_presence.observe_process_probe(new_agent) + } + } +} + #[derive(Debug, Clone, Copy)] struct ProcessProbeInput { current_agent: Option, @@ -680,27 +721,20 @@ fn spawn_basic_detection_task( } } let previous_agent = agent_presence.current_agent(); - let changed = match foreground_shell_agent_action( + let foreground_action = foreground_shell_agent_action( previous_agent, new_agent, foreground_is_pane_shell, foreground_shell_exit_reported, - ) { - ForegroundShellAgentAction::ReportProcessExit => { - pending_foreground_shell_clear = true; - false - } - ForegroundShellAgentAction::ClearAgent => { - pending_foreground_shell_clear = false; - foreground_shell_exit_reported = false; - agent_presence.clear_current_agent() - } - ForegroundShellAgentAction::ObserveProbe => { - pending_foreground_shell_clear = false; - foreground_shell_exit_reported = false; - agent_presence.observe_process_probe(new_agent) - } - }; + ); + let changed = apply_foreground_shell_agent_action( + &mut agent_presence, + foreground_action, + previous_agent, + new_agent, + &mut pending_foreground_shell_clear, + &mut foreground_shell_exit_reported, + ); if new_agent.is_some() { last_foreground_pgid = process_group_id.or(foreground_pgid); acquisition_started_at = None; @@ -715,7 +749,9 @@ fn spawn_basic_detection_task( } if changed { agent = agent_presence.current_agent(); - agent_changed = previous_agent != agent; + agent_changed = previous_agent != agent + || foreground_action + == ForegroundShellAgentAction::ReportReplacementProcess; if agent_changed { pending_idle.clear(); last_screen_scan_detection_content_seq = None; @@ -2105,27 +2141,20 @@ impl PaneRuntime { } let previous_agent = agent_presence.current_agent(); - let changed = match foreground_shell_agent_action( + let foreground_action = foreground_shell_agent_action( previous_agent, new_agent, foreground_is_pane_shell, foreground_shell_exit_reported, - ) { - ForegroundShellAgentAction::ReportProcessExit => { - pending_foreground_shell_clear = true; - false - } - ForegroundShellAgentAction::ClearAgent => { - pending_foreground_shell_clear = false; - foreground_shell_exit_reported = false; - agent_presence.clear_current_agent() - } - ForegroundShellAgentAction::ObserveProbe => { - pending_foreground_shell_clear = false; - foreground_shell_exit_reported = false; - agent_presence.observe_process_probe(new_agent) - } - }; + ); + let changed = apply_foreground_shell_agent_action( + &mut agent_presence, + foreground_action, + previous_agent, + new_agent, + &mut pending_foreground_shell_clear, + &mut foreground_shell_exit_reported, + ); if new_agent.is_some() { last_foreground_pgid = process_group_id; acquisition_started_at = None; @@ -2142,7 +2171,10 @@ impl PaneRuntime { } if changed { agent = agent_presence.current_agent(); - if agent != previous_agent { + if agent != previous_agent + || foreground_action + == ForegroundShellAgentAction::ReportReplacementProcess + { pending_idle.clear(); last_screen_scan_detection_content_seq = None; // A new foreground agent must not inherit OSC @@ -3369,6 +3401,14 @@ mod tests { ); } + #[test] + fn same_agent_after_reported_exit_is_a_replacement_process() { + assert_eq!( + foreground_shell_agent_action(Some(Agent::Pi), Some(Agent::Pi), false, true), + ForegroundShellAgentAction::ReportReplacementProcess + ); + } + #[test] fn unknown_non_shell_foreground_job_is_not_immediate_clear_signal() { assert_eq!( @@ -3388,7 +3428,7 @@ mod tests { #[test] fn foreground_agent_job_is_not_clear_signal() { assert_eq!( - foreground_shell_agent_action(Some(Agent::Claude), Some(Agent::OpenCode), true, false), + foreground_shell_agent_action(Some(Agent::Claude), Some(Agent::OpenCode), true, false,), ForegroundShellAgentAction::ObserveProbe ); } diff --git a/src/persist/snapshot.rs b/src/persist/snapshot.rs index e1e112a0..39f790dd 100644 --- a/src/persist/snapshot.rs +++ b/src/persist/snapshot.rs @@ -1108,18 +1108,24 @@ mod tests { let terminal_id = state.workspaces[0].tabs[0].panes[&root] .attached_terminal_id .clone(); - state - .terminals - .get_mut(&terminal_id) - .unwrap() - .set_hook_authority_with_session_ref( - "herdr:pi".into(), - "pi".into(), - crate::detect::AgentState::Working, - None, - crate::agent_resume::AgentSessionRef::path(session_path.clone()), - Some(20), - ); + let terminal = state.terminals.get_mut(&terminal_id).unwrap(); + terminal.set_detected_state( + Some(crate::detect::Agent::Pi), + crate::detect::AgentState::Idle, + ); + terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { + source: "herdr:pi".into(), + agent: "pi".into(), + session_ref: crate::agent_resume::AgentSessionRef::path(session_path.clone()).unwrap(), + }); + terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + crate::detect::AgentState::Working, + None, + crate::agent_resume::AgentSessionRef::path(session_path.clone()), + Some(20), + ); let snapshot = capture_from_state(&state); let agent_session = snapshot.workspaces[0].tabs[0].panes[&root.raw()] diff --git a/src/server/headless.rs b/src/server/headless.rs index aaa3742a..942a17f1 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -5840,7 +5840,7 @@ next_tab = "" assert!( server.handle_internal_event_with_forwarding(AppEvent::HookStateReported { pane_id, - source: "herdr:pi".into(), + source: "custom:pi".into(), agent_label: "pi".into(), state: crate::detect::AgentState::Working, message: None, @@ -5853,7 +5853,7 @@ next_tab = "" pane_id, source: "user:pi-display".into(), agent_label: Some("pi".into()), - applies_to_source: Some("herdr:pi".into()), + applies_to_source: Some("custom:pi".into()), title: Some("short lived".into()), display_agent: None, state_labels: HashMap::new(), @@ -9522,6 +9522,34 @@ next_tab = "" .unwrap() .attached_terminal_id .clone(); + server + .app + .state + .terminals + .get_mut(&terminal_id) + .unwrap() + .set_detected_state( + Some(crate::detect::Agent::Pi), + crate::detect::AgentState::Idle, + ); + server + .app + .state + .terminals + .get_mut(&terminal_id) + .unwrap() + .set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { + source: "herdr:pi".into(), + agent: "pi".into(), + session_ref: crate::agent_resume::AgentSessionRef::path( + std::env::current_dir() + .unwrap() + .join("headless-pi-session.jsonl") + .display() + .to_string(), + ) + .unwrap(), + }); server .app .state diff --git a/src/terminal/metadata.rs b/src/terminal/metadata.rs index 27ac8528..2a912b27 100644 --- a/src/terminal/metadata.rs +++ b/src/terminal/metadata.rs @@ -58,11 +58,47 @@ impl TerminalState { crate::metadata_tokens::sequence_is_fresh(&self.metadata_report_sequences, source, seq) } + pub(crate) fn metadata_report_agent( + source: &str, + agent_label: Option<&str>, + applies_to_source: Option<&str>, + ) -> Option { + agent_label + .and_then(crate::detect::parse_agent_label) + .or_else(|| { + crate::detect::Agent::ALL.iter().copied().find(|agent| { + let agent_label = crate::detect::agent_label(*agent); + crate::agent_resume::is_official_agent_source(source, agent_label) + || applies_to_source.is_some_and(|source| { + crate::agent_resume::is_official_agent_source(source, agent_label) + }) + }) + }) + } + + pub(crate) fn metadata_report_blocked_by_process_exit( + &self, + source: &str, + agent_label: Option<&str>, + applies_to_source: Option<&str>, + ) -> bool { + let Some(exit) = self.recent_agent_process_exit else { + return false; + }; + let exited_agent_label = crate::detect::agent_label(exit.agent); + agent_label.and_then(crate::detect::parse_agent_label) == Some(exit.agent) + || crate::agent_resume::is_official_agent_source(source, exited_agent_label) + || applies_to_source.is_some_and(|source| { + crate::agent_resume::is_official_agent_source(source, exited_agent_label) + }) + } + pub(crate) fn accept_metadata_report( &mut self, source: &str, seq: Option, includes_tokens: bool, + agent: Option, ) -> Result { let Some(seq) = seq else { return Ok(true); @@ -79,6 +115,10 @@ impl TerminalState { } self.metadata_report_sequences .insert(source.to_string(), seq); + if let Some(agent) = agent { + self.metadata_report_agents + .insert(source.to_string(), agent); + } if includes_tokens { self.metadata_token_sequence_sources .insert(source.to_string()); @@ -105,8 +145,20 @@ impl TerminalState { &mut self, report: AgentMetadataReport, ) -> Option { + if self.metadata_report_blocked_by_process_exit( + &report.source, + report.agent_label.as_deref(), + report.applies_to_source.as_deref(), + ) { + return None; + } + let report_agent = Self::metadata_report_agent( + &report.source, + report.agent_label.as_deref(), + report.applies_to_source.as_deref(), + ); if !matches!( - self.accept_metadata_report(&report.source, report.seq, false), + self.accept_metadata_report(&report.source, report.seq, false, report_agent), Ok(true) ) { return None; @@ -482,13 +534,13 @@ mod tests { let mut terminal = test_terminal(); for index in 0..=crate::metadata_tokens::MAX_SEQUENCE_SOURCES { assert_eq!( - terminal.accept_metadata_report(&format!("source-{index}"), Some(1), false), + terminal.accept_metadata_report(&format!("source-{index}"), Some(1), false, None,), Ok(true) ); } for index in 0..crate::metadata_tokens::MAX_SEQUENCE_SOURCES { assert_eq!( - terminal.accept_metadata_report(&format!("source-{index}"), Some(2), true), + terminal.accept_metadata_report(&format!("source-{index}"), Some(2), true, None,), Ok(true) ); } @@ -497,11 +549,68 @@ mod tests { &format!("source-{}", crate::metadata_tokens::MAX_SEQUENCE_SOURCES), Some(2), true, + None, ), Err(()) ); } + #[test] + fn custom_metadata_reanchors_sequence_after_process_restart() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + let report = |seq, ttl| AgentMetadataReport { + source: "custom:pi-metadata".into(), + agent_label: Some("pi".into()), + applies_to_source: None, + title: Some("Pi task".into()), + display_agent: None, + state_labels: HashMap::new(), + clear_title: false, + clear_display_agent: false, + clear_state_labels: false, + ttl, + seq: Some(seq), + }; + assert!(terminal + .set_agent_metadata(report(100, Some(Duration::ZERO))) + .is_some()); + let deadline = terminal.next_agent_metadata_expiry().unwrap(); + terminal.expire_agent_metadata_at(deadline, deadline); + assert!(terminal.agent_metadata.is_empty()); + let exit_at = Instant::now() + Duration::from_millis(1); + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Idle, + false, + false, + false, + true, + exit_at, + ); + terminal.set_detected_state_with_screen_signals_at( + None, + AgentState::Unknown, + false, + false, + false, + false, + exit_at + Duration::from_millis(1), + ); + assert!(terminal.set_agent_metadata(report(1, None)).is_none()); + + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Idle, + false, + false, + false, + false, + exit_at + Duration::from_millis(2), + ); + assert!(terminal.set_agent_metadata(report(1, None)).is_some()); + } + #[test] fn user_agent_metadata_overrides_presentation_fields_only() { let mut terminal = test_terminal(); diff --git a/src/terminal/state.rs b/src/terminal/state.rs index 38e4739e..4485c773 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -30,6 +30,14 @@ struct SuppressedFullLifecycleHookReport { session_ref: Option, observed_at: Instant, reason: FullLifecycleHookSuppressionReason, + replacement_session_ref: Option, + pending_replacement_report: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct PendingFullLifecycleHookReport { + authority: HookAuthority, + seq: u64, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -38,6 +46,12 @@ enum FullLifecycleHookSuppressionReason { ProcessExit, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FullLifecycleHookReportRoute { + Accept { reanchor_sequence: bool }, + Ignore, +} + #[derive(Debug, Clone, PartialEq, Eq)] struct StaleFullLifecycleHookSession { agent_label: String, @@ -91,6 +105,12 @@ struct AgentNameOwner { session_ref: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct RecentAgentProcessExit { + agent: Agent, + observed_at: Instant, +} + /// Pure state for a server-owned terminal. /// /// During the migration this is still one-to-one with a pane-backed PTY, but @@ -116,13 +136,14 @@ pub struct TerminalState { suppressed_full_lifecycle_hook_reports: HashMap, stale_full_lifecycle_hook_sessions: HashMap>, metadata_report_sequences: HashMap, + metadata_report_agents: HashMap, metadata_token_sequence_sources: std::collections::HashSet, pub state: AgentState, pub last_agent_state_change_seq: Option, pub revision: u64, pub launch_argv: Option>, pub respawn_shell_on_exit: bool, - recent_agent_process_exit_at: Option, + recent_agent_process_exit: Option, pub pending_agent_resume_plan: Option, } @@ -148,13 +169,14 @@ impl TerminalState { suppressed_full_lifecycle_hook_reports: HashMap::new(), stale_full_lifecycle_hook_sessions: HashMap::new(), metadata_report_sequences: HashMap::new(), + metadata_report_agents: HashMap::new(), metadata_token_sequence_sources: std::collections::HashSet::new(), state: AgentState::Unknown, last_agent_state_change_seq: None, revision: 0, launch_argv: None, respawn_shell_on_exit: false, - recent_agent_process_exit_at: None, + recent_agent_process_exit: None, pending_agent_resume_plan: None, } } @@ -193,8 +215,8 @@ impl TerminalState { #[cfg(any(windows, test))] pub(crate) fn agent_process_exited_within(&self, now: Instant, max_age: Duration) -> bool { - self.recent_agent_process_exit_at - .is_some_and(|exited_at| now.saturating_duration_since(exited_at) <= max_age) + self.recent_agent_process_exit + .is_some_and(|exit| now.saturating_duration_since(exit.observed_at) <= max_age) } pub fn with_pending_agent_resume_plan( @@ -268,8 +290,17 @@ impl TerminalState { let previous_presentation = self.effective_presentation_for_state_at(previous_state, now); let previous_detected_agent = self.detected_agent; let previous_session = self.current_session_identity_for_persistence(); + let newer_custom_authority = process_exited + && self.hook_authority.as_ref().is_some_and(|authority| { + crate::detect::parse_agent_label(&authority.agent_label) == agent + && !crate::agent_resume::is_official_agent_source( + &authority.source, + &authority.agent_label, + ) + && authority.reported_at > now + }); let agent_released = process_exited - && self.hook_authority_not_newer_than(now) + && !newer_custom_authority && (previous_agent_label.is_some() || self.agent_name.is_some()); if self.should_ignore_detected_state_under_full_lifecycle_hook(agent, process_exited) { if self @@ -293,6 +324,11 @@ impl TerminalState { agent_released: false, }; } + let replacement_process_detected = !process_exited + && agent.is_some() + && self + .recent_agent_process_exit + .is_some_and(|exit| Some(exit.agent) == agent && exit.observed_at < now); if !process_exited && self.detected_state_observed_before_release_suppression(agent, now) { return TerminalStateMutation { effective_state_change: self.recompute_effective_state( @@ -314,43 +350,168 @@ impl TerminalState { } if !process_exited { self.clear_full_lifecycle_hook_suppression_for_detected_agent( - previous_detected_agent, + if replacement_process_detected { + None + } else { + previous_detected_agent + }, agent, ); } self.fallback_state = fallback_state; self.fallback_visible_blocker = visible_blocker && fallback_state == AgentState::Blocked; self.fallback_observed_at = Some(now); - if process_exited && agent.is_some() { - self.recent_agent_process_exit_at = Some(now); + if process_exited { + if let Some(agent) = agent { + self.recent_agent_process_exit = Some(RecentAgentProcessExit { + agent, + observed_at: now, + }); + } } else if agent.is_some() { - self.recent_agent_process_exit_at = None; + self.recent_agent_process_exit = None; } - if process_exited - && self.hook_authority_not_newer_than(now) - && self.hook_authority.as_ref().is_some_and(|authority| { - crate::detect::parse_agent_label(&authority.agent_label) == agent - }) - { - let cleared_source = self - .hook_authority - .as_ref() - .map(|authority| authority.source.clone()); - self.suppress_current_full_lifecycle_hook_authority( - FullLifecycleHookSuppressionReason::ProcessExit, - ); - if let Some(source) = cleared_source { + if process_exited { + let mut reset_sources = Vec::new(); + let mut stale_sessions = Vec::new(); + for (source, suppressed) in &mut self.suppressed_full_lifecycle_hook_reports { + if crate::detect::parse_agent_label(&suppressed.agent_label) != agent + || suppressed.reason == FullLifecycleHookSuppressionReason::HookClear + { + continue; + } + let exited_session_ref = suppressed + .replacement_session_ref + .take() + .or_else(|| { + suppressed + .pending_replacement_report + .as_ref() + .and_then(|pending| pending.authority.session_ref.clone()) + }) + .or_else(|| suppressed.session_ref.clone()); + if let (Some(previous), Some(exited)) = + (suppressed.session_ref.as_ref(), exited_session_ref.as_ref()) + { + if previous != exited { + stale_sessions.push(( + source.clone(), + suppressed.agent_label.clone(), + previous.clone(), + )); + } + } + suppressed.session_ref = exited_session_ref; + suppressed.pending_replacement_report = None; + suppressed.observed_at = now; + reset_sources.push(source.clone()); + } + for (source, agent_label, session_ref) in stale_sessions { + self.remember_stale_full_lifecycle_hook_session(source, agent_label, session_ref); + } + for source in reset_sources { self.hook_report_sequences.remove(&source); } - self.hook_authority = None; - } - if process_exited - && self - .persisted_agent_session + + let official_session = self + .hook_authority .as_ref() - .is_some_and(|session| crate::detect::parse_agent_label(&session.agent) == agent) - { - self.persisted_agent_session = None; + .filter(|authority| { + crate::agent_resume::is_official_agent_source( + &authority.source, + &authority.agent_label, + ) && crate::detect::parse_agent_label(&authority.agent_label) == agent + }) + .map(|authority| { + ( + authority.source.clone(), + authority.agent_label.clone(), + authority.session_ref.clone(), + ) + }) + .or_else(|| { + self.persisted_agent_session.as_ref().and_then(|session| { + (crate::agent_resume::is_official_agent_source( + &session.source, + &session.agent, + ) && crate::detect::parse_agent_label(&session.agent) == agent) + .then(|| { + ( + session.source.clone(), + session.agent.clone(), + Some(session.session_ref.clone()), + ) + }) + }) + }); + if let Some((source, agent_label, session_ref)) = official_session { + self.hook_report_sequences.remove(&source); + self.suppress_full_lifecycle_hook_report_with_session_ref( + source, + agent_label, + session_ref, + FullLifecycleHookSuppressionReason::ProcessExit, + now, + ); + } + let cleared_hook_source = self.hook_authority.as_ref().and_then(|authority| { + (crate::detect::parse_agent_label(&authority.agent_label) == agent + && !newer_custom_authority) + .then(|| authority.source.clone()) + }); + if let Some(source) = cleared_hook_source { + self.hook_report_sequences.remove(&source); + self.hook_authority = None; + } + if !newer_custom_authority + && self + .persisted_agent_session + .as_ref() + .is_some_and(|session| { + crate::detect::parse_agent_label(&session.agent) == agent + }) + { + self.persisted_agent_session = None; + } + if let Some(agent) = agent { + let agent_label = crate::detect::agent_label(agent); + let mut cleared_metadata_sources = Vec::new(); + self.agent_metadata.retain(|source, metadata| { + let official_metadata = crate::agent_resume::is_official_agent_source( + &metadata.source, + agent_label, + ) || metadata.applies_to_source.as_deref().is_some_and( + |applies_to| { + crate::agent_resume::is_official_agent_source(applies_to, agent_label) + }, + ); + let matches_agent = + metadata.agent_label.as_deref() == Some(agent_label) || official_metadata; + let clear = matches_agent && (official_metadata || metadata.reported_at <= now); + if clear { + cleared_metadata_sources.push(source.clone()); + } + !clear + }); + for source in cleared_metadata_sources { + self.metadata_report_sequences.remove(&source); + self.metadata_report_agents.remove(&source); + self.metadata_token_sequence_sources.remove(&source); + } + let mut exited_generation_sources = Vec::new(); + self.metadata_report_agents.retain(|source, owner| { + if *owner == agent { + exited_generation_sources.push(source.clone()); + false + } else { + true + } + }); + for source in exited_generation_sources { + self.metadata_report_sequences.remove(&source); + self.metadata_token_sequence_sources.remove(&source); + } + } } if self.hook_authority_not_newer_than(now) && (self.hook_authority_conflicts_with_detected_agent(agent) @@ -444,26 +605,25 @@ impl TerminalState { seq: Option, now: Instant, ) -> Option { - if self.full_lifecycle_hook_report_is_suppressed(&source, &agent_label, &session_ref) { + if !crate::detect::full_lifecycle_hook_authority(&source, &agent_label) + && self.recent_agent_process_exit.is_some_and(|exit| { + crate::detect::parse_agent_label(&agent_label) == Some(exit.agent) + }) + { return None; } - if self.full_lifecycle_hook_report_matches_stale_session( + let reanchor_sequence = match self.route_full_lifecycle_hook_report( &source, &agent_label, + state, + message.as_deref(), &session_ref, + seq, + now, ) { - return None; - } - let reanchor_sequence = - self.full_lifecycle_hook_report_has_fresh_session_after_suppression( - &source, - &agent_label, - &session_ref, - ) || self.full_lifecycle_hook_report_has_fresh_session_after_stale_session( - &source, - &agent_label, - &session_ref, - ); + FullLifecycleHookReportRoute::Accept { reanchor_sequence } => reanchor_sequence, + FullLifecycleHookReportRoute::Ignore => return None, + }; if self.known_agent_label_conflicts_with_detected_agent(&agent_label) { return None; } @@ -514,7 +674,7 @@ impl TerminalState { FullLifecycleHookSuppressionReason::HookClear, ); } - if session_ref.is_some() { + if session_ref.is_some() || reanchor_sequence { if let Some(suppressed) = self.suppressed_full_lifecycle_hook_reports.remove(&source) { if let Some(suppressed_ref) = suppressed.session_ref { self.remember_stale_full_lifecycle_hook_session( @@ -611,6 +771,7 @@ impl TerminalState { agent_label, session_ref, reason, + Instant::now(), ); } } @@ -631,6 +792,7 @@ impl TerminalState { agent_label.to_string(), session_ref, reason, + Instant::now(), ); } } @@ -641,65 +803,143 @@ impl TerminalState { agent_label: String, session_ref: Option, reason: FullLifecycleHookSuppressionReason, + observed_at: Instant, ) { self.suppressed_full_lifecycle_hook_reports.insert( source, SuppressedFullLifecycleHookReport { agent_label, session_ref, - observed_at: Instant::now(), + observed_at, reason, + replacement_session_ref: None, + pending_replacement_report: None, }, ); } - fn full_lifecycle_hook_report_is_suppressed( - &self, + fn route_full_lifecycle_hook_report( + &mut self, source: &str, agent_label: &str, + state: AgentState, + message: Option<&str>, session_ref: &Option, - ) -> bool { + seq: Option, + reported_at: Instant, + ) -> FullLifecycleHookReportRoute { if !crate::detect::full_lifecycle_hook_authority(source, agent_label) { - return false; + return FullLifecycleHookReportRoute::Accept { + reanchor_sequence: false, + }; + } + if self.full_lifecycle_hook_report_matches_stale_session(source, agent_label, session_ref) { + return FullLifecycleHookReportRoute::Ignore; } - self.suppressed_full_lifecycle_hook_reports - .get(source) - .is_some_and(|suppressed| { - if suppressed.agent_label != agent_label { - return false; - } - if suppressed.reason == FullLifecycleHookSuppressionReason::ProcessExit { - return true; - } - match (&suppressed.session_ref, session_ref) { - (Some(suppressed_ref), Some(incoming_ref)) => incoming_ref == suppressed_ref, - (Some(_), None) => true, - (None, Some(_)) => false, - (None, None) => true, - } - }) - } - fn full_lifecycle_hook_report_has_fresh_session_after_suppression( - &self, - source: &str, - agent_label: &str, - session_ref: &Option, - ) -> bool { - if !crate::detect::full_lifecycle_hook_authority(source, agent_label) { - return false; - } - self.suppressed_full_lifecycle_hook_reports - .get(source) - .is_some_and(|suppressed| { - suppressed.agent_label == agent_label - && suppressed.reason != FullLifecycleHookSuppressionReason::ProcessExit - && matches!( - (&suppressed.session_ref, session_ref), - (Some(suppressed_ref), Some(incoming_ref)) - if incoming_ref != suppressed_ref - ) + let known_agent = crate::detect::parse_agent_label(agent_label); + let process_present = known_agent.is_some() + && self.detected_agent == known_agent + && self.recent_agent_process_exit.is_none(); + let session_anchored = self + .hook_authority + .as_ref() + .filter(|authority| authority.source == source && authority.agent_label == agent_label) + .and_then(|authority| authority.session_ref.as_ref()) + .or_else(|| { + self.persisted_agent_session + .as_ref() + .filter(|session| session.source == source && session.agent == agent_label) + .map(|session| &session.session_ref) }) + .is_some_and(|anchored| { + session_ref + .as_ref() + .is_none_or(|incoming| incoming == anchored) + }); + if let Some(suppressed) = self.suppressed_full_lifecycle_hook_reports.get(source) { + if suppressed.agent_label != agent_label { + return FullLifecycleHookReportRoute::Ignore; + } + if suppressed.reason == FullLifecycleHookSuppressionReason::HookClear { + let reanchor_sequence = matches!( + (&suppressed.session_ref, session_ref), + (Some(previous), Some(incoming)) if previous != incoming + ); + return if reanchor_sequence { + FullLifecycleHookReportRoute::Accept { + reanchor_sequence: true, + } + } else { + FullLifecycleHookReportRoute::Ignore + }; + } + } + + if process_present + && session_anchored + && !self + .suppressed_full_lifecycle_hook_reports + .contains_key(source) + { + return FullLifecycleHookReportRoute::Accept { + reanchor_sequence: self + .full_lifecycle_hook_report_has_fresh_session_after_stale_session( + source, + agent_label, + session_ref, + ), + }; + } + + let Some(session_ref) = session_ref.clone() else { + return FullLifecycleHookReportRoute::Ignore; + }; + let Some(seq) = seq else { + return FullLifecycleHookReportRoute::Ignore; + }; + if self + .hook_report_sequences + .get(source) + .is_some_and(|previous| seq <= *previous) + { + return FullLifecycleHookReportRoute::Ignore; + } + + let previous_session_ref = self + .persisted_agent_session + .as_ref() + .filter(|session| session.source == source && session.agent == agent_label) + .map(|session| session.session_ref.clone()); + let suppressed = self + .suppressed_full_lifecycle_hook_reports + .entry(source.to_string()) + .or_insert_with(|| SuppressedFullLifecycleHookReport { + agent_label: agent_label.to_string(), + session_ref: previous_session_ref, + observed_at: reported_at, + reason: FullLifecycleHookSuppressionReason::ProcessExit, + replacement_session_ref: None, + pending_replacement_report: None, + }); + let replace_pending = suppressed + .pending_replacement_report + .as_ref() + .is_none_or(|pending| seq > pending.seq); + if replace_pending { + suppressed.pending_replacement_report = Some(PendingFullLifecycleHookReport { + authority: HookAuthority { + source: source.to_string(), + agent_label: agent_label.to_string(), + state, + message: message.map(str::to_string), + reported_at, + session_ref: Some(session_ref), + }, + seq, + }); + } + FullLifecycleHookReportRoute::Ignore } fn full_lifecycle_hook_report_matches_stale_session( @@ -801,22 +1041,59 @@ impl TerminalState { } let detected_label = crate::detect::agent_label(detected_agent); let mut stale_sessions = Vec::new(); + let mut validated_replacement_sessions = Vec::new(); self.suppressed_full_lifecycle_hook_reports .retain(|source, suppressed| { let should_clear = crate::detect::parse_agent_label(&suppressed.agent_label) == Some(detected_agent); - if should_clear { - if let Some(session_ref) = suppressed.session_ref.clone() { - stale_sessions.push(( - source.clone(), - StaleFullLifecycleHookSession { - agent_label: suppressed.agent_label.clone(), - session_ref, - }, - )); - } + if !should_clear { + return true; } - !should_clear + if suppressed.reason == FullLifecycleHookSuppressionReason::ProcessExit { + if let Some(session_ref) = suppressed.replacement_session_ref.take() { + if let Some(exited_session_ref) = suppressed + .session_ref + .as_ref() + .filter(|exited_session_ref| *exited_session_ref != &session_ref) + .cloned() + { + stale_sessions.push(( + source.clone(), + StaleFullLifecycleHookSession { + agent_label: suppressed.agent_label.clone(), + session_ref: exited_session_ref, + }, + )); + } + let session_start_seq = self.hook_report_sequences.get(source).copied(); + let pending = + suppressed + .pending_replacement_report + .take() + .filter(|pending| { + pending.authority.session_ref.as_ref() == Some(&session_ref) + && session_start_seq.is_none_or(|seq| pending.seq > seq) + }); + validated_replacement_sessions.push(( + source.clone(), + suppressed.agent_label.clone(), + session_ref, + pending, + )); + return false; + } + return true; + } + if let Some(session_ref) = suppressed.session_ref.clone() { + stale_sessions.push(( + source.clone(), + StaleFullLifecycleHookSession { + agent_label: suppressed.agent_label.clone(), + session_ref, + }, + )); + } + false }); for (source, stale_session) in stale_sessions { self.remember_stale_full_lifecycle_hook_session( @@ -826,8 +1103,24 @@ impl TerminalState { ); } self.hook_report_sequences.retain(|source, _| { - !crate::detect::full_lifecycle_hook_authority(source, detected_label) + validated_replacement_sessions + .iter() + .any(|(validated_source, _, _, _)| validated_source == source) + || !crate::detect::full_lifecycle_hook_authority(source, detected_label) }); + for (source, agent_label, session_ref, pending) in validated_replacement_sessions { + self.forget_stale_full_lifecycle_hook_session(&source, &agent_label, &session_ref); + self.reconcile_agent_name_owner(&agent_label, Some(&session_ref)); + self.persisted_agent_session = Some(crate::agent_resume::PersistedAgentSession { + source: source.clone(), + agent: agent_label, + session_ref, + }); + if let Some(pending) = pending { + self.hook_report_sequences.insert(source, pending.seq); + self.hook_authority = Some(pending.authority); + } + } } fn remember_stale_full_lifecycle_hook_session( @@ -986,7 +1279,8 @@ impl TerminalState { "herdr:codex", "codex", Some("startup" | "clear" | "resume" | "compact") - ) | ("herdr:opencode", "opencode", Some("new")) + ) | ("herdr:mastracode", "mastracode", Some("startup")) + | ("herdr:opencode", "opencode", Some("new")) | ("herdr:pi", "pi", Some("new" | "resume" | "fork")) | ( "herdr:omp", @@ -1029,6 +1323,86 @@ impl TerminalState { session_start_source: Option, ) -> Option { let session_ref = session_ref?; + let known_agent = crate::detect::parse_agent_label(&agent_label); + let process_present = known_agent.is_some() + && self.detected_agent == known_agent + && self.recent_agent_process_exit.is_none(); + let full_lifecycle_source = + crate::detect::full_lifecycle_hook_authority(&source, &agent_label); + let generation_gated = self + .suppressed_full_lifecycle_hook_reports + .get(&source) + .is_some_and(|suppressed| { + suppressed.agent_label == agent_label + && suppressed.reason != FullLifecycleHookSuppressionReason::HookClear + }); + let session_anchored = self.hook_authority.as_ref().is_some_and(|authority| { + authority.source == source + && authority.agent_label == agent_label + && authority.session_ref.is_some() + }) || self.persisted_agent_session_matches(&source, &agent_label); + if full_lifecycle_source && (!process_present || generation_gated || !session_anchored) { + if !Self::session_start_source_is_recognized(session_start_source.as_deref()) { + return None; + } + let seq = seq?; + if self + .hook_report_sequences + .get(&source) + .is_some_and(|previous| seq <= *previous) + { + return None; + } + + let previous_agent_label = self.effective_agent_label().map(str::to_string); + let previous_known_agent = self.effective_known_agent(); + let previous_state = self.state; + let now = Instant::now(); + let previous_presentation = + self.effective_presentation_for_state_at(previous_state, now); + let previous_session = self.current_session_identity_for_persistence(); + let suppressed = self + .suppressed_full_lifecycle_hook_reports + .entry(source.clone()) + .or_insert_with(|| SuppressedFullLifecycleHookReport { + agent_label: agent_label.clone(), + session_ref: None, + observed_at: now, + reason: FullLifecycleHookSuppressionReason::ProcessExit, + replacement_session_ref: None, + pending_replacement_report: None, + }); + if suppressed.replacement_session_ref.as_ref() != Some(&session_ref) { + if suppressed + .pending_replacement_report + .as_ref() + .is_some_and(|pending| { + pending.authority.session_ref.as_ref() != Some(&session_ref) + }) + { + suppressed.pending_replacement_report = None; + } + suppressed.replacement_session_ref = Some(session_ref); + } + self.hook_report_sequences.insert(source.clone(), seq); + + if process_present { + self.clear_full_lifecycle_hook_suppression_for_detected_agent(None, known_agent); + let current_session = self.current_session_identity_for_persistence(); + return Some(TerminalStateMutation { + effective_state_change: self.recompute_effective_state( + previous_agent_label, + previous_known_agent, + previous_state, + previous_presentation, + now, + ), + session_ref_changed: previous_session != current_session, + agent_released: false, + }); + } + return None; + } if !self.accept_hook_report(&source, seq) { return None; } @@ -1193,6 +1567,13 @@ impl TerminalState { .as_ref() .map(|authority| authority.source.clone()) }); + let should_clear = self + .hook_authority + .as_ref() + .is_some_and(|authority| source.is_none_or(|source| authority.source == source)); + if !should_clear { + return None; + } if let Some(source) = sequence_source.as_deref() { if !self.accept_hook_report(source, seq) { return None; @@ -1205,13 +1586,6 @@ impl TerminalState { let previous_state = self.state; let previous_presentation = self.effective_presentation_for_state_at(previous_state, now); let previous_session = self.current_session_identity_for_persistence(); - let should_clear = self - .hook_authority - .as_ref() - .is_some_and(|authority| source.is_none_or(|source| authority.source == source)); - if !should_clear { - return None; - } self.suppress_current_full_lifecycle_hook_authority( FullLifecycleHookSuppressionReason::HookClear, ); @@ -1230,27 +1604,12 @@ impl TerminalState { }) } - #[cfg(test)] - pub fn release_agent( - &mut self, - source: &str, - agent_label: &str, - seq: Option, - ) -> Option { - self.release_agent_with_mutation(source, agent_label, seq) - .and_then(|mutation| mutation.effective_state_change) - } - pub fn release_agent_with_mutation( &mut self, source: &str, agent_label: &str, seq: Option, ) -> Option { - if !self.accept_hook_report(source, seq) { - return None; - } - if self.hook_authority.as_ref().is_some_and(|authority| { authority.agent_label != agent_label || authority.source != source }) { @@ -1262,10 +1621,17 @@ impl TerminalState { if !matches_current_agent && !matches_persisted_session { return None; } + if !self.accept_hook_report(source, seq) { + return None; + } let preserve_foreign_persisted_session = self .persisted_agent_session .as_ref() .is_some_and(|session| session.source != source || session.agent != agent_label); + let process_owns_agent = + crate::detect::parse_agent_label(agent_label).is_some_and(|agent| { + self.detected_agent == Some(agent) && self.recent_agent_process_exit.is_none() + }); let now = Instant::now(); let previous_agent_label = self.effective_agent_label().map(str::to_string); @@ -1278,12 +1644,14 @@ impl TerminalState { agent_label, FullLifecycleHookSuppressionReason::HookClear, ); - self.detected_agent = None; - self.fallback_state = AgentState::Unknown; - self.fallback_visible_blocker = false; - self.fallback_observed_at = None; + if !process_owns_agent { + self.detected_agent = None; + self.fallback_state = AgentState::Unknown; + self.fallback_visible_blocker = false; + self.fallback_observed_at = None; + self.clear_agent_name(); + } self.hook_authority = None; - self.clear_agent_name(); if !preserve_foreign_persisted_session { self.persisted_agent_session = None; } @@ -1297,22 +1665,33 @@ impl TerminalState { now, ), session_ref_changed: previous_session != current_session, - agent_released: true, + agent_released: !process_owns_agent, }) } + fn hook_authority_is_effective(&self, authority: &HookAuthority) -> bool { + !crate::detect::full_lifecycle_hook_authority(&authority.source, &authority.agent_label) + || crate::detect::parse_agent_label(&authority.agent_label).is_none_or(|agent| { + self.detected_agent == Some(agent) && self.recent_agent_process_exit.is_none() + }) + } + pub fn effective_agent_label(&self) -> Option<&str> { self.hook_authority .as_ref() + .filter(|authority| self.hook_authority_is_effective(authority)) .map(|authority| authority.agent_label.as_str()) - .or_else(|| self.detected_agent.map(crate::detect::agent_label)) + .or_else(|| { + self.recent_agent_process_exit + .is_none() + .then(|| self.detected_agent.map(crate::detect::agent_label)) + .flatten() + }) } pub fn effective_known_agent(&self) -> Option { - if let Some(authority) = &self.hook_authority { - return crate::detect::parse_agent_label(&authority.agent_label); - } - self.detected_agent + self.effective_agent_label() + .and_then(crate::detect::parse_agent_label) } pub(crate) fn unchanged_effective_state_change_at(&self, now: Instant) -> EffectiveStateChange { @@ -1351,7 +1730,11 @@ impl TerminalState { fn live_full_lifecycle_hook_authority(&self) -> bool { self.hook_authority.as_ref().is_some_and(|authority| { - crate::detect::full_lifecycle_hook_authority(&authority.source, &authority.agent_label) + self.hook_authority_is_effective(authority) + && crate::detect::full_lifecycle_hook_authority( + &authority.source, + &authority.agent_label, + ) }) } @@ -1533,13 +1916,14 @@ impl TerminalState { self.hook_authority = None; self.persisted_agent_session = None; self.agent_metadata.clear(); + self.metadata_report_agents.clear(); self.suppressed_full_lifecycle_hook_reports.clear(); self.stale_full_lifecycle_hook_sessions.clear(); self.state = AgentState::Unknown; self.last_agent_state_change_seq = None; self.launch_argv = None; self.respawn_shell_on_exit = false; - self.recent_agent_process_exit_at = None; + self.recent_agent_process_exit = None; self.pending_agent_resume_plan = None; self.clear_agent_name(); } @@ -1612,6 +1996,7 @@ impl TerminalState { } else { self.hook_authority .as_ref() + .filter(|authority| self.hook_authority_is_effective(authority)) .map(|authority| authority.state) .unwrap_or(self.fallback_state) }; @@ -1663,6 +2048,21 @@ mod tests { .to_string() } + fn anchor_full_lifecycle_session( + terminal: &mut TerminalState, + agent: Agent, + source: &str, + agent_label: &str, + session_ref: crate::agent_resume::AgentSessionRef, + ) { + terminal.set_detected_state(Some(agent), terminal.fallback_state); + terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { + source: source.into(), + agent: agent_label.into(), + session_ref, + }); + } + #[test] fn managed_agent_activates_only_after_matching_settled_detection() { let mut terminal = test_terminal(); @@ -1740,6 +2140,13 @@ mod tests { fn hook_authority_overrides_fallback_for_same_agent() { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + anchor_full_lifecycle_session( + &mut terminal, + Agent::Pi, + "herdr:pi", + "pi", + crate::agent_resume::AgentSessionRef::path(test_session_path("root.jsonl")).unwrap(), + ); terminal.set_hook_authority( "herdr:pi".into(), "pi".into(), @@ -1776,6 +2183,13 @@ mod tests { fn omp_hook_authority_overrides_detected_fallback() { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Omp), AgentState::Idle); + anchor_full_lifecycle_session( + &mut terminal, + Agent::Omp, + "herdr:omp", + "omp", + crate::agent_resume::AgentSessionRef::id("omp-root").unwrap(), + ); terminal.set_hook_authority( "herdr:omp".into(), "omp".into(), @@ -1837,6 +2251,45 @@ mod tests { } } + #[test] + fn startup_session_claim_activates_full_lifecycle_integrations() { + for (agent, source, label) in [ + (Agent::Kimi, "herdr:kimi", "kimi"), + (Agent::Kilo, "herdr:kilo", "kilo"), + (Agent::Hermes, "herdr:hermes", "hermes"), + ] { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(agent), AgentState::Idle); + let session_ref = crate::agent_resume::AgentSessionRef::id(format!("{label}-root")); + + let session = terminal.set_agent_session_ref_for_session_start( + source.into(), + label.into(), + session_ref.clone(), + Some(10), + Some("startup".into()), + ); + let working = terminal.set_hook_authority_with_session_ref( + source.into(), + label.into(), + AgentState::Working, + None, + session_ref, + Some(11), + ); + + assert!( + session.is_some(), + "{label} should accept its startup session" + ); + assert!( + working.is_some(), + "{label} should accept state after startup" + ); + assert_eq!(terminal.state, AgentState::Working); + } + } + #[test] fn pi_session_replacement_reports_reanchor_full_lifecycle_authority() { for reason in ["new", "resume", "fork"] { @@ -1995,6 +2448,13 @@ mod tests { let old_session = test_session_path("pi-current.jsonl"); let new_session = test_session_path("pi-unexpected.jsonl"); terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + anchor_full_lifecycle_session( + &mut terminal, + Agent::Pi, + "herdr:pi", + "pi", + crate::agent_resume::AgentSessionRef::path(old_session.clone()).unwrap(), + ); terminal.set_hook_authority_with_session_ref( "herdr:pi".into(), "pi".into(), @@ -2094,91 +2554,6 @@ mod tests { assert_eq!(terminal.state, AgentState::Blocked); } - #[test] - fn process_exit_clears_matching_full_lifecycle_hook_authority() { - let now = Instant::now(); - let mut terminal = test_terminal(); - terminal.set_detected_state(Some(Agent::Pi), AgentState::Working); - terminal.set_hook_authority_at( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - None, - Some(10), - now, - ); - - let change = terminal.set_detected_state_with_screen_signals_at( - Some(Agent::Pi), - AgentState::Idle, - false, - true, - false, - true, - now + Duration::from_millis(1), - ); - - assert!(terminal.hook_authority.is_none()); - assert_eq!(terminal.state, AgentState::Idle); - assert_eq!( - change.effective_state_change.unwrap().previous_state, - AgentState::Working - ); - - let stale = terminal.set_hook_authority_at( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - None, - Some(9), - now + Duration::from_millis(2), - ); - - assert!(stale.is_none()); - assert_eq!(terminal.state, AgentState::Idle); - } - - #[test] - fn late_full_lifecycle_hook_after_process_exit_does_not_reacquire_authority() { - let now = Instant::now(); - let mut terminal = test_terminal(); - terminal.set_detected_state(Some(Agent::Pi), AgentState::Working); - terminal.set_hook_authority_at( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - None, - Some(20), - now, - ); - - terminal.set_detected_state_with_screen_signals_at( - Some(Agent::Pi), - AgentState::Idle, - false, - true, - false, - true, - now + Duration::from_millis(1), - ); - let late = terminal.set_hook_authority_at( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - None, - Some(21), - now + Duration::from_millis(2), - ); - - assert!(late.is_none()); - assert!(terminal.hook_authority.is_none()); - assert_eq!(terminal.state, AgentState::Idle); - } - #[test] fn late_full_lifecycle_hook_with_same_session_after_process_exit_does_not_reacquire_authority() { @@ -2218,172 +2593,17 @@ mod tests { assert_eq!(terminal.state, AgentState::Idle); } - #[test] - fn late_full_lifecycle_hook_after_release_does_not_reacquire_authority() { - let mut terminal = test_terminal(); - terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); - terminal.set_hook_authority( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - Some(20), - ); - - terminal.release_agent("herdr:pi", "pi", Some(21)); - let late = terminal.set_hook_authority( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - Some(22), - ); - - assert!(late.is_none()); - assert!(terminal.hook_authority.is_none()); - assert_eq!(terminal.state, AgentState::Unknown); - } - - #[test] - fn late_full_lifecycle_hook_with_same_session_after_release_does_not_reacquire_authority() { - let mut terminal = test_terminal(); - let session_path = test_session_path("pi.jsonl"); - terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); - terminal.set_hook_authority_with_session_ref( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - crate::agent_resume::AgentSessionRef::path(session_path.clone()), - Some(20), - ); - - terminal.release_agent("herdr:pi", "pi", Some(21)); - let late = terminal.set_hook_authority_with_session_ref( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - crate::agent_resume::AgentSessionRef::path(session_path), - Some(22), - ); - - assert!(late.is_none()); - assert!(terminal.hook_authority.is_none()); - assert_eq!(terminal.state, AgentState::Unknown); - } - - #[test] - fn changed_session_ref_allows_full_lifecycle_hook_after_suppression() { - let mut terminal = test_terminal(); - terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); - terminal.set_hook_authority_with_session_ref( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - crate::agent_resume::AgentSessionRef::path(test_session_path("old.jsonl")), - Some(20), - ); - terminal.release_agent("herdr:pi", "pi", Some(21)); - - let fresh = terminal.set_hook_authority_with_session_ref( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - crate::agent_resume::AgentSessionRef::path(test_session_path("new.jsonl")), - Some(22), - ); - - assert!(fresh.is_some()); - assert!(terminal.hook_authority.is_some()); - assert_eq!(terminal.state, AgentState::Working); - } - - #[test] - fn changed_session_ref_reanchors_hook_sequence_after_release() { - let mut terminal = test_terminal(); - terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); - terminal.set_hook_authority_with_session_ref( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - crate::agent_resume::AgentSessionRef::path(test_session_path("old.jsonl")), - Some(1000), - ); - terminal.release_agent("herdr:pi", "pi", Some(3000)); - - let fresh = terminal.set_hook_authority_with_session_ref( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - crate::agent_resume::AgentSessionRef::path(test_session_path("new.jsonl")), - Some(1500), - ); - - assert!(fresh.is_some()); - assert!(terminal.hook_authority.is_some()); - assert_eq!(terminal.state, AgentState::Working); - } - - #[test] - fn stale_session_suppression_survives_multiple_release_generations() { - let mut terminal = test_terminal(); - let session_a = test_session_path("release-generation-a.jsonl"); - let session_b = test_session_path("release-generation-b.jsonl"); - let session_c = test_session_path("release-generation-c.jsonl"); - terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); - terminal.set_hook_authority_with_session_ref( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - crate::agent_resume::AgentSessionRef::path(session_a.clone()), - Some(1000), - ); - terminal.release_agent("herdr:pi", "pi", Some(2000)); - - let generation_b = terminal.set_hook_authority_with_session_ref( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - crate::agent_resume::AgentSessionRef::path(session_b), - Some(1500), - ); - assert!(generation_b.is_some()); - terminal.release_agent("herdr:pi", "pi", Some(3000)); - - let late_generation_a = terminal.set_hook_authority_with_session_ref( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - crate::agent_resume::AgentSessionRef::path(session_a), - Some(2500), - ); - let generation_c = terminal.set_hook_authority_with_session_ref( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - crate::agent_resume::AgentSessionRef::path(session_c), - Some(2500), - ); - - assert!(late_generation_a.is_none()); - assert!(generation_c.is_some()); - assert!(terminal.hook_authority.is_some()); - assert_eq!(terminal.state, AgentState::Working); - } - #[test] fn live_full_lifecycle_hook_rejects_different_session_ref_for_same_source() { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + anchor_full_lifecycle_session( + &mut terminal, + Agent::Pi, + "herdr:pi", + "pi", + crate::agent_resume::AgentSessionRef::path(test_session_path("one.jsonl")).unwrap(), + ); terminal.set_hook_authority_with_session_ref( "herdr:pi".into(), "pi".into(), @@ -2414,96 +2634,6 @@ mod tests { ); } - #[test] - fn fresh_detected_process_allows_full_lifecycle_hook_after_suppression() { - let mut terminal = test_terminal(); - terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); - terminal.set_hook_authority( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - Some(20), - ); - terminal.release_agent("herdr:pi", "pi", Some(21)); - let now = Instant::now(); - - terminal.set_detected_state_with_screen_signals_at( - Some(Agent::Pi), - AgentState::Unknown, - false, - false, - false, - false, - now, - ); - let fresh = terminal.set_hook_authority( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - Some(22), - ); - - assert!(fresh.is_some()); - assert!(terminal.hook_authority.is_some()); - assert_eq!(terminal.state, AgentState::Working); - } - - #[test] - fn fresh_detected_process_reanchors_hook_sequence_after_process_exit() { - let mut terminal = test_terminal(); - terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); - terminal.set_hook_authority( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - Some(1000), - ); - let process_exit_seen_at = Instant::now() + Duration::from_millis(1); - terminal.set_detected_state_with_screen_signals_at( - Some(Agent::Pi), - AgentState::Idle, - false, - true, - false, - true, - process_exit_seen_at, - ); - - let fresh_process_seen_at = process_exit_seen_at + Duration::from_millis(1); - terminal.set_detected_state_with_screen_signals_at( - None, - AgentState::Unknown, - false, - false, - false, - false, - fresh_process_seen_at, - ); - terminal.set_detected_state_with_screen_signals_at( - Some(Agent::Pi), - AgentState::Unknown, - false, - false, - false, - false, - fresh_process_seen_at + Duration::from_millis(1), - ); - let fresh = terminal.set_hook_authority( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - Some(500), - ); - - assert!(fresh.is_some()); - assert!(terminal.hook_authority.is_some()); - assert_eq!(terminal.state, AgentState::Working); - } - #[test] fn fresh_detected_process_keeps_old_session_suppressed_after_process_exit() { let mut terminal = test_terminal(); @@ -2562,16 +2692,240 @@ mod tests { "pi".into(), AgentState::Working, None, - crate::agent_resume::AgentSessionRef::path(new_session), - Some(500), + crate::agent_resume::AgentSessionRef::path(new_session.clone()), + Some(501), ); assert!(late_old.is_none()); - assert!(fresh_new.is_some()); + assert!(fresh_new.is_none()); + terminal + .set_agent_session_ref_for_session_start( + "herdr:pi".into(), + "pi".into(), + crate::agent_resume::AgentSessionRef::path(new_session), + Some(400), + Some("startup".into()), + ) + .expect("fresh session should activate the buffered report"); assert!(terminal.hook_authority.is_some()); assert_eq!(terminal.state, AgentState::Working); } + #[test] + fn rapid_restart_replays_reports_that_arrive_before_process_evidence() { + let mut terminal = test_terminal(); + let session_path = test_session_path("reports-before-process-evidence.jsonl"); + let now = Instant::now(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + terminal.set_hook_authority_at( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + crate::agent_resume::AgentSessionRef::path(session_path.clone()), + Some(1000), + now, + ); + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Idle, + false, + true, + false, + true, + now + Duration::from_millis(1), + ); + + let lower_sequence = terminal.set_hook_authority_at( + "herdr:pi".into(), + "pi".into(), + AgentState::Idle, + None, + crate::agent_resume::AgentSessionRef::path(session_path.clone()), + Some(1001), + now + Duration::from_millis(2), + ); + let missing_sequence = terminal.set_hook_authority_at( + "herdr:pi".into(), + "pi".into(), + AgentState::Idle, + None, + crate::agent_resume::AgentSessionRef::path(session_path.clone()), + None, + now + Duration::from_millis(3), + ); + let buffered_working = terminal.set_hook_authority_at( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + crate::agent_resume::AgentSessionRef::path(session_path.clone()), + Some(2001), + now + Duration::from_millis(4), + ); + let startup = terminal.set_agent_session_ref_for_session_start( + "herdr:pi".into(), + "pi".into(), + crate::agent_resume::AgentSessionRef::path(session_path), + Some(2000), + Some("startup".into()), + ); + assert!(startup.is_none()); + assert!(lower_sequence.is_none()); + assert!(missing_sequence.is_none()); + assert!(buffered_working.is_none()); + assert!(!terminal.full_lifecycle_hook_authority_active()); + + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Idle, + false, + true, + false, + false, + now + Duration::from_millis(5), + ); + + assert!(terminal.full_lifecycle_hook_authority_active()); + assert_eq!(terminal.state, AgentState::Working); + } + + #[test] + fn process_exit_discards_unclaimed_buffered_state_from_that_generation() { + let mut terminal = test_terminal(); + let old_session = test_session_path("buffered-exit-old.jsonl"); + let shared_session = test_session_path("buffered-exit-shared.jsonl"); + let now = Instant::now(); + anchor_full_lifecycle_session( + &mut terminal, + Agent::Pi, + "herdr:pi", + "pi", + crate::agent_resume::AgentSessionRef::path(old_session.clone()).unwrap(), + ); + terminal.set_hook_authority_at( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + crate::agent_resume::AgentSessionRef::path(old_session), + Some(1000), + now, + ); + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Idle, + false, + true, + false, + true, + now + Duration::from_millis(1), + ); + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Idle, + false, + true, + false, + false, + now + Duration::from_millis(2), + ); + terminal.set_hook_authority_at( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + crate::agent_resume::AgentSessionRef::path(shared_session.clone()), + Some(500), + now + Duration::from_millis(3), + ); + + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Idle, + false, + true, + false, + true, + now + Duration::from_millis(4), + ); + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Idle, + false, + true, + false, + false, + now + Duration::from_millis(5), + ); + terminal + .set_agent_session_ref_for_session_start( + "herdr:pi".into(), + "pi".into(), + crate::agent_resume::AgentSessionRef::path(shared_session), + Some(100), + Some("startup".into()), + ) + .expect("new generation session claim"); + + assert!(terminal.hook_authority.is_none()); + assert_eq!(terminal.state, AgentState::Idle); + } + + #[test] + fn queued_fresh_process_evidence_uses_process_exit_observation_time() { + let mut terminal = test_terminal(); + let session_path = test_session_path("queued-after-process-exit.jsonl"); + let process_exit_at = Instant::now() - Duration::from_secs(1); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + terminal.set_hook_authority_at( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + crate::agent_resume::AgentSessionRef::path(session_path.clone()), + Some(1000), + process_exit_at - Duration::from_millis(1), + ); + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Idle, + false, + true, + false, + true, + process_exit_at, + ); + + terminal.set_detected_state_with_screen_signals_at( + None, + AgentState::Unknown, + false, + false, + false, + false, + process_exit_at + Duration::from_millis(1), + ); + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Idle, + false, + true, + false, + false, + process_exit_at + Duration::from_millis(2), + ); + let startup = terminal.set_agent_session_ref_for_session_start( + "herdr:pi".into(), + "pi".into(), + crate::agent_resume::AgentSessionRef::path(session_path), + Some(2000), + Some("startup".into()), + ); + + assert!(startup.is_some()); + } + #[test] fn different_session_after_process_exit_waits_for_fresh_process_evidence() { let mut terminal = test_terminal(); @@ -2629,14 +2983,12 @@ mod tests { false, now + Duration::from_millis(4), ); - let fresh_new = terminal.set_hook_authority_at( + let fresh_new = terminal.set_agent_session_ref_for_session_start( "herdr:pi".into(), "pi".into(), - AgentState::Working, - None, crate::agent_resume::AgentSessionRef::path(new_session), - Some(500), - now + Duration::from_millis(5), + Some(400), + Some("startup".into()), ); assert!(fresh_new.is_some()); @@ -2709,279 +3061,66 @@ mod tests { Some(500), now + Duration::from_millis(5), ); - - assert!(fresh_without_session.is_some()); - assert!(terminal.hook_authority.is_some()); - assert_eq!(terminal.state, AgentState::Working); - } - - #[test] - fn stale_session_suppression_survives_multiple_process_generations() { - let mut terminal = test_terminal(); - let session_a = test_session_path("generation-a.jsonl"); - let session_b = test_session_path("generation-b.jsonl"); - let session_c = test_session_path("generation-c.jsonl"); - let now = Instant::now(); - terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); - terminal.set_hook_authority_at( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - crate::agent_resume::AgentSessionRef::path(session_a.clone()), - Some(1000), - now, - ); - - terminal.set_detected_state_with_screen_signals_at( - Some(Agent::Pi), - AgentState::Idle, - false, - true, - false, - true, - now + Duration::from_millis(1), - ); - terminal.set_detected_state_with_screen_signals_at( - None, - AgentState::Unknown, - false, - false, - false, - false, - now + Duration::from_millis(2), - ); - terminal.set_detected_state_with_screen_signals_at( - Some(Agent::Pi), - AgentState::Unknown, - false, - false, - false, - false, - now + Duration::from_millis(3), - ); - let generation_b = terminal.set_hook_authority_at( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - crate::agent_resume::AgentSessionRef::path(session_b), - Some(500), - now + Duration::from_millis(4), - ); - assert!(generation_b.is_some()); - - terminal.set_detected_state_with_screen_signals_at( - Some(Agent::Pi), - AgentState::Idle, - false, - true, - false, - true, - now + Duration::from_millis(5), - ); - terminal.set_detected_state_with_screen_signals_at( - None, - AgentState::Unknown, - false, - false, - false, - false, - now + Duration::from_millis(6), - ); - terminal.set_detected_state_with_screen_signals_at( - Some(Agent::Pi), - AgentState::Unknown, - false, - false, - false, - false, - now + Duration::from_millis(7), - ); - - let late_generation_a = terminal.set_hook_authority_at( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - crate::agent_resume::AgentSessionRef::path(session_a), - Some(250), - now + Duration::from_millis(8), - ); - let generation_c = terminal.set_hook_authority_at( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - crate::agent_resume::AgentSessionRef::path(session_c), - Some(250), - now + Duration::from_millis(9), - ); - - assert!(late_generation_a.is_none()); - assert!(generation_c.is_some()); - assert!(terminal.hook_authority.is_some()); - assert_eq!(terminal.state, AgentState::Working); - } - - #[test] - fn release_suppression_ignores_same_agent_idle_publish() { - let now = Instant::now(); - let mut terminal = test_terminal(); - terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); - terminal.set_hook_authority( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - Some(20), - ); - terminal.release_agent("herdr:pi", "pi", Some(21)); - - let change = terminal.set_detected_state_with_screen_signals_at( - Some(Agent::Pi), - AgentState::Idle, - false, - true, - false, - false, - now, - ); - let late = terminal.set_hook_authority( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - Some(22), - ); - - assert!(change.effective_state_change.is_none()); - assert!(late.is_none()); - assert_eq!(terminal.detected_agent, None); - assert_eq!(terminal.state, AgentState::Unknown); - } - - #[test] - fn fresh_session_ref_allows_full_lifecycle_hook_after_suppression() { - let mut terminal = test_terminal(); - terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); - terminal.set_hook_authority( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - Some(20), - ); - terminal.release_agent("herdr:pi", "pi", Some(21)); - - let fresh = terminal.set_hook_authority_with_session_ref( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - crate::agent_resume::AgentSessionRef::id("fresh-session"), - Some(22), - ); - - assert!(fresh.is_some()); - assert!(terminal.hook_authority.is_some()); - assert_eq!(terminal.state, AgentState::Working); - } - - // Regression for #614: a same-pane restart must reacquire lifecycle authority. - #[test] - fn omp_reacquires_full_lifecycle_hook_after_release_with_fresh_session_ref() { - assert_full_lifecycle_hook_reacquires_after_release_with_fresh_session_ref( - "herdr:omp", - "omp", - ); - } - - #[test] - fn mastracode_reacquires_full_lifecycle_hook_after_release_with_fresh_session_ref() { - assert_full_lifecycle_hook_reacquires_after_release_with_fresh_session_ref( - "herdr:mastracode", - "mastracode", - ); - } - - #[test] - fn mastracode_lifecycle_report_replaces_restored_thread_ref() { - let mut terminal = test_terminal(); - terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { - source: "herdr:mastracode".into(), - agent: "mastracode".into(), - session_ref: crate::agent_resume::AgentSessionRef::id("mastracode-old").unwrap(), - }); - - let mutation = terminal - .set_hook_authority_with_session_ref( - "herdr:mastracode".into(), - "mastracode".into(), - AgentState::Working, - None, - crate::agent_resume::AgentSessionRef::id("mastracode-new"), - Some(20), - ) - .expect("fresh MastraCode thread should replace restored thread id"); - - assert!(mutation.session_ref_changed); - assert_eq!( - terminal.current_session_identity_for_persistence(), - Some(( - "herdr:mastracode".into(), - "mastracode".into(), - crate::agent_resume::AgentSessionRefKind::Id, - "mastracode-new".into() - )) - ); - } - - fn assert_full_lifecycle_hook_reacquires_after_release_with_fresh_session_ref( - source: &str, - agent_label: &str, - ) { - let mut terminal = test_terminal(); - let old_session = format!("{agent_label}-old"); - let new_session = format!("{agent_label}-new"); - - terminal.set_hook_authority_with_session_ref( - source.into(), - agent_label.into(), - AgentState::Working, - None, - crate::agent_resume::AgentSessionRef::id(&old_session), - Some(20), - ); - terminal.release_agent(source, agent_label, Some(21)); - - // A late report from the released run keeps its old session ref and stays - // suppressed, so a just-exited agent cannot resurrect the pane. - let stale = terminal.set_hook_authority_with_session_ref( - source.into(), - agent_label.into(), - AgentState::Working, - None, - crate::agent_resume::AgentSessionRef::id(&old_session), - Some(22), - ); - assert!(stale.is_none()); + assert!(fresh_without_session.is_none()); assert!(terminal.hook_authority.is_none()); - // A fresh run carries a new session ref and reacquires authority. - let fresh = terminal.set_hook_authority_with_session_ref( - source.into(), - agent_label.into(), + terminal + .set_agent_session_ref_for_session_start( + "herdr:pi".into(), + "pi".into(), + crate::agent_resume::AgentSessionRef::path(test_session_path( + "fresh-after-nosession-process-exit.jsonl", + )), + Some(600), + Some("startup".into()), + ) + .expect("fresh root session should claim the process generation"); + let child_update = terminal.set_hook_authority_at( + "herdr:pi".into(), + "pi".into(), AgentState::Working, None, - crate::agent_resume::AgentSessionRef::id(&new_session), - Some(23), + None, + Some(601), + now + Duration::from_millis(6), ); - assert!(fresh.is_some()); - assert!(terminal.hook_authority.is_some()); + + assert!(child_update.is_some()); assert_eq!(terminal.state, AgentState::Working); } + #[test] + fn mastracode_session_start_replaces_current_root_session() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Mastracode), AgentState::Idle); + terminal + .set_agent_session_ref_for_session_start( + "herdr:mastracode".into(), + "mastracode".into(), + crate::agent_resume::AgentSessionRef::id("mastracode-old"), + Some(20), + Some("startup".into()), + ) + .expect("initial root session"); + + let replacement = terminal.set_agent_session_ref_for_session_start( + "herdr:mastracode".into(), + "mastracode".into(), + crate::agent_resume::AgentSessionRef::id("mastracode-new"), + Some(21), + Some("startup".into()), + ); + + assert!(replacement.is_some_and(|mutation| mutation.session_ref_changed)); + assert_eq!( + terminal + .persisted_agent_session + .as_ref() + .map(|session| session.session_ref.value.as_str()), + Some("mastracode-new") + ); + } + #[test] fn omp_reacquires_full_lifecycle_hook_after_process_exit_with_fresh_process_and_session_ref() { let now = Instant::now(); @@ -3035,6 +3174,15 @@ mod tests { false, now + Duration::from_millis(3), ); + terminal + .set_agent_session_ref_for_session_start( + "herdr:omp".into(), + "omp".into(), + crate::agent_resume::AgentSessionRef::id("omp-new"), + Some(400), + Some("startup".into()), + ) + .expect("fresh process and session should claim the pane"); let fresh = terminal.set_hook_authority_with_session_ref( "herdr:omp".into(), "omp".into(), @@ -3078,6 +3226,13 @@ mod tests { fn visible_blocker_does_not_override_full_lifecycle_hook_authority() { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + anchor_full_lifecycle_session( + &mut terminal, + Agent::Pi, + "herdr:pi", + "pi", + crate::agent_resume::AgentSessionRef::path(test_session_path("root.jsonl")).unwrap(), + ); terminal.set_hook_authority( "herdr:pi".into(), "pi".into(), @@ -3206,6 +3361,13 @@ mod tests { let now = Instant::now(); let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Working); + anchor_full_lifecycle_session( + &mut terminal, + Agent::OpenCode, + "herdr:opencode", + "opencode", + crate::agent_resume::AgentSessionRef::id("opencode-root").unwrap(), + ); terminal.set_hook_authority_at( "herdr:opencode".into(), "opencode".into(), @@ -3264,6 +3426,13 @@ mod tests { let now = Instant::now(); let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Hermes), AgentState::Idle); + anchor_full_lifecycle_session( + &mut terminal, + Agent::Hermes, + "herdr:hermes", + "hermes", + crate::agent_resume::AgentSessionRef::id("hermes-root").unwrap(), + ); terminal.set_hook_authority_at( "herdr:hermes".into(), "hermes".into(), @@ -3294,6 +3463,13 @@ mod tests { let now = Instant::now(); let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Kilo), AgentState::Idle); + anchor_full_lifecycle_session( + &mut terminal, + Agent::Kilo, + "herdr:kilo", + "kilo", + crate::agent_resume::AgentSessionRef::id("kilo-root").unwrap(), + ); terminal.set_hook_authority_at( "herdr:kilo".into(), "kilo".into(), @@ -3502,6 +3678,13 @@ mod tests { let now = Instant::now(); let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + anchor_full_lifecycle_session( + &mut terminal, + Agent::Pi, + "herdr:pi", + "pi", + crate::agent_resume::AgentSessionRef::path(test_session_path("root.jsonl")).unwrap(), + ); terminal.set_hook_authority_at( "herdr:pi".into(), "pi".into(), @@ -3592,7 +3775,7 @@ mod tests { assert!(terminal.hook_authority.is_none()); assert_eq!(terminal.detected_agent, Some(Agent::Codex)); - assert_eq!(terminal.effective_agent_label(), Some("codex")); + assert_eq!(terminal.effective_agent_label(), None); assert_eq!(terminal.state, AgentState::Idle); } @@ -3633,7 +3816,119 @@ mod tests { } #[test] - fn stale_process_exit_does_not_clear_newer_same_agent_hook_authority() { + fn stale_process_exit_preserves_newer_custom_authority() { + let mut terminal = test_terminal(); + let observed = Instant::now(); + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Idle, + false, + false, + false, + false, + observed, + ); + terminal.set_hook_authority_at( + "custom:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + Some(100), + observed + Duration::from_secs(1), + ); + + let mutation = terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Idle, + false, + false, + false, + true, + observed, + ); + + assert!(!mutation.agent_released); + assert_eq!(terminal.state, AgentState::Working); + assert_eq!( + terminal + .hook_authority + .as_ref() + .map(|hook| hook.source.as_str()), + Some("custom:pi") + ); + } + + #[test] + fn custom_authority_reanchors_sequence_after_process_restart() { + let mut terminal = test_terminal(); + let observed = Instant::now(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + terminal.set_hook_authority_at( + "custom:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + Some(100), + observed, + ); + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Idle, + false, + false, + false, + true, + observed + Duration::from_millis(1), + ); + terminal.set_detected_state_with_screen_signals_at( + None, + AgentState::Unknown, + false, + false, + false, + false, + observed + Duration::from_millis(2), + ); + + assert!(terminal + .release_agent_with_mutation("custom:pi", "pi", Some(200)) + .is_none()); + assert!(terminal + .clear_hook_authority_with_mutation(Some("custom:pi"), Some(201)) + .is_none()); + assert!(terminal + .set_hook_authority( + "custom:pi".into(), + "pi".into(), + AgentState::Working, + None, + Some(1), + ) + .is_none()); + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Idle, + false, + false, + false, + false, + observed + Duration::from_millis(3), + ); + assert!(terminal + .set_hook_authority( + "custom:pi".into(), + "pi".into(), + AgentState::Working, + None, + Some(1), + ) + .is_some()); + } + + #[test] + fn process_exit_clears_newer_same_agent_hook_authority() { let mut terminal = test_terminal(); let observed = Instant::now(); terminal.set_detected_state_with_screen_signals_at( @@ -3674,10 +3969,9 @@ mod tests { observed, ); - let authority = terminal.hook_authority.as_ref().expect("hook authority"); - assert_eq!(authority.reported_at, observed + Duration::from_secs(1)); - assert_eq!(terminal.state, AgentState::Working); - assert_eq!(terminal.effective_agent_label(), Some("codex")); + assert!(terminal.hook_authority.is_none()); + assert_eq!(terminal.state, AgentState::Idle); + assert_eq!(terminal.effective_agent_label(), None); } #[test] @@ -3700,29 +3994,17 @@ mod tests { assert_eq!(terminal.state, AgentState::Working); } - #[test] - fn release_agent_clears_identity_immediately() { - let mut terminal = test_terminal(); - terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); - terminal.set_hook_authority( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - None, - ); - - terminal.release_agent("herdr:pi", "pi", None); - - assert!(terminal.hook_authority.is_none()); - assert_eq!(terminal.detected_agent, None); - assert_eq!(terminal.fallback_state, AgentState::Unknown); - assert_eq!(terminal.state, AgentState::Unknown); - } - #[test] fn stale_hook_report_sequence_is_ignored_for_same_source() { let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + anchor_full_lifecycle_session( + &mut terminal, + Agent::Pi, + "herdr:pi", + "pi", + crate::agent_resume::AgentSessionRef::path(test_session_path("root.jsonl")).unwrap(), + ); terminal.set_hook_authority( "herdr:pi".into(), "pi".into(), @@ -3751,6 +4033,13 @@ mod tests { fn accepted_hook_report_stores_session_ref() { let mut terminal = test_terminal(); let session_path = test_session_path("pi.jsonl"); + anchor_full_lifecycle_session( + &mut terminal, + Agent::Pi, + "herdr:pi", + "pi", + crate::agent_resume::AgentSessionRef::path(session_path.clone()).unwrap(), + ); let mutation = terminal .set_hook_authority_with_session_ref( "herdr:pi".into(), @@ -3762,7 +4051,7 @@ mod tests { ) .expect("accepted report"); - assert!(mutation.session_ref_changed); + assert!(!mutation.session_ref_changed); assert_eq!( terminal .hook_authority @@ -3781,6 +4070,13 @@ mod tests { let mut terminal = test_terminal(); let session_path = test_session_path("pi.jsonl"); let new_session_path = test_session_path("new.jsonl"); + anchor_full_lifecycle_session( + &mut terminal, + Agent::Pi, + "herdr:pi", + "pi", + crate::agent_resume::AgentSessionRef::path(session_path.clone()).unwrap(), + ); terminal.set_hook_authority_with_session_ref( "herdr:pi".into(), "pi".into(), @@ -3814,6 +4110,13 @@ mod tests { fn accepted_hook_report_without_session_ref_clears_previous_ref() { let mut terminal = test_terminal(); let session_path = test_session_path("pi.jsonl"); + anchor_full_lifecycle_session( + &mut terminal, + Agent::Pi, + "herdr:pi", + "pi", + crate::agent_resume::AgentSessionRef::path(session_path.clone()).unwrap(), + ); terminal.set_hook_authority_with_session_ref( "herdr:pi".into(), "pi".into(), @@ -3844,30 +4147,6 @@ mod tests { .is_none()); } - #[test] - fn accepted_hook_report_marks_changed_when_same_owner_session_identity_changes() { - let mut terminal = test_terminal(); - terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { - source: "herdr:pi".into(), - agent: "pi".into(), - session_ref: crate::agent_resume::AgentSessionRef::path(test_session_path("old.jsonl")) - .unwrap(), - }); - - let mutation = terminal - .set_hook_authority_with_session_ref( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - crate::agent_resume::AgentSessionRef::path(test_session_path("new.jsonl")), - Some(20), - ) - .expect("accepted report"); - - assert!(mutation.session_ref_changed); - } - #[test] fn different_same_agent_session_ref_is_ignored_until_current_session_clears() { let mut terminal = test_terminal(); @@ -4008,12 +4287,14 @@ mod tests { #[test] fn opencode_new_session_ref_replaces_existing_session_ref() { let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle); terminal - .set_agent_session_ref( + .set_agent_session_ref_for_session_start( "herdr:opencode".into(), "opencode".into(), crate::agent_resume::AgentSessionRef::id("opencode-old"), Some(20), + Some("new".into()), ) .expect("initial session should be accepted"); @@ -4042,11 +4323,12 @@ mod tests { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); terminal - .set_agent_session_ref( + .set_agent_session_ref_for_session_start( "herdr:pi".into(), "pi".into(), crate::agent_resume::AgentSessionRef::id("pi-old"), Some(20), + Some("new".into()), ) .expect("initial session should be accepted"); terminal.set_agent_name("reviewer".into()); @@ -4104,12 +4386,14 @@ mod tests { #[test] fn opencode_session_ref_without_start_source_does_not_replace_existing() { let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle); terminal - .set_agent_session_ref( + .set_agent_session_ref_for_session_start( "herdr:opencode".into(), "opencode".into(), crate::agent_resume::AgentSessionRef::id("opencode-old"), Some(20), + Some("new".into()), ) .expect("initial session should be accepted"); @@ -4303,6 +4587,13 @@ mod tests { fn foreground_agent_session_replaces_stale_different_owner_hook_authority() { let mut terminal = test_terminal(); let now = std::time::Instant::now(); + anchor_full_lifecycle_session( + &mut terminal, + Agent::OpenCode, + "herdr:opencode", + "opencode", + crate::agent_resume::AgentSessionRef::id("opencode-session").unwrap(), + ); terminal .set_hook_authority_at( "herdr:opencode".into(), @@ -4356,13 +4647,22 @@ mod tests { assert!(late_old_session.is_none()); terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle); + terminal + .set_agent_session_ref_for_session_start( + "herdr:opencode".into(), + "opencode".into(), + crate::agent_resume::AgentSessionRef::id("opencode-new-session"), + Some(23), + Some("new".into()), + ) + .expect("fresh root session"); let fresh_session = terminal.set_hook_authority_with_session_ref( "herdr:opencode".into(), "opencode".into(), AgentState::Working, None, crate::agent_resume::AgentSessionRef::id("opencode-new-session"), - Some(23), + Some(24), ); assert!(fresh_session.is_some()); } @@ -4425,8 +4725,16 @@ mod tests { } #[test] - fn hook_authority_preserves_current_session_ref_when_incoming_ref_differs() { + fn hook_authority_rejects_state_from_a_different_session() { let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle); + anchor_full_lifecycle_session( + &mut terminal, + Agent::OpenCode, + "herdr:opencode", + "opencode", + crate::agent_resume::AgentSessionRef::id("opencode-session").unwrap(), + ); terminal .set_hook_authority_with_session_ref( "herdr:opencode".into(), @@ -4438,19 +4746,17 @@ mod tests { ) .expect("initial session should be accepted"); - let mutation = terminal - .set_hook_authority_with_session_ref( - "herdr:opencode".into(), - "opencode".into(), - AgentState::Blocked, - Some("needs approval".into()), - crate::agent_resume::AgentSessionRef::id("nested-session"), - Some(21), - ) - .expect("state update should still be accepted"); + let mutation = terminal.set_hook_authority_with_session_ref( + "herdr:opencode".into(), + "opencode".into(), + AgentState::Blocked, + Some("needs approval".into()), + crate::agent_resume::AgentSessionRef::id("nested-session"), + Some(21), + ); - assert!(!mutation.session_ref_changed); - assert_eq!(terminal.state, AgentState::Blocked); + assert!(mutation.is_none()); + assert_eq!(terminal.state, AgentState::Working); assert_eq!( terminal .hook_authority @@ -4498,6 +4804,13 @@ mod tests { fn clearing_hook_authority_clears_session_ref() { let mut terminal = test_terminal(); let session_path = test_session_path("pi.jsonl"); + anchor_full_lifecycle_session( + &mut terminal, + Agent::Pi, + "herdr:pi", + "pi", + crate::agent_resume::AgentSessionRef::path(session_path.clone()).unwrap(), + ); terminal.set_hook_authority_with_session_ref( "herdr:pi".into(), "pi".into(), @@ -4516,28 +4829,7 @@ mod tests { } #[test] - fn release_agent_clears_session_ref() { - let mut terminal = test_terminal(); - let session_path = test_session_path("pi.jsonl"); - terminal.set_hook_authority_with_session_ref( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - crate::agent_resume::AgentSessionRef::path(session_path), - Some(20), - ); - - let mutation = terminal - .release_agent_with_mutation("herdr:pi", "pi", Some(21)) - .expect("accepted release"); - - assert!(mutation.session_ref_changed); - assert!(terminal.hook_authority.is_none()); - } - - #[test] - fn agent_alias_survives_detection_uncertainty_but_not_replacement_or_release() { + fn agent_alias_survives_detection_uncertainty_and_reported_release_but_not_replacement() { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Pi), AgentState::Working); terminal.set_agent_name("reviewer".into()); @@ -4549,10 +4841,37 @@ mod tests { assert!(terminal.agent_name.is_none()); terminal.set_agent_name("replacement".into()); - terminal + let mutation = terminal .release_agent_with_mutation("herdr:codex", "codex", None) .expect("detected agent release should be accepted"); - assert!(terminal.agent_name.is_none()); + assert!(!mutation.agent_released); + assert_eq!(terminal.agent_name.as_deref(), Some("replacement")); + assert_eq!(terminal.detected_agent, Some(Agent::Codex)); + } + + #[test] + fn custom_release_preserves_process_owned_agent_state() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + terminal + .set_hook_authority( + "custom:pi".into(), + "pi".into(), + AgentState::Working, + None, + Some(10), + ) + .expect("custom state should be accepted"); + + let mutation = terminal + .release_agent_with_mutation("custom:pi", "pi", Some(11)) + .expect("custom release should be accepted"); + + assert!(!mutation.agent_released); + assert!(terminal.hook_authority.is_none()); + assert_eq!(terminal.detected_agent, Some(Agent::Pi)); + assert_eq!(terminal.effective_agent_label(), Some("pi")); + assert_eq!(terminal.state, AgentState::Idle); } #[test] @@ -4608,6 +4927,14 @@ mod tests { #[test] fn accepted_same_kind_hook_owner_replacement_clears_the_alias() { let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + anchor_full_lifecycle_session( + &mut terminal, + Agent::Pi, + "herdr:pi", + "pi", + crate::agent_resume::AgentSessionRef::path(test_session_path("first.jsonl")).unwrap(), + ); terminal .set_hook_authority_at( "herdr:pi".into(), @@ -4694,11 +5021,12 @@ mod tests { #[test] fn process_exit_clears_matching_persisted_session_ref() { let mut terminal = test_terminal(); + let session_ref = + crate::agent_resume::AgentSessionRef::path(test_session_path("pi.jsonl")).unwrap(); terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { source: "herdr:pi".into(), agent: "pi".into(), - session_ref: crate::agent_resume::AgentSessionRef::path(test_session_path("pi.jsonl")) - .unwrap(), + session_ref: session_ref.clone(), }); terminal.set_detected_state(Some(Agent::Pi), AgentState::Working); @@ -4714,6 +5042,15 @@ mod tests { assert!(mutation.session_ref_changed); assert!(terminal.persisted_agent_session.is_none()); + + let delayed = terminal.set_agent_session_ref( + "herdr:pi".into(), + "pi".into(), + Some(session_ref), + Some(21), + ); + assert!(delayed.is_none()); + assert!(terminal.persisted_agent_session.is_none()); } #[test] @@ -4831,6 +5168,13 @@ mod tests { fn detected_agent_disappearance_does_not_clear_full_lifecycle_hook_session_ref() { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Hermes), AgentState::Idle); + anchor_full_lifecycle_session( + &mut terminal, + Agent::Hermes, + "herdr:hermes", + "hermes", + crate::agent_resume::AgentSessionRef::id("hermes-session").unwrap(), + ); terminal.set_hook_authority_with_session_ref( "herdr:hermes".into(), "hermes".into(), @@ -4884,6 +5228,14 @@ mod tests { #[test] fn unsequenced_hook_report_is_ignored_after_source_uses_sequence() { let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + anchor_full_lifecycle_session( + &mut terminal, + Agent::Pi, + "herdr:pi", + "pi", + crate::agent_resume::AgentSessionRef::path(test_session_path("root.jsonl")).unwrap(), + ); terminal.set_hook_authority( "herdr:pi".into(), "pi".into(), @@ -4904,27 +5256,17 @@ mod tests { assert_eq!(terminal.state, AgentState::Working); } - #[test] - fn stale_release_sequence_is_ignored_for_same_source() { - let mut terminal = test_terminal(); - terminal.set_hook_authority( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - Some(20), - ); - - let change = terminal.release_agent("herdr:pi", "pi", Some(19)); - - assert!(change.is_none()); - assert_eq!(terminal.state, AgentState::Working); - assert!(terminal.hook_authority.is_some()); - } - #[test] fn stale_clear_all_sequence_is_checked_against_current_authority_source() { let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + anchor_full_lifecycle_session( + &mut terminal, + Agent::Pi, + "herdr:pi", + "pi", + crate::agent_resume::AgentSessionRef::path(test_session_path("root.jsonl")).unwrap(), + ); terminal.set_hook_authority( "herdr:pi".into(), "pi".into(), @@ -4943,6 +5285,7 @@ mod tests { #[test] fn same_sequence_from_different_sources_is_independent() { let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); terminal.set_hook_authority( "herdr:pi".into(), "pi".into(), diff --git a/tests/api_ping.rs b/tests/api_ping.rs index d05f3f7f..344fe6ec 100644 --- a/tests/api_ping.rs +++ b/tests/api_ping.rs @@ -1699,10 +1699,19 @@ fn pane_report_agent_updates_effective_state() { } let session_path = base.join("pi-session.jsonl"); + let session = send_request( + &socket_path, + &format!( + r#"{{"id":"req_hook_session","method":"pane.report_agent_session","params":{{"pane_id":"{}","source":"herdr:pi","agent":"pi","agent_session_path":"{}","session_start_source":"startup","seq":1}}}}"#, + pane_id, + session_path.display() + ), + ); + assert_eq!(session["result"]["type"], "ok"); let hook = send_request( &socket_path, &format!( - r#"{{"id":"req_hook_5","method":"pane.report_agent","params":{{"pane_id":"{}","source":"herdr:pi","agent":"pi","state":"working","message":"thinking","agent_session_path":"{}"}}}}"#, + r#"{{"id":"req_hook_5","method":"pane.report_agent","params":{{"pane_id":"{}","source":"herdr:pi","agent":"pi","state":"working","message":"thinking","agent_session_path":"{}","seq":2}}}}"#, pane_id, session_path.display() ), @@ -1875,7 +1884,7 @@ fn pane_report_agent_accepts_unknown_agent_labels() { #[cfg(not(target_os = "macos"))] #[test] -fn pane_release_agent_suppresses_reacquire_during_graceful_exit() { +fn official_release_waits_for_confirmed_process_exit() { let _lock = test_lock(); let base = unique_test_dir(); let config_home = base.join("config"); @@ -1960,11 +1969,22 @@ fn pane_release_agent_suppresses_reacquire_during_graceful_exit() { thread::sleep(Duration::from_millis(100)); } + let session_path = base.join("release-session.jsonl"); + let session = send_request( + &socket_path, + &format!( + r#"{{"id":"req_release_session","method":"pane.report_agent_session","params":{{"pane_id":"{}","source":"herdr:pi","agent":"pi","agent_session_path":"{}","session_start_source":"startup","seq":1}}}}"#, + pane_id, + session_path.display() + ), + ); + assert_eq!(session["result"]["type"], "ok"); let hook = send_request( &socket_path, &format!( - r#"{{"id":"req_release_4","method":"pane.report_agent","params":{{"pane_id":"{}","source":"herdr:pi","agent":"pi","state":"working"}}}}"#, - pane_id + r#"{{"id":"req_release_4","method":"pane.report_agent","params":{{"pane_id":"{}","source":"herdr:pi","agent":"pi","state":"working","agent_session_path":"{}","seq":2}}}}"#, + pane_id, + session_path.display() ), ); assert_eq!(hook["result"]["type"], "ok"); @@ -1978,8 +1998,8 @@ fn pane_release_agent_suppresses_reacquire_during_graceful_exit() { ); assert_eq!(released["result"]["type"], "ok"); - let suppression_deadline = Instant::now() + Duration::from_millis(300); - while Instant::now() < suppression_deadline { + let release_observation_deadline = Instant::now() + Duration::from_millis(300); + while Instant::now() < release_observation_deadline { let pane = send_request( &socket_path, &format!( @@ -1987,11 +2007,11 @@ fn pane_release_agent_suppresses_reacquire_during_graceful_exit() { pane_id ), ); - assert!( - pane["result"]["pane"]["agent"].is_null(), - "pane reacquired pi during graceful release: {pane}" + assert_eq!( + pane["result"]["pane"]["agent"], "pi", + "official release hid the live Pi process: {pane}" ); - assert_eq!(pane["result"]["pane"]["agent_status"], "unknown"); + assert_eq!(pane["result"]["pane"]["agent_status"], "working"); thread::sleep(Duration::from_millis(50)); } @@ -2013,7 +2033,7 @@ fn pane_release_agent_suppresses_reacquire_during_graceful_exit() { } assert!( Instant::now() < cleared_deadline, - "pi agent was not cleared promptly after release: {pane}" + "pi agent was not cleared promptly after process exit: {pane}" ); thread::sleep(Duration::from_millis(50)); } @@ -2443,7 +2463,7 @@ fn metadata_status_subscription_filter_and_ttl_expiry_are_observable() { let report_agent = send_request( &socket_path, &format!( - r#"{{"id":"req_meta_sub_2","method":"pane.report_agent","params":{{"pane_id":"{}","source":"herdr:pi","agent":"pi","state":"working"}}}}"#, + r#"{{"id":"req_meta_sub_2","method":"pane.report_agent","params":{{"pane_id":"{}","source":"custom:pi","agent":"pi","state":"working"}}}}"#, pane_id ), ); @@ -2463,7 +2483,7 @@ fn metadata_status_subscription_filter_and_ttl_expiry_are_observable() { let metadata = send_request( &socket_path, &format!( - r#"{{"id":"req_meta_sub_3","method":"pane.report_metadata","params":{{"pane_id":"{}","source":"user:pi-display","agent":"pi","applies_to_source":"herdr:pi","title":"filtered out"}}}}"#, + r#"{{"id":"req_meta_sub_3","method":"pane.report_metadata","params":{{"pane_id":"{}","source":"user:pi-display","agent":"pi","applies_to_source":"custom:pi","title":"filtered out"}}}}"#, pane_id ), ); @@ -2489,7 +2509,7 @@ fn metadata_status_subscription_filter_and_ttl_expiry_are_observable() { let metadata = send_request( &socket_path, &format!( - r#"{{"id":"req_meta_sub_4","method":"pane.report_metadata","params":{{"pane_id":"{}","source":"user:pi-display","agent":"pi","applies_to_source":"herdr:pi","title":"short lived","ttl_ms":100}}}}"#, + r#"{{"id":"req_meta_sub_4","method":"pane.report_metadata","params":{{"pane_id":"{}","source":"user:pi-display","agent":"pi","applies_to_source":"custom:pi","title":"short lived","ttl_ms":100}}}}"#, pane_id ), ); diff --git a/tests/cli/agent_wait.rs b/tests/cli/agent_wait.rs index 11997d00..11da467d 100644 --- a/tests/cli/agent_wait.rs +++ b/tests/cli/agent_wait.rs @@ -26,7 +26,7 @@ fn agent_wait_exits_immediately_when_status_already_matches() { let reported = send_request( &socket_path, &format!( - r#"{{"id":"req_cli_immediate_2","method":"pane.report_agent","params":{{"pane_id":"{}","source":"herdr:pi","agent":"pi","state":"idle"}}}}"#, + r#"{{"id":"req_cli_immediate_2","method":"pane.report_agent","params":{{"pane_id":"{}","source":"custom:test","agent":"pi","state":"idle"}}}}"#, pane_id ), ); @@ -78,7 +78,7 @@ fn agent_wait_times_out_when_status_does_not_match() { let reported = send_request( &socket_path, &format!( - r#"{{"id":"req_cli_timeout_2","method":"pane.report_agent","params":{{"pane_id":"{}","source":"herdr:pi","agent":"pi","state":"working"}}}}"#, + r#"{{"id":"req_cli_timeout_2","method":"pane.report_agent","params":{{"pane_id":"{}","source":"custom:test","agent":"pi","state":"working"}}}}"#, pane_id ), ); diff --git a/tests/cli/sessions.rs b/tests/cli/sessions.rs index 126cb616..b7655542 100644 --- a/tests/cli/sessions.rs +++ b/tests/cli/sessions.rs @@ -230,7 +230,7 @@ fn integration_commands_run_locally_when_server_is_missing() { .unwrap(); assert_eq!(integration_status.status.code(), Some(0)); let status_stdout = String::from_utf8_lossy(&integration_status.stdout); - assert!(status_stdout.contains("pi: current (v6)")); + assert!(status_stdout.contains("pi: current (v7)")); assert!(status_stdout.contains("claude: not installed")); let integration_uninstall = Command::new(env!("CARGO_BIN_EXE_herdr")) diff --git a/tests/live_handoff.rs b/tests/live_handoff.rs index c93898b6..4d81246a 100644 --- a/tests/live_handoff.rs +++ b/tests/live_handoff.rs @@ -1213,6 +1213,8 @@ fn live_handoff_accepts_canonical_pane_id_from_child_env() { #[test] fn live_handoff_keeps_unmanaged_agent_name_bound_to_saved_session() { + use std::os::unix::fs::PermissionsExt; + let _lock = test_lock(); let base = unique_test_dir(); let config_home = base.join("config"); @@ -1220,6 +1222,18 @@ fn live_handoff_keeps_unmanaged_agent_name_bound_to_saved_session() { let api_socket = runtime_dir.join("herdr.sock"); let old_session = base.join("old-session.jsonl"); let new_session = base.join("new-session.jsonl"); + let started_marker = base.join("agent-started"); + let fake_pi = base.join("pi"); + fs::create_dir_all(&base).unwrap(); + fs::write( + &fake_pi, + format!( + "#!/bin/sh\nexport HERDR_AGENT=pi\necho started > {}\nexec /bin/sleep 30\n", + started_marker.display() + ), + ) + .unwrap(); + fs::set_permissions(&fake_pi, fs::Permissions::from_mode(0o755)).unwrap(); let spawned = spawn_server(&config_home, &runtime_dir, &api_socket); wait_for_socket(&api_socket, Duration::from_secs(10)); @@ -1236,6 +1250,30 @@ fn live_handoff_keeps_unmanaged_agent_name_bound_to_saved_session() { .as_str() .unwrap() .to_string(); + assert_ok(request( + &api_socket, + serde_json::json!({ + "id": "test:pane:start-agent", + "method": "pane.send_input", + "params": {"pane_id": pane_id, "text": fake_pi, "keys": ["Enter"]} + }), + )); + support::wait_for_file(&started_marker, Duration::from_secs(5)); + assert_ok(request( + &api_socket, + serde_json::json!({ + "id": "test:agent:session", + "method": "pane.report_agent_session", + "params": { + "pane_id": pane_id, + "source": "herdr:pi", + "agent": "pi", + "seq": 1, + "agent_session_path": old_session, + "session_start_source": "startup" + } + }), + )); assert_ok(request( &api_socket, serde_json::json!({ @@ -1246,11 +1284,30 @@ fn live_handoff_keeps_unmanaged_agent_name_bound_to_saved_session() { "source": "herdr:pi", "agent": "pi", "state": "idle", - "seq": 1, + "seq": 2, "agent_session_path": old_session } }), )); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let response = request( + &api_socket, + serde_json::json!({ + "id": "test:agent:wait-for-process", + "method": "agent.get", + "params": {"target": pane_id} + }), + ); + if response.get("result").is_some() { + break; + } + assert!( + Instant::now() < deadline, + "agent process was not detected: {response}" + ); + thread::sleep(Duration::from_millis(25)); + } assert_ok(request( &api_socket, serde_json::json!({ @@ -1276,21 +1333,31 @@ fn live_handoff_keeps_unmanaged_agent_name_bound_to_saved_session() { "pane_id": pane_id, "source": "herdr:pi", "agent": "pi", - "seq": 2, + "seq": 3, "agent_session_path": new_session, "session_start_source": "new" } }), )); - let old_name = request( - &api_socket, - serde_json::json!({ - "id": "test:agent:get-old-name", - "method": "agent.get", - "params": {"target": "reviewer"} - }), - ); - assert_eq!(old_name["error"]["code"], "agent_not_found", "{old_name}"); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let old_name = request( + &api_socket, + serde_json::json!({ + "id": "test:agent:get-old-name", + "method": "agent.get", + "params": {"target": "reviewer"} + }), + ); + if old_name["error"]["code"] == "agent_not_found" { + break; + } + assert!( + Instant::now() < deadline, + "old session alias was not cleared: {old_name}" + ); + thread::sleep(Duration::from_millis(25)); + } let _ = request( &api_socket,