From db67e9d74ffafc9c63bdf78fcf712419359f634b Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Sat, 23 May 2026 21:27:26 +0300 Subject: [PATCH] feat: restore agent sessions refs #233 --- .../src/content/docs/configuration.mdx | 13 + .../website/src/content/docs/integrations.mdx | 6 +- src/agent_resume.rs | 302 ++++++++++ src/api/schema.rs | 6 + src/app/actions.rs | 58 +- src/app/api/panes.rs | 6 + src/app/mod.rs | 1 + src/cli/pane.rs | 24 +- src/config/io.rs | 23 + src/config/model.rs | 22 + src/events.rs | 1 + .../assets/claude/herdr-agent-state.sh | 6 +- .../assets/codex/herdr-agent-state.sh | 23 +- src/integration/assets/hermes/__init__.py | 26 +- .../assets/opencode/herdr-agent-state.js | 65 ++- .../assets/pi/herdr-agent-state.ts | 44 +- src/integration/mod.rs | 55 +- src/main.rs | 6 + src/pane.rs | 179 +++++- src/persist/restore.rs | 267 ++++++++- src/persist/snapshot.rs | 109 ++++ src/server/headless.rs | 2 + src/terminal/mod.rs | 2 +- src/terminal/runtime.rs | 29 + src/terminal/state.rs | 515 ++++++++++++++++-- tests/cli_wrapper.rs | 48 +- 26 files changed, 1720 insertions(+), 118 deletions(-) create mode 100644 src/agent_resume.rs diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index 2fe30027..87de1102 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -316,6 +316,19 @@ kitty_graphics = false Leave this off unless you are testing terminal image behavior. +## Agent session restore + +Herdr can restart supported agent panes in their native conversation sessions after a Herdr server restart. + +```toml +[session] +resume_agents_on_restore = false +``` + +When enabled, Herdr only resumes panes that reported a native session reference through an official Herdr integration. Supported resume targets are Claude Code, Codex, Pi, Hermes Agent, and OpenCode. Unsupported, missing, invalid, duplicated, or stale session references restore as a normal shell in the saved pane directory. + +Session references are stored in the local Herdr session snapshot. They are not shown in normal pane, agent, status, or event output. + ## IME cursor tracking When the focused pane hides its cursor and paints its own — common in AI-agent TUIs like Claude Code, pi, and codex — macOS native input methods stop tracking the candidate window position because the outer terminal stops reporting the cursor. diff --git a/docs/next/website/src/content/docs/integrations.mdx b/docs/next/website/src/content/docs/integrations.mdx index 8c6443b8..43beec0b 100644 --- a/docs/next/website/src/content/docs/integrations.mdx +++ b/docs/next/website/src/content/docs/integrations.mdx @@ -43,6 +43,8 @@ Herdr combines three signals: Integrations enrich state reporting. They do not replace process detection. +Some integrations also report native agent session references. If `[session] resume_agents_on_restore = true` is enabled, Herdr uses official session references to resume Claude Code, Codex, Pi, Hermes Agent, and OpenCode panes after a Herdr server restart. + ## Pi Install the Pi integration: @@ -111,7 +113,7 @@ herdr integration install opencode Herdr writes the plugin to `~/.config/opencode/plugins/herdr-agent-state.js`. The OpenCode config directory must already exist. Uninstall removes only that plugin file. -The plugin reports semantic state while OpenCode runs inside a Herdr pane. +The plugin reports semantic state while OpenCode runs inside a Herdr pane. After OpenCode emits a session-bearing event, Herdr can use the reported session id to resume the pane with `opencode --session `. ## Hermes Agent @@ -123,7 +125,7 @@ herdr integration install hermes Herdr writes `~/.hermes/plugins/herdr-agent-state/` and enables `herdr-agent-state` in `~/.hermes/config.yaml`. The Hermes config directory must already exist. Restart Hermes after installing so the plugin loads. Uninstall removes the plugin directory and removes `herdr-agent-state` from `plugins.enabled`. -The plugin reports lifecycle, tool, and approval state while Hermes runs inside a Herdr pane. Native screen heuristics remain available when the plugin is not installed. +The plugin reports lifecycle, tool, approval state, and session id while Hermes runs inside a Herdr pane. Herdr can use the reported session id to resume the pane with `hermes --resume `. Native screen heuristics remain available when the plugin is not installed. ## Custom status labels diff --git a/src/agent_resume.rs b/src/agent_resume.rs new file mode 100644 index 00000000..8f450088 --- /dev/null +++ b/src/agent_resume.rs @@ -0,0 +1,302 @@ +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +const MAX_SESSION_ID_LEN: usize = 512; +const MAX_SESSION_PATH_LEN: usize = 4096; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentSessionRef { + pub kind: AgentSessionRefKind, + pub value: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentSessionRefKind { + Id, + Path, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentResumePlan { + pub agent: String, + pub argv: Vec, + pub dedupe_key: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PersistedAgentSession { + pub source: String, + pub agent: String, + pub session_ref: AgentSessionRef, +} + +impl AgentSessionRef { + pub fn id(value: impl Into) -> Option { + let value = value.into(); + valid_session_id(&value).then_some(Self { + kind: AgentSessionRefKind::Id, + value, + }) + } + + pub fn path(value: impl Into) -> Option { + let value = value.into(); + valid_session_path(&value).then_some(Self { + kind: AgentSessionRefKind::Path, + value, + }) + } +} + +pub fn session_ref_from_report( + source: &str, + agent: &str, + agent_session_id: Option, + _agent_session_path: Option, +) -> Option { + if !is_official_agent_source(source, agent) { + return None; + } + + if agent == "pi" { + return _agent_session_path + .and_then(AgentSessionRef::path) + .or_else(|| agent_session_id.and_then(AgentSessionRef::id)); + } + + agent_session_id.and_then(AgentSessionRef::id) +} + +pub fn session_ref_from_snapshot( + source: &str, + agent: &str, + kind: AgentSessionRefKind, + value: &str, +) -> Option { + if !is_official_agent_source(source, agent) { + return None; + } + let session_ref = match (agent, kind) { + ("pi", AgentSessionRefKind::Path) => AgentSessionRef::path(value)?, + (_, AgentSessionRefKind::Id) => AgentSessionRef::id(value)?, + _ => return None, + }; + Some(PersistedAgentSession { + source: source.to_string(), + agent: agent.to_string(), + session_ref, + }) +} + +pub fn plan(source: &str, agent: &str, session_ref: &AgentSessionRef) -> Option { + if !is_official_agent_source(source, agent) { + return None; + } + + let argv = match (source, agent, session_ref.kind) { + ("herdr:claude", "claude", AgentSessionRefKind::Id) => { + vec![ + "claude".into(), + "--resume".into(), + session_ref.value.clone(), + ] + } + ("herdr:codex", "codex", AgentSessionRefKind::Id) => { + vec!["codex".into(), "resume".into(), session_ref.value.clone()] + } + ("herdr:pi", "pi", AgentSessionRefKind::Path | AgentSessionRefKind::Id) => { + vec!["pi".into(), "--session".into(), session_ref.value.clone()] + } + ("herdr:hermes", "hermes", AgentSessionRefKind::Id) => { + vec![ + "hermes".into(), + "--resume".into(), + session_ref.value.clone(), + ] + } + ("herdr:opencode", "opencode", AgentSessionRefKind::Id) => { + vec![ + "opencode".into(), + "--session".into(), + session_ref.value.clone(), + ] + } + _ => return None, + }; + + Some(AgentResumePlan { + agent: agent.to_string(), + argv, + dedupe_key: dedupe_key(source, agent, session_ref), + }) +} + +pub fn dedupe_key(source: &str, agent: &str, session_ref: &AgentSessionRef) -> String { + format!( + "{source}\u{0}{agent}\u{0}{:?}\u{0}{}", + session_ref.kind, session_ref.value + ) +} + +fn is_official_agent_source(source: &str, agent: &str) -> bool { + matches!( + (source, agent), + ("herdr:claude", "claude") + | ("herdr:codex", "codex") + | ("herdr:pi", "pi") + | ("herdr:hermes", "hermes") + | ("herdr:opencode", "opencode") + ) +} + +fn valid_session_id(value: &str) -> bool { + !value.is_empty() && value.len() <= MAX_SESSION_ID_LEN && !value.chars().any(char::is_control) +} + +fn valid_session_path(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_SESSION_PATH_LEN + && !value.chars().any(char::is_control) + && Path::new(value).is_absolute() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn planner_allows_supported_agents() { + assert_eq!( + plan( + "herdr:claude", + "claude", + &AgentSessionRef::id("claude-session").unwrap() + ) + .unwrap() + .argv, + vec!["claude", "--resume", "claude-session"] + ); + assert_eq!( + plan( + "herdr:codex", + "codex", + &AgentSessionRef::id("codex-session").unwrap() + ) + .unwrap() + .argv, + vec!["codex", "resume", "codex-session"] + ); + assert_eq!( + plan( + "herdr:pi", + "pi", + &AgentSessionRef::path("/tmp/pi-session.jsonl").unwrap() + ) + .unwrap() + .argv, + vec!["pi", "--session", "/tmp/pi-session.jsonl"] + ); + assert_eq!( + plan( + "herdr:hermes", + "hermes", + &AgentSessionRef::id("hermes-session").unwrap() + ) + .unwrap() + .argv, + vec!["hermes", "--resume", "hermes-session"] + ); + assert_eq!( + plan( + "herdr:opencode", + "opencode", + &AgentSessionRef::id("opencode-session").unwrap() + ) + .unwrap() + .argv, + vec!["opencode", "--session", "opencode-session"] + ); + } + + #[test] + fn planner_rejects_custom_and_unsupported_path_refs() { + assert!(plan( + "custom:claude", + "claude", + &AgentSessionRef::id("session").unwrap() + ) + .is_none()); + assert!(plan( + "herdr:claude", + "claude", + &AgentSessionRef::path("/tmp/claude-session").unwrap() + ) + .is_none()); + } + + #[test] + fn report_ref_prefers_pi_path_and_validates_values() { + let session_ref = session_ref_from_report( + "herdr:pi", + "pi", + Some("pi-id".into()), + Some("/tmp/pi-session.jsonl".into()), + ) + .unwrap(); + assert_eq!(session_ref.kind, AgentSessionRefKind::Path); + assert_eq!(session_ref.value, "/tmp/pi-session.jsonl"); + + assert!(session_ref_from_report("herdr:pi", "pi", Some("bad\nid".into()), None).is_none()); + assert!( + session_ref_from_report("herdr:pi", "pi", None, Some("relative.jsonl".into())) + .is_none() + ); + assert!(session_ref_from_report("custom:pi", "pi", Some("pi-id".into()), None).is_none()); + assert!(session_ref_from_report( + "herdr:claude", + "claude", + None, + Some("/tmp/claude-session".into()) + ) + .is_none()); + } + + #[test] + fn ids_are_data_not_shell_text() { + let id = "abc; rm -rf /"; + let plan = plan("herdr:codex", "codex", &AgentSessionRef::id(id).unwrap()).unwrap(); + assert_eq!(plan.argv, vec!["codex", "resume", id]); + } + + #[test] + fn planner_rejects_path_refs_for_id_only_agents() { + assert!(plan( + "herdr:hermes", + "hermes", + &AgentSessionRef::path("/tmp/hermes-session").unwrap() + ) + .is_none()); + assert!(plan( + "herdr:opencode", + "opencode", + &AgentSessionRef::path("/tmp/opencode-session").unwrap() + ) + .is_none()); + assert!(session_ref_from_snapshot( + "herdr:hermes", + "hermes", + AgentSessionRefKind::Id, + "hermes-session" + ) + .is_some()); + assert!(session_ref_from_snapshot( + "herdr:opencode", + "opencode", + AgentSessionRefKind::Id, + "opencode-session" + ) + .is_some()); + } +} diff --git a/src/api/schema.rs b/src/api/schema.rs index 17a5d35e..907bc2b6 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -331,6 +331,10 @@ pub struct PaneReportAgentParams { pub custom_status: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub seq: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_session_path: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -959,6 +963,8 @@ mod tests { message: Some("thinking".into()), custom_status: Some("indexing".into()), seq: Some(42), + agent_session_id: Some("pi-session".into()), + agent_session_path: Some("/tmp/pi-session.jsonl".into()), }), }; diff --git a/src/app/actions.rs b/src/app/actions.rs index ee2e210a..ba71cc87 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -6,7 +6,7 @@ use tracing::{info, warn}; use crate::detect::{Agent, AgentState}; use crate::events::AppEvent; use crate::layout::{find_in_direction, NavDirection, PaneId}; -use crate::terminal::EffectiveStateChange; +use crate::terminal::{EffectiveStateChange, TerminalStateMutation}; use crate::workspace::WorkspaceGitStatus; use super::state::{AppState, Mode, ToastKind, ToastNotification, ToastTarget, ViewLayout}; @@ -883,7 +883,7 @@ impl AppState { observed_at, } => self .update_terminal_state(pane_id, |terminal| { - terminal.set_detected_state_with_screen_signals_at( + Some(terminal.set_detected_state_with_screen_signals_at( agent, state, visible_blocker, @@ -891,7 +891,7 @@ impl AppState { visible_working, process_exited, observed_at, - ) + )) }) .into_iter() .collect(), @@ -903,14 +903,16 @@ impl AppState { message, custom_status, seq, + session_ref, } => self .update_terminal_state(pane_id, |terminal| { - terminal.set_hook_authority_with_custom_status( + terminal.set_hook_authority_with_session_ref( source, agent_label, state, message, custom_status, + session_ref, seq, ) }) @@ -922,7 +924,7 @@ impl AppState { seq, } => self .update_terminal_state(pane_id, |terminal| { - terminal.clear_hook_authority(source.as_deref(), seq) + terminal.clear_hook_authority_with_mutation(source.as_deref(), seq) }) .into_iter() .collect(), @@ -934,7 +936,7 @@ impl AppState { .. } => self .update_terminal_state(pane_id, |terminal| { - terminal.release_agent(&source, &agent_label, seq) + terminal.release_agent_with_mutation(&source, &agent_label, seq) }) .into_iter() .collect(), @@ -952,7 +954,7 @@ impl AppState { fn update_terminal_state(&mut self, pane_id: PaneId, update: F) -> Option where - F: FnOnce(&mut crate::terminal::TerminalState) -> Option, + F: FnOnce(&mut crate::terminal::TerminalState) -> Option, { let ws_idx = self .workspaces @@ -962,10 +964,14 @@ impl AppState { .pane_state(pane_id)? .attached_terminal_id .clone(); - let change = { + let mutation = { let terminal = self.terminals.get_mut(&terminal_id)?; update(terminal)? }; + if mutation.session_ref_changed { + self.mark_session_dirty(); + } + let change = mutation.effective_state_change?; let update = PaneStateUpdate { pane_id, ws_idx, @@ -1788,6 +1794,7 @@ mod tests { message: None, custom_status: None, seq: None, + session_ref: None, }); let toast = state.toast.as_ref().unwrap(); @@ -1827,6 +1834,7 @@ mod tests { message: None, custom_status: None, seq: Some(1), + session_ref: None, }); state.handle_app_event(AppEvent::StateChanged { pane_id: bg_pane_id, @@ -1877,6 +1885,7 @@ mod tests { message: None, custom_status: None, seq: Some(1), + session_ref: None, }); state.handle_app_event(AppEvent::StateChanged { pane_id: bg_pane_id, @@ -1894,6 +1903,39 @@ mod tests { assert!(state.toast.is_none()); } + #[test] + fn hidden_session_ref_only_update_marks_session_dirty_without_visible_update() { + let mut state = app_with_workspaces(&["active"]); + let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); + + let first_updates = state.handle_app_event(AppEvent::HookStateReported { + pane_id, + source: "herdr:pi".into(), + agent_label: "pi".into(), + state: AgentState::Working, + message: None, + custom_status: None, + seq: Some(20), + session_ref: crate::agent_resume::AgentSessionRef::path("/tmp/one.jsonl"), + }); + assert_eq!(first_updates.len(), 1); + state.session_dirty = false; + + let second_updates = state.handle_app_event(AppEvent::HookStateReported { + pane_id, + source: "herdr:pi".into(), + agent_label: "pi".into(), + state: AgentState::Working, + message: None, + custom_status: None, + seq: Some(21), + session_ref: crate::agent_resume::AgentSessionRef::path("/tmp/two.jsonl"), + }); + + assert!(second_updates.is_empty()); + assert!(state.session_dirty); + } + #[test] fn background_idle_sets_finished_toast() { let mut state = app_with_workspaces(&["active", "background"]); diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index e0df6845..d5dcb132 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -179,6 +179,12 @@ impl App { }; self.handle_internal_event(crate::events::AppEvent::HookStateReported { pane_id, + session_ref: crate::agent_resume::session_ref_from_report( + ¶ms.source, + &agent_label, + params.agent_session_id, + params.agent_session_path, + ), source: params.source, agent_label, state: detect_state_from_api(params.state), diff --git a/src/app/mod.rs b/src/app/mod.rs index 6124abc1..cfd5b5f8 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -255,6 +255,7 @@ impl App { 80, config.advanced.scrollback_limit_bytes, &config.terminal.default_shell, + config.session.resume_agents_on_restore, event_tx.clone(), render_notify.clone(), render_dirty.clone(), diff --git a/src/cli/pane.rs b/src/cli/pane.rs index 58d65af6..23218a4a 100644 --- a/src/cli/pane.rs +++ b/src/cli/pane.rs @@ -299,7 +299,7 @@ fn pane_run(args: &[String]) -> std::io::Result { fn pane_report_agent(args: &[String]) -> std::io::Result { let Some(raw_pane_id) = args.first() else { - eprintln!("usage: herdr pane report-agent --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--custom-status TEXT] [--seq N]"); + eprintln!("usage: herdr pane report-agent --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--custom-status TEXT] [--seq N] [--agent-session-id ID] [--agent-session-path PATH]"); return Ok(2); }; @@ -310,6 +310,8 @@ fn pane_report_agent(args: &[String]) -> std::io::Result { let mut message = None; let mut custom_status = None; let mut seq = None; + let mut agent_session_id = None; + let mut agent_session_path = None; let mut index = 1; while index < args.len() { @@ -362,6 +364,22 @@ fn pane_report_agent(args: &[String]) -> std::io::Result { seq = Some(super::parse_u64_flag("--seq", value)?); index += 2; } + "--agent-session-id" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --agent-session-id"); + return Ok(2); + }; + agent_session_id = Some(value.clone()); + index += 2; + } + "--agent-session-path" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --agent-session-path"); + return Ok(2); + }; + agent_session_path = Some(value.clone()); + index += 2; + } other => { eprintln!("unknown option: {other}"); return Ok(2); @@ -390,6 +408,8 @@ fn pane_report_agent(args: &[String]) -> std::io::Result { message, custom_status, seq, + agent_session_id, + agent_session_path, })) } @@ -405,6 +425,6 @@ fn print_pane_help() { eprintln!(" herdr pane close "); eprintln!(" herdr pane send-text "); eprintln!(" herdr pane send-keys [key ...]"); - eprintln!(" herdr pane report-agent --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--custom-status TEXT] [--seq N]"); + eprintln!(" herdr pane report-agent --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--custom-status TEXT] [--seq N] [--agent-session-id ID] [--agent-session-path PATH]"); eprintln!(" herdr pane run "); } diff --git a/src/config/io.rs b/src/config/io.rs index fe415d1c..2c6d78ab 100644 --- a/src/config/io.rs +++ b/src/config/io.rs @@ -173,6 +173,14 @@ fn load_live_config_from_str(content: &str) -> Result> &mut invalid_sections, |section| config.terminal = section, ); + load_live_section( + table, + "session", + "session config", + &mut diagnostics, + &mut invalid_sections, + |section| config.session = section, + ); load_live_section( table, "ui", @@ -463,6 +471,21 @@ mod tests { ); } + #[test] + fn load_live_config_parses_session_section() { + let loaded = load_live_config_from_str( + r#" +[session] +resume_agents_on_restore = true +"#, + ) + .unwrap(); + + assert!(loaded.config.session.resume_agents_on_restore); + assert!(loaded.diagnostics.is_empty()); + assert!(loaded.invalid_sections.is_empty()); + } + #[test] fn remove_keybinding_config_sections_removes_keys_tables_only() { let content = r#"onboarding = false diff --git a/src/config/model.rs b/src/config/model.rs index 68a55b0f..8894b300 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -72,6 +72,14 @@ pub struct TerminalConfig { pub new_cwd: NewTerminalCwdConfig, } +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub struct SessionConfig { + /// Resume supported AI-agent panes into their native conversation sessions + /// when restoring a Herdr session. Default: false. + pub resume_agents_on_restore: bool, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum ConfigReloadStatus { @@ -105,6 +113,7 @@ pub struct Config { pub onboarding: Option, pub theme: ThemeConfig, pub terminal: TerminalConfig, + pub session: SessionConfig, pub keys: KeysConfig, pub ui: UiConfig, pub worktrees: WorktreesConfig, @@ -489,6 +498,19 @@ new_cwd = "~/Projects" ); } + #[test] + fn resume_agents_on_restore_defaults_off_and_parses() { + let default_config = Config::default(); + assert!(!default_config.session.resume_agents_on_restore); + + let toml = r#" +[session] +resume_agents_on_restore = true +"#; + let config: Config = toml::from_str(toml).unwrap(); + assert!(config.session.resume_agents_on_restore); + } + #[test] fn agent_panel_scope_config_parses() { let toml = r#" diff --git a/src/events.rs b/src/events.rs index 4416d00d..49c22ec9 100644 --- a/src/events.rs +++ b/src/events.rs @@ -47,6 +47,7 @@ pub enum AppEvent { message: Option, custom_status: Option, seq: Option, + session_ref: Option, }, /// Hook authority was explicitly cleared for a pane. HookAuthorityCleared { diff --git a/src/integration/assets/claude/herdr-agent-state.sh b/src/integration/assets/claude/herdr-agent-state.sh index 5042cad8..b76d98e7 100644 --- a/src/integration/assets/claude/herdr-agent-state.sh +++ b/src/integration/assets/claude/herdr-agent-state.sh @@ -2,7 +2,7 @@ # installed by herdr # safe to edit. this hook only activates inside herdr-managed panes. # HERDR_INTEGRATION_ID=claude -# HERDR_INTEGRATION_VERSION=3 +# HERDR_INTEGRATION_VERSION=4 set -eu @@ -60,6 +60,8 @@ if is_subagent and action in ("idle", "release"): request_id = f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}" report_seq = time.time_ns() +session_id = hook_input.get("session_id") +agent_session_id = session_id if isinstance(session_id, str) and session_id else None if action == "release": request = { "id": request_id, @@ -83,6 +85,8 @@ else: "seq": report_seq, }, } + if agent_session_id: + request["params"]["agent_session_id"] = agent_session_id try: client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) diff --git a/src/integration/assets/codex/herdr-agent-state.sh b/src/integration/assets/codex/herdr-agent-state.sh index fbfa24cb..0f0979e3 100644 --- a/src/integration/assets/codex/herdr-agent-state.sh +++ b/src/integration/assets/codex/herdr-agent-state.sh @@ -2,12 +2,14 @@ # installed by herdr # safe to edit. this hook only activates inside herdr-managed panes. # HERDR_INTEGRATION_ID=codex -# HERDR_INTEGRATION_VERSION=3 +# HERDR_INTEGRATION_VERSION=4 set -eu action="${1:-}" -cat >/dev/null 2>/dev/null || true +hook_input_file="$(mktemp "${TMPDIR:-/tmp}/herdr-codex-hook.XXXXXX")" || exit 0 +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) ;; @@ -19,7 +21,7 @@ esac [ -n "${HERDR_PANE_ID:-}" ] || exit 0 command -v python3 >/dev/null 2>&1 || exit 0 -HERDR_ACTION="$action" python3 - <<'PY' +HERDR_ACTION="$action" HERDR_HOOK_INPUT_FILE="$hook_input_file" python3 - <<'PY' import json import os import random @@ -30,12 +32,25 @@ source = "herdr:codex" action = os.environ.get("HERDR_ACTION", "") pane_id = os.environ.get("HERDR_PANE_ID") socket_path = os.environ.get("HERDR_SOCKET_PATH") +hook_input_file = os.environ.get("HERDR_HOOK_INPUT_FILE") if not pane_id or not socket_path: raise SystemExit(0) +hook_input = {} +if hook_input_file: + try: + with open(hook_input_file, encoding="utf-8") as handle: + content = handle.read() + if content.strip(): + hook_input = json.loads(content) + except Exception: + hook_input = {} + request_id = f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}" report_seq = time.time_ns() +session_id = hook_input.get("session_id") +agent_session_id = session_id if isinstance(session_id, str) and session_id else None if action == "release": request = { "id": request_id, @@ -59,6 +74,8 @@ else: "seq": report_seq, }, } + if agent_session_id: + request["params"]["agent_session_id"] = agent_session_id try: client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) diff --git a/src/integration/assets/hermes/__init__.py b/src/integration/assets/hermes/__init__.py index 03d15a55..6bb0fe8a 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=1 +# HERDR_INTEGRATION_VERSION=2 from __future__ import annotations @@ -56,8 +56,19 @@ def _send(method: str, params: dict) -> None: pass -def _report(state: str) -> None: - _send("pane.report_agent", {"state": state}) +def _session_id(kwargs: dict) -> str | None: + value = kwargs.get("session_id") + if isinstance(value, str) and value: + return value + return None + + +def _report(state: str, **kwargs) -> None: + params = {"state": state} + session_id = _session_id(kwargs) + if session_id: + params["agent_session_id"] = session_id + _send("pane.report_agent", params) def _release() -> None: @@ -65,18 +76,15 @@ def _release() -> None: def _working(**kwargs) -> None: - del kwargs - _report("working") + _report("working", **kwargs) def _blocked(**kwargs) -> None: - del kwargs - _report("blocked") + _report("blocked", **kwargs) def _idle(**kwargs) -> None: - del kwargs - _report("idle") + _report("idle", **kwargs) def _finalize(**kwargs) -> None: diff --git a/src/integration/assets/opencode/herdr-agent-state.js b/src/integration/assets/opencode/herdr-agent-state.js index 0fb96217..778fe564 100644 --- a/src/integration/assets/opencode/herdr-agent-state.js +++ b/src/integration/assets/opencode/herdr-agent-state.js @@ -1,7 +1,7 @@ // installed by herdr // safe to edit. this plugin only activates inside herdr-managed panes. // HERDR_INTEGRATION_ID=opencode -// HERDR_INTEGRATION_VERSION=1 +// HERDR_INTEGRATION_VERSION=2 import net from "node:net"; @@ -13,7 +13,13 @@ function nextReportSeq() { return reportSeq; } -function reportState(action) { +function sessionIDFromProperties(properties) { + return typeof properties?.sessionID === "string" && properties.sessionID + ? properties.sessionID + : undefined; +} + +function reportState(action, sessionID) { const paneId = process.env.HERDR_PANE_ID; const socketPath = process.env.HERDR_SOCKET_PATH; @@ -24,24 +30,26 @@ function reportState(action) { const requestId = `${SOURCE}:${Date.now()}:${Math.floor(Math.random() * 1_000_000) .toString() .padStart(6, "0")}`; + const params = + action === "release" + ? { + pane_id: paneId, + source: SOURCE, + agent: "opencode", + seq: nextReportSeq(), + } + : { + pane_id: paneId, + source: SOURCE, + agent: "opencode", + state: action, + seq: nextReportSeq(), + ...(sessionID ? { agent_session_id: sessionID } : {}), + }; const request = { id: requestId, method: action === "release" ? "pane.release_agent" : "pane.report_agent", - params: - action === "release" - ? { - pane_id: paneId, - source: SOURCE, - agent: "opencode", - seq: nextReportSeq(), - } - : { - pane_id: paneId, - source: SOURCE, - agent: "opencode", - state: action, - seq: nextReportSeq(), - }, + params, }; return new Promise((resolve) => { @@ -75,26 +83,33 @@ export const HerdrAgentStatePlugin = async () => { event: async ({ event }) => { const type = event?.type; const properties = event?.properties ?? {}; + const sessionID = sessionIDFromProperties(properties); switch (type) { case "permission.asked": case "question.asked": - await reportState("blocked"); + await reportState("blocked", sessionID); break; case "permission.replied": { const reply = properties.reply ?? properties.response; if (reply === "reject") { - await reportState("idle"); + await reportState("idle", sessionID); } else if (reply === "once" || reply === "always") { - await reportState("working"); + await reportState("working", sessionID); } break; } case "question.replied": - await reportState("working"); + await reportState("working", sessionID); break; case "question.rejected": - await reportState("idle"); + await reportState("idle", sessionID); + break; + case "session.created": + case "session.updated": + if (sessionID) { + await reportState("idle", sessionID); + } break; case "session.status": { const status = @@ -102,14 +117,14 @@ export const HerdrAgentStatePlugin = async () => { ? properties.status : properties.status?.type; if (status === "busy" || status === "retry") { - await reportState("working"); + await reportState("working", sessionID); } else if (status === "idle") { - await reportState("idle"); + await reportState("idle", sessionID); } break; } case "session.idle": - await reportState("idle"); + await reportState("idle", sessionID); break; default: break; diff --git a/src/integration/assets/pi/herdr-agent-state.ts b/src/integration/assets/pi/herdr-agent-state.ts index 26869f1c..450fd48c 100644 --- a/src/integration/assets/pi/herdr-agent-state.ts +++ b/src/integration/assets/pi/herdr-agent-state.ts @@ -1,7 +1,7 @@ // installed by herdr // safe to edit. this integration only activates inside herdr-managed panes. // HERDR_INTEGRATION_ID=pi -// HERDR_INTEGRATION_VERSION=1 +// HERDR_INTEGRATION_VERSION=2 // @ts-nocheck import { createConnection } from "node:net"; @@ -52,6 +52,8 @@ const retryGraceMs = parseDurationEnv("HERDR_PI_RETRY_GRACE_MS", 2500); const retryableErrorPattern = /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i; let reportSeq = Date.now() * 1000; +let currentAgentSessionId: string | undefined; +let currentAgentSessionPath: string | undefined; function nextReportSeq(): number { reportSeq += 1; @@ -70,18 +72,45 @@ function parseDurationEnv(name: string, fallback: number): number { return parsed; } +function updateSessionRef(ctx: any): void { + try { + const file = ctx?.sessionManager?.getSessionFile?.(); + currentAgentSessionPath = + typeof file === "string" && file.startsWith("/") ? file : undefined; + } catch { + currentAgentSessionPath = undefined; + } + + try { + const id = ctx?.sessionManager?.getSessionId?.(); + currentAgentSessionId = typeof id === "string" && id.length > 0 ? id : undefined; + } catch { + currentAgentSessionId = undefined; + } +} + +function withSessionRef(params: Record): Record { + if (currentAgentSessionPath) { + return { ...params, agent_session_path: currentAgentSessionPath }; + } + if (currentAgentSessionId) { + return { ...params, agent_session_id: currentAgentSessionId }; + } + return params; +} + function sendState(state: AgentState, message?: string, seq = nextReportSeq()): Promise { return sendRequest({ id: `${source}:${Date.now()}:${Math.random().toString(36).slice(2)}`, method: "pane.report_agent", - params: { + params: withSessionRef({ pane_id: paneId, source, agent: "pi", state, message, seq, - }, + }), }); } @@ -200,9 +229,9 @@ export default function (pi) { return { state: "idle" as const, message: undefined }; } - function publishState() { + function publishState(force = false) { const next = desiredState(); - if (next.state === lastState && next.message === lastMessage) { + if (!force && next.state === lastState && next.message === lastMessage) { return; } lastState = next.state; @@ -220,6 +249,11 @@ export default function (pi) { idleTimer.unref?.(); } + pi.on("session_start", (_event, ctx) => { + updateSessionRef(ctx); + publishState(true); + }); + function holdForRetry(message: string) { clearPendingTimers(); retryHoldActive = true; diff --git a/src/integration/mod.rs b/src/integration/mod.rs index d6ccaa0f..d9abf648 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -12,28 +12,28 @@ use crate::layout::PaneId; pub(crate) const HERDR_PANE_ID_ENV_VAR: &str = "HERDR_PANE_ID"; 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 = 1; +const PI_INTEGRATION_VERSION: u32 = 2; 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 = 1; const PI_CODING_AGENT_DIR_ENV_VAR: &str = "PI_CODING_AGENT_DIR"; const CLAUDE_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; const CLAUDE_HOOK_ASSET: &str = include_str!("assets/claude/herdr-agent-state.sh"); -const CLAUDE_INTEGRATION_VERSION: u32 = 3; +const CLAUDE_INTEGRATION_VERSION: u32 = 4; const CLAUDE_CONFIG_DIR_ENV_VAR: &str = "CLAUDE_CONFIG_DIR"; const CODEX_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; const CODEX_HOOK_ASSET: &str = include_str!("assets/codex/herdr-agent-state.sh"); -const CODEX_INTEGRATION_VERSION: u32 = 3; +const CODEX_INTEGRATION_VERSION: u32 = 4; const CODEX_HOME_ENV_VAR: &str = "CODEX_HOME"; const OPENCODE_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state.js"; const OPENCODE_PLUGIN_ASSET: &str = include_str!("assets/opencode/herdr-agent-state.js"); -const OPENCODE_INTEGRATION_VERSION: u32 = 1; +const OPENCODE_INTEGRATION_VERSION: u32 = 2; 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 = 1; +const HERMES_INTEGRATION_VERSION: u32 = 2; const INTEGRATION_VERSION_MARKER: &str = "HERDR_INTEGRATION_VERSION="; #[derive(Debug)] @@ -683,6 +683,13 @@ pub(crate) fn install_claude() -> io::Result { "SubagentStop", &format!("bash {quoted_hook_path} working"), )?; + ensure_command_hook( + hooks, + "SessionStart", + format!("bash {quoted_hook_path} idle"), + 10, + Some("*"), + )?; ensure_command_hook( hooks, "UserPromptSubmit", @@ -908,6 +915,11 @@ pub(crate) fn uninstall_claude() -> io::Result { "claude settings hooks", )? { let quoted_hook_path = shell_single_quote(&hook_path.display().to_string()); + updated_settings |= remove_command_hook( + hooks, + "SessionStart", + &format!("bash {quoted_hook_path} idle"), + )?; updated_settings |= remove_command_hook( hooks, "UserPromptSubmit", @@ -1901,6 +1913,11 @@ mod tests { ); assert_eq!(hook_content, CLAUDE_HOOK_ASSET); assert!(settings["permissions"]["allow"].is_array()); + assert_eq!(settings["hooks"]["SessionStart"][0]["matcher"], "*"); + assert!(settings["hooks"]["SessionStart"][0]["hooks"][0]["command"] + .as_str() + .unwrap() + .contains(" idle")); assert_eq!(settings["hooks"]["UserPromptSubmit"][0]["matcher"], "*"); assert!( settings["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"] @@ -1984,6 +2001,10 @@ mod tests { .len(), 1 ); + assert_eq!( + settings["hooks"]["SessionStart"].as_array().unwrap().len(), + 1 + ); assert!(settings["hooks"].get("PostToolUse").is_none()); assert!(settings["hooks"].get("PostToolUseFailure").is_none()); assert!(settings["hooks"].get("SubagentStop").is_none()); @@ -2069,7 +2090,7 @@ mod tests { assert_eq!(claude.path, hook_path); assert_eq!(claude.installed_version, Some(1)); - assert_eq!(claude.expected_version, 3); + assert_eq!(claude.expected_version, 4); assert_eq!(claude.state, IntegrationStatusKind::Outdated); std::env::remove_var("HOME"); @@ -2099,7 +2120,7 @@ mod tests { assert_eq!(claude.path, hook_path); assert_eq!(claude.installed_version, Some(2)); - assert_eq!(claude.expected_version, 3); + assert_eq!(claude.expected_version, 4); assert_eq!(claude.state, IntegrationStatusKind::Outdated); std::env::remove_var("HOME"); @@ -2119,7 +2140,8 @@ mod tests { fs::write( claude_dir.join("settings.json"), format!( - r#"{{"hooks":{{"UserPromptSubmit":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep","timeout":10}}]}}],"PermissionRequest":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' blocked","timeout":10}}]}}],"PostToolUse":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}}]}}],"PostToolUseFailure":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}}]}}],"SubagentStop":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}}]}}],"Stop":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' idle","timeout":10}}]}}],"SessionEnd":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' release","timeout":10}}]}}]}}}}"#, + r#"{{"hooks":{{"SessionStart":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' idle","timeout":10}}]}}],"UserPromptSubmit":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep","timeout":10}}]}}],"PermissionRequest":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' blocked","timeout":10}}]}}],"PostToolUse":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}}]}}],"PostToolUseFailure":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}}]}}],"SubagentStop":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}}]}}],"Stop":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' idle","timeout":10}}]}}],"SessionEnd":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' release","timeout":10}}]}}]}}}}"#, + hook_path.display(), hook_path.display(), hook_path.display(), hook_path.display(), @@ -2152,6 +2174,7 @@ mod tests { "echo keep" ); assert!(settings["hooks"].get("PermissionRequest").is_none()); + assert!(settings["hooks"].get("SessionStart").is_none()); assert!(settings["hooks"].get("PostToolUse").is_none()); assert!(settings["hooks"].get("PostToolUseFailure").is_none()); assert!(settings["hooks"].get("SubagentStop").is_none()); @@ -2201,7 +2224,7 @@ mod tests { assert_eq!(codex.path, hook_path); assert_eq!(codex.installed_version, Some(2)); - assert_eq!(codex.expected_version, 3); + assert_eq!(codex.expected_version, 4); assert_eq!(codex.state, IntegrationStatusKind::Outdated); std::env::remove_var("HOME"); @@ -2590,4 +2613,18 @@ mod tests { std::env::remove_var("HOME"); let _ = fs::remove_dir_all(base); } + + #[test] + fn bundled_integration_assets_report_session_refs() { + assert!(PI_EXTENSION_ASSET.contains("agent_session_path: currentAgentSessionPath")); + assert!(PI_EXTENSION_ASSET.contains("agent_session_id: currentAgentSessionId")); + assert!(PI_EXTENSION_ASSET.contains("publishState(true)")); + assert!(CLAUDE_HOOK_ASSET.contains("agent_session_id")); + assert!(CODEX_HOOK_ASSET.contains("HERDR_HOOK_INPUT_FILE")); + assert!(CODEX_HOOK_ASSET.contains("agent_session_id")); + assert!(OPENCODE_PLUGIN_ASSET.contains("properties?.sessionID")); + assert!(OPENCODE_PLUGIN_ASSET.contains("agent_session_id: sessionID")); + assert!(HERMES_PLUGIN_INIT_ASSET.contains("session_id = _session_id(kwargs)")); + assert!(HERMES_PLUGIN_INIT_ASSET.contains("agent_session_id")); + } } diff --git a/src/main.rs b/src/main.rs index e2b9a14a..71631ed4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,6 +18,7 @@ const NESTED_HERDR_MESSAGES: [&str; 6] = [ "recursion detected. base case not found. aborting.", ]; +mod agent_resume; mod api; mod app; mod cli; @@ -212,6 +213,11 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # [ui.sound.agents] # droid = "off" +[session] +# Resume supported AI-agent panes into their native conversation sessions after +# a Herdr server restart. Requires official integrations that report session refs. +# resume_agents_on_restore = false + [experimental] # Allow launching herdr from inside a herdr-managed pane. # allow_nested = false diff --git a/src/pane.rs b/src/pane.rs index 20eb43da..70b855cb 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -326,6 +326,35 @@ fn pane_shell_from(configured_shell: &str, env_shell: Option) -> String .unwrap_or_else(|| "/bin/sh".into()) } +fn restore_command_builder(agent: &str, fallback_shell: &str, argv: &[String]) -> CommandBuilder { + let mut cmd = CommandBuilder::new("/bin/sh"); + cmd.arg("-c"); + cmd.arg( + r#"agent="$1" +fallback_shell="$2" +early_window="$3" +shift 3 + start="$(date +%s 2>/dev/null || printf 0)" + "$@" + status="$?" + end="$(date +%s 2>/dev/null || printf 999999)" + elapsed="$((end - start))" + if [ "$status" -ne 0 ] && [ "$elapsed" -le "$early_window" ]; then + printf 'herdr: %s session restore failed; started a shell instead\n' "$agent" + fi + exec "$fallback_shell" + "#, + ); + cmd.arg("herdr-agent-restore"); + cmd.arg(agent); + cmd.arg(fallback_shell); + cmd.arg("30"); + for arg in argv { + cmd.arg(arg); + } + cmd +} + impl PaneRuntime { pub fn shutdown(self) { self.detect_handle.abort(); @@ -365,6 +394,7 @@ impl PaneRuntime { render_dirty, cmd, "failed to spawn shell", + None, ) } @@ -402,6 +432,7 @@ impl PaneRuntime { render_dirty, cmd, "failed to spawn command pane", + None, ) } @@ -442,6 +473,48 @@ impl PaneRuntime { render_dirty, cmd, "failed to spawn argv command pane", + None, + ) + } + + pub fn spawn_agent_restore( + pane_id: PaneId, + rows: u16, + cols: u16, + cwd: std::path::PathBuf, + restore_plan: &crate::agent_resume::AgentResumePlan, + scrollback_limit_bytes: usize, + host_terminal_theme: crate::terminal_theme::TerminalTheme, + default_shell: &str, + events: mpsc::Sender, + render_notify: Arc, + render_dirty: Arc, + ) -> std::io::Result { + if restore_plan.argv.is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "restore argv must not be empty", + )); + } + + let shell = pane_shell(default_shell); + let mut cmd = restore_command_builder(&restore_plan.agent, &shell, &restore_plan.argv); + cmd.cwd(cwd); + cmd.env(crate::HERDR_ENV_VAR, crate::HERDR_ENV_VALUE); + apply_pane_terminal_env(&mut cmd); + crate::integration::apply_pane_env(&mut cmd, pane_id); + Self::spawn_command_builder( + pane_id, + rows, + cols, + scrollback_limit_bytes, + host_terminal_theme, + events, + render_notify, + render_dirty, + cmd, + "failed to spawn agent restore pane", + crate::detect::parse_agent_label(&restore_plan.agent), ) } @@ -456,6 +529,7 @@ impl PaneRuntime { render_dirty: Arc, cmd: CommandBuilder, spawn_error_message: &'static str, + initial_detected_agent: Option, ) -> std::io::Result { let pty_system = native_pty_system(); let pair = pty_system @@ -605,12 +679,17 @@ impl PaneRuntime { let pending_release_for_task = pending_release.clone(); let handle = tokio::spawn(async move { - let mut agent_presence = AgentDetectionPresence::from_agent(None); - let mut state = AgentState::Unknown; + let mut agent_presence = AgentDetectionPresence::from_agent(initial_detected_agent); + let mut state = if initial_detected_agent.is_some() { + AgentState::Idle + } else { + AgentState::Unknown + }; let mut last_process_check = Instant::now(); let mut last_foreground_pgid = None; let mut pending_foreground_shell_clear = false; let mut foreground_shell_exit_reported = false; + let mut pending_restore_probe = initial_detected_agent.is_some(); let mut last_claude_working_at = None; let mut last_visible_blocker = false; let mut last_visible_idle = false; @@ -637,6 +716,7 @@ impl PaneRuntime { last_foreground_pgid = None; pending_foreground_shell_clear = false; foreground_shell_exit_reported = false; + pending_restore_probe = false; last_claude_working_at = None; last_visible_blocker = false; last_visible_idle = false; @@ -657,6 +737,7 @@ impl PaneRuntime { || agent_presence.current_agent().is_none() || foreground_group_changed || pending_foreground_shell_clear + || pending_restore_probe || now.duration_since(last_process_check) >= PROCESS_RECHECK; let mut agent_changed = false; @@ -718,8 +799,10 @@ impl PaneRuntime { }; if new_agent.is_some() { last_foreground_pgid = process_group_id; + pending_restore_probe = false; } else if agent_presence.current_agent().is_none() { last_foreground_pgid = None; + pending_restore_probe = false; } if changed { agent = agent_presence.current_agent(); @@ -1234,6 +1317,26 @@ mod tests { output } + fn capture_command_output(cmd: CommandBuilder) -> (bool, String) { + let pair = native_pty_system() + .openpty(PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + }) + .unwrap(); + let mut reader = pair.master.try_clone_reader().unwrap(); + let slave = pair.slave; + let mut child = slave.spawn_command(cmd).unwrap(); + drop(slave); + let status = child.wait().unwrap(); + + let mut output = String::new(); + reader.read_to_string(&mut output).unwrap(); + (status.success(), output) + } + #[test] fn pane_shell_prefers_configured_shell() { assert_eq!( @@ -1271,6 +1374,78 @@ mod tests { assert_eq!(output, "vt100\n24bit\n"); } + #[test] + fn restore_wrapper_falls_back_after_early_resume_failure() { + let argv = vec!["/bin/sh".into(), "-c".into(), "exit 7".into()]; + let cmd = restore_command_builder("codex", "/bin/true", &argv); + let (success, output) = capture_command_output(cmd); + + assert!(success, "fallback command should own the final exit status"); + assert!(output.contains("herdr: codex session restore failed; started a shell instead")); + } + + #[tokio::test] + async fn spawn_agent_restore_keeps_pane_alive_after_early_failure() { + let (events, mut event_rx) = mpsc::channel(4); + let runtime = PaneRuntime::spawn_agent_restore( + PaneId::from_raw(7), + 24, + 80, + std::env::current_dir().unwrap(), + &crate::agent_resume::AgentResumePlan { + agent: "codex".into(), + argv: vec!["/bin/sh".into(), "-c".into(), "exit 7".into()], + dedupe_key: "test".into(), + }, + 0, + crate::terminal_theme::TerminalTheme::default(), + "/bin/sh", + events, + Arc::new(Notify::new()), + Arc::new(AtomicBool::new(false)), + ) + .unwrap(); + + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + + assert!(runtime + .visible_text() + .contains("herdr: codex session restore failed; started a shell instead")); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(50), event_rx.recv()) + .await + .is_err(), + "fallback shell should keep the pane runtime alive" + ); + let deadline = tokio::time::Instant::now() + std::time::Duration::from_millis(2500); + let mut cleared = false; + while tokio::time::Instant::now() < deadline { + let Some(event) = tokio::time::timeout( + deadline.saturating_duration_since(tokio::time::Instant::now()), + event_rx.recv(), + ) + .await + .expect("fallback shell should clear the seeded restored agent") else { + break; + }; + if matches!( + event, + AppEvent::StateChanged { + pane_id, + agent: None, + state: AgentState::Unknown, + .. + } if pane_id == PaneId::from_raw(7) + ) { + cleared = true; + break; + } + } + assert!(cleared); + + runtime.shutdown(); + } + #[tokio::test] async fn focus_events_are_forwarded_when_enabled() { let (tx, mut rx) = mpsc::channel(4); diff --git a/src/persist/restore.rs b/src/persist/restore.rs index ebad2f16..4f763943 100644 --- a/src/persist/restore.rs +++ b/src/persist/restore.rs @@ -7,6 +7,7 @@ use ratatui::layout::Direction; use tokio::sync::{mpsc, Notify}; use tracing::{error, warn}; +use crate::detect::AgentState; use crate::events::AppEvent; use crate::layout::{Node, PaneId, TileLayout}; use crate::pane::PaneState; @@ -22,6 +23,7 @@ pub fn restore( cols: u16, scrollback_limit_bytes: usize, default_shell: &str, + resume_agents_on_restore: bool, events: mpsc::Sender, render_notify: Arc, render_dirty: Arc, @@ -33,6 +35,7 @@ pub fn restore( let mut workspaces = Vec::new(); let mut terminals = HashMap::new(); let mut terminal_runtimes = HashMap::new(); + let mut resumed_agent_sessions = HashSet::new(); for ws_snap in &snapshot.workspaces { if let Some((workspace, restored_terminals, restored_runtimes)) = restore_workspace( ws_snap, @@ -40,6 +43,8 @@ pub fn restore( cols, scrollback_limit_bytes, default_shell, + resume_agents_on_restore, + &mut resumed_agent_sessions, events.clone(), render_notify.clone(), render_dirty.clone(), @@ -60,6 +65,8 @@ fn restore_workspace( cols: u16, scrollback_limit_bytes: usize, default_shell: &str, + resume_agents_on_restore: bool, + resumed_agent_sessions: &mut HashSet, events: mpsc::Sender, render_notify: Arc, render_dirty: Arc, @@ -82,6 +89,8 @@ fn restore_workspace( cols, scrollback_limit_bytes, default_shell, + resume_agents_on_restore, + resumed_agent_sessions, events.clone(), render_notify.clone(), render_dirty.clone(), @@ -142,6 +151,8 @@ fn restore_tab( cols: u16, scrollback_limit_bytes: usize, default_shell: &str, + resume_agents_on_restore: bool, + resumed_agent_sessions: &mut HashSet, events: mpsc::Sender, render_notify: Arc, render_dirty: Arc, @@ -192,19 +203,55 @@ fn restore_tab( .get(id) .and_then(|old_id| snap.panes.get(old_id)) .and_then(|p| p.agent_name.clone()); + let saved_agent_session = reverse_id_map + .get(id) + .and_then(|old_id| snap.panes.get(old_id)) + .and_then(|p| p.agent_session.as_ref()); + let mut restore_plan = reverse_id_map + .get(id) + .and_then(|old_id| snap.panes.get(old_id)) + .and_then(|p| p.agent_session.as_ref()) + .and_then(|session| restore_plan_for_snapshot(session, resume_agents_on_restore)); + let duplicate_agent_session = restore_plan + .as_ref() + .is_some_and(|plan| !resumed_agent_sessions.insert(plan.dedupe_key.clone())); + if duplicate_agent_session { + restore_plan = None; + } + let initial_restore_agent = restore_plan + .as_ref() + .and_then(|plan| crate::detect::parse_agent_label(&plan.agent)); - match TerminalRuntime::spawn( - *id, - rows, - cols, - cwd.clone(), - scrollback_limit_bytes, - crate::terminal_theme::TerminalTheme::default(), - default_shell, - events.clone(), - render_notify.clone(), - render_dirty.clone(), - ) { + let runtime_result = if let Some(plan) = restore_plan { + TerminalRuntime::spawn_agent_restore( + *id, + rows, + cols, + cwd.clone(), + &plan, + scrollback_limit_bytes, + crate::terminal_theme::TerminalTheme::default(), + default_shell, + events.clone(), + render_notify.clone(), + render_dirty.clone(), + ) + } else { + TerminalRuntime::spawn( + *id, + rows, + cols, + cwd.clone(), + scrollback_limit_bytes, + crate::terminal_theme::TerminalTheme::default(), + default_shell, + events.clone(), + render_notify.clone(), + render_dirty.clone(), + ) + }; + + match runtime_result { Ok(runtime) => { let terminal_id = TerminalId::alloc(); let mut terminal = TerminalState::new(terminal_id.clone(), cwd.clone()); @@ -214,6 +261,22 @@ fn restore_tab( if let Some(agent_name) = saved_agent_name { terminal.set_agent_name(agent_name); } + if let Some(agent) = initial_restore_agent { + let _ = terminal.set_detected_state_with_screen_signals_at( + Some(agent), + AgentState::Idle, + false, + false, + false, + false, + std::time::Instant::now(), + ); + } + if let Some(session) = + restored_terminal_agent_session(saved_agent_session, duplicate_agent_session) + { + terminal.set_persisted_agent_session(session); + } panes.insert(*id, PaneState::new(terminal_id.clone())); terminal_runtimes.insert(terminal_id, runtime); terminals.push(terminal); @@ -269,6 +332,48 @@ fn restore_tab( )) } +fn restore_plan_for_snapshot( + session: &super::snapshot::PaneAgentSessionSnapshot, + resume_agents_on_restore: bool, +) -> Option { + if !resume_agents_on_restore { + return None; + } + let persisted = persisted_agent_session_from_snapshot(session)?; + crate::agent_resume::plan(&session.source, &session.agent, &persisted.session_ref) +} + +fn persisted_agent_session_from_snapshot( + session: &super::snapshot::PaneAgentSessionSnapshot, +) -> Option { + crate::agent_resume::session_ref_from_snapshot( + &session.source, + &session.agent, + session.kind, + &session.value, + ) +} + +fn restored_terminal_agent_session( + session: Option<&super::snapshot::PaneAgentSessionSnapshot>, + duplicate_agent_session: bool, +) -> Option { + if duplicate_agent_session { + return None; + } + session.and_then(persisted_agent_session_from_snapshot) +} + +#[cfg(test)] +fn take_restore_plan_for_snapshot( + session: &super::snapshot::PaneAgentSessionSnapshot, + resume_agents_on_restore: bool, + resumed_agent_sessions: &mut HashSet, +) -> Option { + restore_plan_for_snapshot(session, resume_agents_on_restore) + .filter(|plan| resumed_agent_sessions.insert(plan.dedupe_key.clone())) +} + pub(super) fn prune_restored_node(node: Node, surviving: &HashSet) -> Option { match node { Node::Pane(id) => surviving.contains(&id).then_some(Node::Pane(id)), @@ -436,4 +541,142 @@ mod tests { assert_eq!(restored_worktree_space_membership(Some(membership)), None); } + + #[test] + fn restore_plan_respects_opt_in_and_allowlist() { + let session = super::super::snapshot::PaneAgentSessionSnapshot { + source: "herdr:pi".into(), + agent: "pi".into(), + kind: crate::agent_resume::AgentSessionRefKind::Path, + value: "/tmp/pi-session.jsonl".into(), + }; + + assert!(restore_plan_for_snapshot(&session, false).is_none()); + assert_eq!( + restore_plan_for_snapshot(&session, true).unwrap().argv, + vec!["pi", "--session", "/tmp/pi-session.jsonl"] + ); + + let unsupported_path = super::super::snapshot::PaneAgentSessionSnapshot { + source: "herdr:claude".into(), + agent: "claude".into(), + kind: crate::agent_resume::AgentSessionRefKind::Path, + value: "/tmp/claude-session".into(), + }; + assert!(restore_plan_for_snapshot(&unsupported_path, true).is_none()); + } + + #[test] + fn restore_plan_selection_suppresses_duplicates() { + let session = super::super::snapshot::PaneAgentSessionSnapshot { + source: "herdr:pi".into(), + agent: "pi".into(), + kind: crate::agent_resume::AgentSessionRefKind::Path, + value: "/tmp/pi-session.jsonl".into(), + }; + let mut resumed = HashSet::new(); + + assert!(take_restore_plan_for_snapshot(&session, false, &mut resumed).is_none()); + assert!(resumed.is_empty()); + + let first = take_restore_plan_for_snapshot(&session, true, &mut resumed) + .expect("first restore should get a plan"); + assert_eq!(first.argv, vec!["pi", "--session", "/tmp/pi-session.jsonl"]); + assert!(take_restore_plan_for_snapshot(&session, true, &mut resumed).is_none()); + } + + #[test] + fn restore_rehydrates_agent_session_metadata() { + let session = super::super::snapshot::PaneAgentSessionSnapshot { + source: "herdr:hermes".into(), + agent: "hermes".into(), + kind: crate::agent_resume::AgentSessionRefKind::Id, + value: "hermes-session".into(), + }; + + let preserved = restored_terminal_agent_session(Some(&session), false) + .expect("restore should preserve metadata"); + assert_eq!(preserved.source, "herdr:hermes"); + assert_eq!(preserved.agent, "hermes"); + assert_eq!(preserved.session_ref.value, "hermes-session"); + } + + #[test] + fn restore_does_not_rehydrate_duplicate_agent_session_metadata() { + let session = super::super::snapshot::PaneAgentSessionSnapshot { + source: "herdr:pi".into(), + agent: "pi".into(), + kind: crate::agent_resume::AgentSessionRefKind::Path, + value: "/tmp/pi-session.jsonl".into(), + }; + let mut resumed = HashSet::new(); + assert!(take_restore_plan_for_snapshot(&session, true, &mut resumed).is_some()); + assert!(take_restore_plan_for_snapshot(&session, true, &mut resumed).is_none()); + + assert!(restored_terminal_agent_session(Some(&session), true).is_none()); + } + + #[tokio::test] + async fn restore_carries_persisted_agent_session_metadata() { + let cwd = std::env::current_dir().unwrap(); + let snapshot = SessionSnapshot { + version: super::super::snapshot::SNAPSHOT_VERSION, + workspaces: vec![WorkspaceSnapshot { + id: Some("workspace".into()), + custom_name: None, + identity_cwd: cwd.clone(), + worktree_space: None, + tabs: vec![TabSnapshot { + custom_name: None, + layout: LayoutSnapshot::Pane(0), + panes: HashMap::from([( + 0, + super::super::snapshot::PaneSnapshot { + cwd, + label: None, + agent_name: None, + agent_session: Some(super::super::snapshot::PaneAgentSessionSnapshot { + source: "herdr:opencode".into(), + agent: "opencode".into(), + kind: crate::agent_resume::AgentSessionRefKind::Id, + value: "opencode-session".into(), + }), + }, + )]), + zoomed: false, + focused: Some(0), + root_pane: Some(0), + }], + active_tab: 0, + }], + active: Some(0), + selected: 0, + agent_panel_scope: Default::default(), + sidebar_width: None, + sidebar_section_split: None, + collapsed_space_keys: Default::default(), + }; + let (events, _event_rx) = mpsc::channel(4); + + let (_workspaces, terminals, _runtimes) = restore( + &snapshot, + 24, + 80, + 0, + "/bin/true", + false, + events, + Arc::new(Notify::new()), + Arc::new(AtomicBool::new(false)), + ); + + let session = terminals + .values() + .next() + .and_then(|terminal| terminal.persisted_agent_session.as_ref()) + .expect("persisted agent session should survive restore"); + assert_eq!(session.source, "herdr:opencode"); + assert_eq!(session.agent, "opencode"); + assert_eq!(session.session_ref.value, "opencode-session"); + } } diff --git a/src/persist/snapshot.rs b/src/persist/snapshot.rs index 79d5c406..a223513d 100644 --- a/src/persist/snapshot.rs +++ b/src/persist/snapshot.rs @@ -77,6 +77,16 @@ pub struct PaneSnapshot { pub label: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub agent_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_session: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneAgentSessionSnapshot { + pub source: String, + pub agent: String, + pub kind: crate::agent_resume::AgentSessionRefKind, + pub value: String, } /// Serializable BSP tree. @@ -280,12 +290,37 @@ fn capture_tab( .get(id) .and_then(|pane| terminals.get(&pane.attached_terminal_id)) .and_then(|terminal| terminal.agent_name.clone()); + let agent_session = + tab.panes + .get(id) + .and_then(|pane| terminals.get(&pane.attached_terminal_id)) + .and_then(|terminal| { + if let Some(authority) = terminal.hook_authority.as_ref() { + if let Some(session_ref) = authority.session_ref.as_ref() { + return Some(PaneAgentSessionSnapshot { + source: authority.source.clone(), + agent: authority.agent_label.clone(), + kind: session_ref.kind, + value: session_ref.value.clone(), + }); + } + } + terminal.persisted_agent_session.as_ref().map(|session| { + PaneAgentSessionSnapshot { + source: session.source.clone(), + agent: session.agent.clone(), + kind: session.session_ref.kind, + value: session.session_ref.value.clone(), + } + }) + }); panes.insert( id.raw(), PaneSnapshot { cwd, label, agent_name, + agent_session, }, ); } @@ -448,6 +483,7 @@ mod tests { cwd: PathBuf::from("/home/can/Projects/herdr"), label: None, agent_name: None, + agent_session: None, }, ); panes.insert( @@ -456,6 +492,7 @@ mod tests { cwd: PathBuf::from("/home/can/Projects/website"), label: Some("website".into()), agent_name: None, + agent_session: None, }, ); @@ -764,6 +801,76 @@ mod tests { assert_eq!(tab.panes[&second.raw()].cwd, PathBuf::from("/tmp/herdr")); } + #[test] + fn capture_contract_tracks_hook_authority_agent_session() { + let mut state = state_with_workspaces(&["one"]); + let root = state.workspaces[0].tabs[0].root_pane; + state.ensure_test_terminals(); + 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, + None, + crate::agent_resume::AgentSessionRef::path("/tmp/pi-session.jsonl"), + Some(20), + ); + + let snapshot = capture_from_state(&state); + let agent_session = snapshot.workspaces[0].tabs[0].panes[&root.raw()] + .agent_session + .as_ref() + .expect("agent session should be captured"); + + assert_eq!(agent_session.source, "herdr:pi"); + assert_eq!(agent_session.agent, "pi"); + assert_eq!( + agent_session.kind, + crate::agent_resume::AgentSessionRefKind::Path + ); + assert_eq!(agent_session.value, "/tmp/pi-session.jsonl"); + } + + #[test] + fn capture_contract_preserves_restored_agent_session() { + let mut state = state_with_workspaces(&["one"]); + let root = state.workspaces[0].tabs[0].root_pane; + state.ensure_test_terminals(); + let terminal_id = state.workspaces[0].tabs[0].panes[&root] + .attached_terminal_id + .clone(); + state + .terminals + .get_mut(&terminal_id) + .unwrap() + .set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { + source: "herdr:opencode".into(), + agent: "opencode".into(), + session_ref: crate::agent_resume::AgentSessionRef::id("opencode-session").unwrap(), + }); + + let snapshot = capture_from_state(&state); + let agent_session = snapshot.workspaces[0].tabs[0].panes[&root.raw()] + .agent_session + .as_ref() + .expect("persisted agent session should be captured"); + + assert_eq!(agent_session.source, "herdr:opencode"); + assert_eq!(agent_session.agent, "opencode"); + assert_eq!( + agent_session.kind, + crate::agent_resume::AgentSessionRefKind::Id + ); + assert_eq!(agent_session.value, "opencode-session"); + } + #[test] fn old_unversioned_snapshot_loads_as_version_0() { let json = r#"{"workspaces":[],"active":null,"selected":0}"#; @@ -793,6 +900,7 @@ mod tests { cwd: PathBuf::from("/tmp/this-directory-does-not-exist-for-herdr-test"), label: None, agent_name: None, + agent_session: None, }, ); panes.insert( @@ -803,6 +911,7 @@ mod tests { .unwrap_or_else(|_| PathBuf::from("/tmp")), label: None, agent_name: None, + agent_session: None, }, ); diff --git a/src/server/headless.rs b/src/server/headless.rs index bf28f36c..632f205d 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -3746,6 +3746,8 @@ next_tab = "" message: None, custom_status: None, seq: Some(19), + agent_session_id: None, + agent_session_path: None, }), }, respond_to, diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 7e43f7a8..663cb6fe 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -6,4 +6,4 @@ pub mod state; pub use id::TerminalId; pub use runtime::TerminalRuntime; pub(crate) use runtime_registry::TerminalRuntimeRegistry; -pub use state::{EffectiveStateChange, TerminalState}; +pub use state::{EffectiveStateChange, TerminalState, TerminalStateMutation}; diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index 43c2467d..be69b713 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -102,6 +102,35 @@ impl TerminalRuntime { .map(Self) } + pub fn spawn_agent_restore( + pane_id: PaneId, + rows: u16, + cols: u16, + cwd: std::path::PathBuf, + restore_plan: &crate::agent_resume::AgentResumePlan, + scrollback_limit_bytes: usize, + host_terminal_theme: crate::terminal_theme::TerminalTheme, + default_shell: &str, + events: mpsc::Sender, + render_notify: Arc, + render_dirty: Arc, + ) -> std::io::Result { + crate::pane::PaneRuntime::spawn_agent_restore( + pane_id, + rows, + cols, + cwd, + restore_plan, + scrollback_limit_bytes, + host_terminal_theme, + default_shell, + events, + render_notify, + render_dirty, + ) + .map(Self) + } + pub fn apply_host_terminal_theme(&self, theme: crate::terminal_theme::TerminalTheme) { self.0.apply_host_terminal_theme(theme); } diff --git a/src/terminal/state.rs b/src/terminal/state.rs index 7f9a254a..b94be3e7 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -22,6 +22,7 @@ pub struct HookAuthority { pub message: Option, pub custom_status: Option, pub reported_at: Instant, + pub session_ref: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -35,6 +36,12 @@ pub struct EffectiveStateChange { pub custom_status: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct TerminalStateMutation { + pub effective_state_change: Option, + pub session_ref_changed: bool, +} + /// Pure state for a server-owned terminal. /// /// During the migration this is still one-to-one with a pane-backed PTY, but @@ -51,6 +58,7 @@ pub struct TerminalState { fallback_observed_at: Option, stale_hook_idle_since: Option, pub hook_authority: Option, + pub persisted_agent_session: Option, pub manual_label: Option, pub agent_name: Option, hook_report_sequences: HashMap, @@ -72,6 +80,7 @@ impl TerminalState { fallback_observed_at: None, stale_hook_idle_since: None, hook_authority: None, + persisted_agent_session: None, manual_label: None, agent_name: None, hook_report_sequences: HashMap::new(), @@ -95,6 +104,23 @@ impl TerminalState { self.set_detected_state_with_visible_blocker(agent, fallback_state, false, false, false) } + #[cfg(test)] + pub fn set_detected_state_with_mutation( + &mut self, + agent: Option, + fallback_state: AgentState, + ) -> TerminalStateMutation { + self.set_detected_state_with_screen_signals_at( + agent, + fallback_state, + false, + false, + false, + false, + Instant::now(), + ) + } + #[cfg(test)] pub fn set_detected_state_with_visible_blocker( &mut self, @@ -113,6 +139,7 @@ impl TerminalState { process_exited, Instant::now(), ) + .effective_state_change } pub fn set_detected_state_with_screen_signals_at( @@ -124,11 +151,12 @@ impl TerminalState { visible_working: bool, process_exited: bool, now: Instant, - ) -> Option { + ) -> TerminalStateMutation { 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 previous_detected_agent = self.detected_agent; + let previous_session = self.current_session_identity_for_persistence(); self.detected_agent = agent; self.fallback_state = fallback_state; self.fallback_visible_blocker = visible_blocker && fallback_state == AgentState::Blocked; @@ -156,13 +184,26 @@ impl TerminalState { self.hook_authority = None; self.stale_hook_idle_since = None; } + let detected_agent_changed_or_disappeared = + previous_detected_agent.is_some() && agent != previous_detected_agent; + let persisted_agent_was_previously_detected = + self.persisted_agent_session_belongs_to_detected_agent(previous_detected_agent); + if self.persisted_agent_session_conflicts_with_detected_agent(agent) + || detected_agent_changed_or_disappeared && persisted_agent_was_previously_detected + { + self.persisted_agent_session = None; + } self.update_stale_hook_idle_window(now); - self.recompute_effective_state( - previous_agent_label, - previous_known_agent, - previous_state, - now, - ) + TerminalStateMutation { + effective_state_change: self.recompute_effective_state( + previous_agent_label, + previous_known_agent, + previous_state, + now, + ), + session_ref_changed: previous_session + != self.current_session_identity_for_persistence(), + } } #[cfg(test)] @@ -177,6 +218,7 @@ impl TerminalState { self.set_hook_authority_with_custom_status(source, agent_label, state, message, None, seq) } + #[cfg(test)] pub fn set_hook_authority_with_custom_status( &mut self, source: String, @@ -192,6 +234,30 @@ impl TerminalState { state, message, custom_status, + None, + seq, + Instant::now(), + ) + .and_then(|mutation| mutation.effective_state_change) + } + + pub fn set_hook_authority_with_session_ref( + &mut self, + source: String, + agent_label: String, + state: AgentState, + message: Option, + custom_status: Option, + session_ref: Option, + seq: Option, + ) -> Option { + self.set_hook_authority_with_custom_status_at( + source, + agent_label, + state, + message, + custom_status, + session_ref, seq, Instant::now(), ) @@ -204,9 +270,10 @@ impl TerminalState { state: AgentState, message: Option, custom_status: Option, + session_ref: Option, seq: Option, now: Instant, - ) -> Option { + ) -> Option { if !self.accept_hook_report(&source, seq) { return None; } @@ -214,9 +281,11 @@ impl TerminalState { 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 previous_session = self.current_session_identity_for_persistence(); if self.known_agent_label_conflicts_with_detected_agent(&agent_label) { return None; } + self.persisted_agent_session = None; self.hook_authority = Some(HookAuthority { source, agent_label, @@ -224,14 +293,19 @@ impl TerminalState { message, custom_status, reported_at: now, + session_ref, }); self.stale_hook_idle_since = None; - self.recompute_effective_state( - previous_agent_label, - previous_known_agent, - previous_state, - now, - ) + let current_session = self.current_session_identity_for_persistence(); + Some(TerminalStateMutation { + effective_state_change: self.recompute_effective_state( + previous_agent_label, + previous_known_agent, + previous_state, + now, + ), + session_ref_changed: previous_session != current_session, + }) } fn hook_authority_not_newer_than(&self, observed_at: Instant) -> bool { @@ -257,6 +331,73 @@ impl TerminalState { }) } + fn persisted_agent_session_conflicts_with_detected_agent( + &self, + detected_agent: Option, + ) -> bool { + let Some(detected_agent) = detected_agent else { + return false; + }; + self.persisted_agent_session + .as_ref() + .and_then(|session| crate::detect::parse_agent_label(&session.agent)) + .is_some_and(|agent| agent != detected_agent) + } + + fn persisted_agent_session_belongs_to_detected_agent( + &self, + detected_agent: Option, + ) -> bool { + let Some(detected_agent) = detected_agent else { + return false; + }; + self.persisted_agent_session + .as_ref() + .and_then(|session| crate::detect::parse_agent_label(&session.agent)) + .is_some_and(|agent| agent == detected_agent) + } + + fn persisted_agent_session_matches(&self, source: &str, agent: &str) -> bool { + self.persisted_agent_session + .as_ref() + .is_some_and(|session| session.source == source && session.agent == agent) + } + + fn current_session_identity_for_persistence( + &self, + ) -> Option<( + String, + String, + crate::agent_resume::AgentSessionRefKind, + String, + )> { + if let Some(authority) = self.hook_authority.as_ref() { + if let Some(session_ref) = authority.session_ref.as_ref() { + return Some(( + authority.source.clone(), + authority.agent_label.clone(), + session_ref.kind, + session_ref.value.clone(), + )); + } + } + self.persisted_agent_session.as_ref().map(|session| { + ( + session.source.clone(), + session.agent.clone(), + session.session_ref.kind, + session.session_ref.value.clone(), + ) + }) + } + + pub fn set_persisted_agent_session( + &mut self, + session: crate::agent_resume::PersistedAgentSession, + ) { + self.persisted_agent_session = Some(session); + } + fn known_agent_label_conflicts_with_detected_agent(&self, agent_label: &str) -> bool { let Some(detected_agent) = self.detected_agent else { return false; @@ -282,11 +423,21 @@ impl TerminalState { true } + #[cfg(test)] pub fn clear_hook_authority( &mut self, source: Option<&str>, seq: Option, ) -> Option { + self.clear_hook_authority_with_mutation(source, seq) + .and_then(|mutation| mutation.effective_state_change) + } + + pub fn clear_hook_authority_with_mutation( + &mut self, + source: Option<&str>, + seq: Option, + ) -> Option { let sequence_source = source.map(str::to_string).or_else(|| { self.hook_authority .as_ref() @@ -301,6 +452,7 @@ impl TerminalState { 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 previous_session = self.current_session_identity_for_persistence(); let should_clear = self .hook_authority .as_ref() @@ -310,26 +462,37 @@ impl TerminalState { } self.hook_authority = None; self.stale_hook_idle_since = None; - self.recompute_effective_state( - previous_agent_label, - previous_known_agent, - previous_state, - Instant::now(), - ) + self.persisted_agent_session = None; + let now = Instant::now(); + Some(TerminalStateMutation { + effective_state_change: self.recompute_effective_state( + previous_agent_label, + previous_known_agent, + previous_state, + now, + ), + session_ref_changed: previous_session.is_some(), + }) } + #[cfg(test)] pub fn release_agent( &mut self, source: &str, agent_label: &str, seq: Option, ) -> Option { - if !self.accept_hook_report(source, seq) { - return None; - } + self.release_agent_with_mutation(source, agent_label, seq) + .and_then(|mutation| mutation.effective_state_change) + } - let current_agent_label = self.effective_agent_label()?; - if current_agent_label != agent_label { + 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; } @@ -339,9 +502,16 @@ impl TerminalState { return None; } + let matches_current_agent = self.effective_agent_label() == Some(agent_label); + let matches_persisted_session = self.persisted_agent_session_matches(source, agent_label); + if !matches_current_agent && !matches_persisted_session { + 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 previous_session = self.current_session_identity_for_persistence(); self.detected_agent = None; self.fallback_state = AgentState::Unknown; self.fallback_visible_blocker = false; @@ -350,12 +520,17 @@ impl TerminalState { self.fallback_observed_at = None; self.hook_authority = None; self.stale_hook_idle_since = None; - self.recompute_effective_state( - previous_agent_label, - previous_known_agent, - previous_state, - Instant::now(), - ) + self.persisted_agent_session = None; + let now = Instant::now(); + Some(TerminalStateMutation { + effective_state_change: self.recompute_effective_state( + previous_agent_label, + previous_known_agent, + previous_state, + now, + ), + session_ref_changed: previous_session.is_some(), + }) } pub fn effective_agent_label(&self) -> Option<&str> { @@ -860,6 +1035,7 @@ mod tests { None, Some("thinking".into()), None, + None, now, ); @@ -873,7 +1049,7 @@ mod tests { now + Duration::from_millis(500), ); - assert!(waiting.is_none()); + assert!(waiting.effective_state_change.is_none()); assert_eq!(terminal.fallback_state, AgentState::Idle); assert_eq!(terminal.state, AgentState::Working); assert_eq!(terminal.effective_custom_status(), Some("thinking")); @@ -890,7 +1066,10 @@ mod tests { assert_eq!(terminal.state, AgentState::Idle); assert_eq!(terminal.effective_custom_status(), None); - assert_eq!(change.unwrap().previous_state, AgentState::Working); + assert_eq!( + change.effective_state_change.unwrap().previous_state, + AgentState::Working + ); } #[test] @@ -905,6 +1084,7 @@ mod tests { None, Some("thinking".into()), None, + None, now, ); terminal.set_detected_state_with_screen_signals_at( @@ -923,6 +1103,7 @@ mod tests { AgentState::Working, None, Some("thinking".into()), + None, Some(1), now + Duration::from_millis(800), ); @@ -936,7 +1117,7 @@ mod tests { now + STALE_HOOK_IDLE_GRACE + Duration::from_millis(1), ); - assert!(change.is_none()); + assert!(change.effective_state_change.is_none()); assert_eq!(terminal.state, AgentState::Working); } @@ -952,6 +1133,7 @@ mod tests { None, None, None, + None, now, ); @@ -966,7 +1148,10 @@ mod tests { ); assert_eq!(terminal.state, AgentState::Working); - assert_eq!(change.unwrap().previous_state, AgentState::Idle); + assert_eq!( + change.effective_state_change.unwrap().previous_state, + AgentState::Idle + ); } #[test] @@ -1183,6 +1368,7 @@ mod tests { AgentState::Working, None, None, + None, Some(1), observed + Duration::from_secs(1), ); @@ -1220,6 +1406,7 @@ mod tests { AgentState::Working, None, None, + None, Some(1), observed, ); @@ -1229,6 +1416,7 @@ mod tests { AgentState::Working, None, Some("new turn".into()), + None, Some(2), observed + Duration::from_secs(1), ); @@ -1316,6 +1504,263 @@ mod tests { ); } + #[test] + fn accepted_hook_report_stores_session_ref() { + let mut terminal = test_terminal(); + let mutation = terminal + .set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::path("/tmp/pi.jsonl"), + Some(20), + ) + .expect("accepted report"); + + assert!(mutation.session_ref_changed); + assert_eq!( + terminal + .hook_authority + .as_ref() + .and_then(|authority| authority.session_ref.as_ref()) + .map(|session_ref| (&session_ref.kind, session_ref.value.as_str())), + Some(( + &crate::agent_resume::AgentSessionRefKind::Path, + "/tmp/pi.jsonl" + )) + ); + } + + #[test] + fn stale_hook_report_cannot_overwrite_session_ref() { + let mut terminal = test_terminal(); + terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::path("/tmp/pi.jsonl"), + Some(20), + ); + + let mutation = terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::path("/tmp/new.jsonl"), + Some(19), + ); + + assert!(mutation.is_none()); + assert_eq!( + terminal + .hook_authority + .as_ref() + .and_then(|authority| authority.session_ref.as_ref()) + .map(|session_ref| session_ref.value.as_str()), + Some("/tmp/pi.jsonl") + ); + } + + #[test] + fn accepted_hook_report_without_session_ref_clears_previous_ref() { + let mut terminal = test_terminal(); + terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::path("/tmp/pi.jsonl"), + Some(20), + ); + + let mutation = terminal + .set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + None, + Some(21), + ) + .expect("accepted report"); + + assert!(mutation.session_ref_changed); + assert!(mutation.effective_state_change.is_none()); + assert!(terminal + .hook_authority + .as_ref() + .unwrap() + .session_ref + .is_none()); + } + + #[test] + fn accepted_hook_report_marks_changed_when_session_identity_changes() { + let mut terminal = test_terminal(); + terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { + source: "herdr:opencode".into(), + agent: "opencode".into(), + session_ref: crate::agent_resume::AgentSessionRef::id("same-session").unwrap(), + }); + + let mutation = terminal + .set_hook_authority_with_session_ref( + "herdr:hermes".into(), + "hermes".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::id("same-session"), + Some(20), + ) + .expect("accepted report"); + + assert!(mutation.session_ref_changed); + } + + #[test] + fn clearing_hook_authority_clears_session_ref() { + let mut terminal = test_terminal(); + terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::path("/tmp/pi.jsonl"), + Some(20), + ); + + let mutation = terminal + .clear_hook_authority_with_mutation(Some("herdr:pi"), Some(21)) + .expect("accepted clear"); + + assert!(mutation.session_ref_changed); + assert!(terminal.hook_authority.is_none()); + } + + #[test] + fn release_agent_clears_session_ref() { + let mut terminal = test_terminal(); + terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::path("/tmp/pi.jsonl"), + 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 release_agent_clears_matching_restored_session_ref_before_detection() { + let mut terminal = test_terminal(); + terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { + source: "herdr:hermes".into(), + agent: "hermes".into(), + session_ref: crate::agent_resume::AgentSessionRef::id("hermes-session").unwrap(), + }); + + let mutation = terminal + .release_agent_with_mutation("herdr:hermes", "hermes", Some(21)) + .expect("accepted release"); + + assert!(mutation.session_ref_changed); + assert!(mutation.effective_state_change.is_none()); + assert!(terminal.persisted_agent_session.is_none()); + } + + #[test] + fn detected_conflict_clears_session_ref() { + let mut terminal = test_terminal(); + terminal.set_hook_authority_with_session_ref( + "herdr:claude".into(), + "claude".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::id("claude-session"), + Some(20), + ); + + let mutation = + terminal.set_detected_state_with_mutation(Some(Agent::Grok), AgentState::Idle); + + assert!(mutation.session_ref_changed); + assert!(terminal.hook_authority.is_none()); + } + + #[test] + fn detected_agent_disappearance_clears_matching_hook_session_ref() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Hermes), AgentState::Idle); + terminal.set_hook_authority_with_session_ref( + "herdr:hermes".into(), + "hermes".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::id("hermes-session"), + Some(20), + ); + + let mutation = terminal.set_detected_state_with_mutation(None, AgentState::Unknown); + + assert!(mutation.session_ref_changed); + assert!(terminal.hook_authority.is_none()); + assert!(terminal.persisted_agent_session.is_none()); + assert_eq!(terminal.effective_agent_label(), None); + } + + #[test] + fn detected_agent_disappearance_clears_matching_persisted_session_ref() { + let mut terminal = test_terminal(); + terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { + source: "herdr:opencode".into(), + agent: "opencode".into(), + session_ref: crate::agent_resume::AgentSessionRef::id("opencode-session").unwrap(), + }); + + let first = + terminal.set_detected_state_with_mutation(Some(Agent::OpenCode), AgentState::Idle); + assert!(!first.session_ref_changed); + assert!(terminal.persisted_agent_session.is_some()); + + let second = terminal.set_detected_state_with_mutation(None, AgentState::Unknown); + assert!(second.session_ref_changed); + assert!(terminal.persisted_agent_session.is_none()); + } + + #[test] + fn initial_unknown_detection_preserves_restored_session_ref() { + let mut terminal = test_terminal(); + terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { + source: "herdr:hermes".into(), + agent: "hermes".into(), + session_ref: crate::agent_resume::AgentSessionRef::id("hermes-session").unwrap(), + }); + + let mutation = terminal.set_detected_state_with_mutation(None, AgentState::Unknown); + assert!(!mutation.session_ref_changed); + assert!(terminal.persisted_agent_session.is_some()); + } + #[test] fn unsequenced_hook_report_is_ignored_after_source_uses_sequence() { let mut terminal = test_terminal(); diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs index 902ea467..f48619d3 100644 --- a/tests/cli_wrapper.rs +++ b/tests/cli_wrapper.rs @@ -427,6 +427,22 @@ fn send_request(socket_path: &Path, json: &str) -> serde_json::Value { } fn run_claude_hook(action: &str, hook_input: &str) -> Option { + run_shell_hook( + "src/integration/assets/claude/herdr-agent-state.sh", + &[action], + hook_input, + ) +} + +fn run_codex_hook(action: &str, hook_input: &str) -> Option { + run_shell_hook( + "src/integration/assets/codex/herdr-agent-state.sh", + &[action], + hook_input, + ) +} + +fn run_shell_hook(asset_path: &str, args: &[&str], hook_input: &str) -> Option { let base = unique_test_dir(); fs::create_dir_all(&base).unwrap(); let socket_path = base.join("herdr.sock"); @@ -455,11 +471,10 @@ fn run_claude_hook(action: &str, hook_input: &str) -> Option None }); - let hook_path = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("src/integration/assets/claude/herdr-agent-state.sh"); + let hook_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(asset_path); let mut child = Command::new("bash") .arg(hook_path) - .arg(action) + .args(args) .env("HERDR_ENV", "1") .env("HERDR_SOCKET_PATH", &socket_path) .env("HERDR_PANE_ID", "p_test") @@ -524,6 +539,31 @@ fn claude_hook_keeps_parent_agent_type_only_blocked() { assert_eq!(request["params"]["state"], "blocked"); } +#[test] +fn claude_hook_reports_session_id_from_stdin() { + let request = run_claude_hook( + "idle", + r#"{"hook_event_name":"SessionStart","session_id":"claude-session"}"#, + ) + .expect("session start should report idle"); + + assert_eq!(request["method"], "pane.report_agent"); + assert_eq!(request["params"]["agent_session_id"], "claude-session"); +} + +#[test] +fn codex_hook_reports_session_id_from_stdin() { + let request = run_codex_hook( + "working", + r#"{"hook_event_name":"SessionStart","session_id":"codex-session"}"#, + ) + .expect("codex hook should report working"); + + assert_eq!(request["method"], "pane.report_agent"); + assert_eq!(request["params"]["state"], "working"); + assert_eq!(request["params"]["agent_session_id"], "codex-session"); +} + #[test] fn pane_run_sends_one_send_input_request_with_enter_key() { let base = unique_test_dir(); @@ -915,7 +955,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 (v1)")); + assert!(status_stdout.contains("pi: current (v2)")); assert!(status_stdout.contains("claude: not installed")); let integration_uninstall = Command::new(env!("CARGO_BIN_EXE_herdr"))