aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-27 20:57:00 +0300
committerGitHub <noreply@github.com>2026-09-27 20:57:00 +0300
commit2552d101afe46b8622a7695f38f6cd1766dd1afc (patch)
treee560c2cc8ef02689711af0865a5b541ee3050bf1
parent0d1a39dcee6a0f244a036c55935970f40cfa76b8 (diff)
feat: let agents report their own resume command (#4687)
* feat: let agents report their own resume command * fix: keep reported resume commands tied to accepted sessions and current owners
-rw-r--r--docs/next/api/herdr-api.schema.json20
-rw-r--r--docs/next/website/src/content/docs/cli-reference.mdx8
-rw-r--r--docs/next/website/src/content/docs/integrations.mdx16
-rw-r--r--docs/next/website/src/content/docs/session-state.mdx2
-rw-r--r--docs/next/website/src/content/docs/socket-api.mdx16
-rw-r--r--src/agent_resume.rs95
-rw-r--r--src/api/schema/panes.rs8
-rw-r--r--src/app/actions.rs225
-rw-r--r--src/app/api.rs1
-rw-r--r--src/app/api/panes.rs132
-rw-r--r--src/app/api/plugins/mod.rs1
-rw-r--r--src/cli/pane.rs15
-rw-r--r--src/cli/spec.rs10
-rw-r--r--src/detect/mod.rs48
-rw-r--r--src/events.rs13
-rw-r--r--src/pane.rs65
-rw-r--r--src/persist/restore.rs163
-rw-r--r--src/persist/snapshot.rs55
-rw-r--r--src/server/headless/tests/mod.rs140
-rw-r--r--src/terminal/runtime.rs4
-rw-r--r--src/terminal/state.rs188
21 files changed, 1178 insertions, 47 deletions
diff --git a/docs/next/api/herdr-api.schema.json b/docs/next/api/herdr-api.schema.json
index 3151bff3..e2553f75 100644
--- a/docs/next/api/herdr-api.schema.json
+++ b/docs/next/api/herdr-api.schema.json
@@ -2909,6 +2909,16 @@
"pane_id": {
"type": "string"
},
+ "resume_argv": {
+ "description": "Command that resumes this agent's session after a Herdr restart. The\nfirst element must be a plain command name.",
+ "items": {
+ "type": "string"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
"seq": {
"format": "uint64",
"minimum": 0,
@@ -2952,6 +2962,16 @@
"pane_id": {
"type": "string"
},
+ "resume_argv": {
+ "description": "Command that resumes this agent's session after a Herdr restart. The\nfirst element must be a plain command name.",
+ "items": {
+ "type": "string"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
"seq": {
"format": "uint64",
"minimum": 0,
diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx
index 05a539d6..1b6f7019 100644
--- a/docs/next/website/src/content/docs/cli-reference.mdx
+++ b/docs/next/website/src/content/docs/cli-reference.mdx
@@ -278,7 +278,8 @@ herdr pane report-agent <pane_id> \
[--message TEXT] \
[--seq N] \
[--agent-session-id ID] \
- [--agent-session-path PATH]
+ [--agent-session-path PATH] \
+ [-- RESUME_COMMAND...]
herdr pane report-agent-session <pane_id> \
--source ID \
@@ -286,7 +287,8 @@ herdr pane report-agent-session <pane_id> \
[--seq N] \
[--agent-session-id ID] \
[--agent-session-path PATH] \
- [--session-start-source SOURCE]
+ [--session-start-source SOURCE] \
+ [-- RESUME_COMMAND...]
herdr pane release-agent <pane_id> \
--source ID \
@@ -296,6 +298,8 @@ herdr pane release-agent <pane_id> \
`report-agent-session` updates native session identity without reporting lifecycle state. `release-agent` ends that source's lifecycle authority when its agent process exits.
+Arguments after `--` are the command Herdr runs to resume this session after a server restart. The first argument must be a plain command name on `PATH`, and no argument may contain an apostrophe.
+
`pane get`, `pane list`, `agent get`, and `agent list` include a read-only `agent_session` object when an official integration has reported a native session reference. If no native session reference is stored, the field is omitted.
Those commands include `foreground_cwd` when Herdr can resolve the cwd of the foreground process controlling the pane. The `cwd` field remains the pane/workspace cwd used for labels and follow-cwd behavior.
diff --git a/docs/next/website/src/content/docs/integrations.mdx b/docs/next/website/src/content/docs/integrations.mdx
index 1261b38d..e36dfdf2 100644
--- a/docs/next/website/src/content/docs/integrations.mdx
+++ b/docs/next/website/src/content/docs/integrations.mdx
@@ -87,7 +87,21 @@ Report `idle` when the agent is ready for input and `blocked` when it needs a us
Report only when `HERDR_ENV=1` and the required variables are present. This keeps the integration a no-op outside Herdr. Keep `--source` stable and unique to the integration. If reports can arrive out of order, include a strictly increasing `--seq`; Herdr ignores stale sequence numbers from the same source.
-You can include `--agent-session-id` or `--agent-session-path` with `report-agent`, or use `pane report-agent-session` when session identity changes independently of state. Herdr exposes that reference through its pane and agent APIs. Automatic session restore also requires Herdr to know how to launch that agent and resume the referenced session.
+You can include `--agent-session-id` or `--agent-session-path` with `report-agent`, or use `pane report-agent-session` when session identity changes independently of state. Herdr exposes that reference through its pane and agent APIs.
+
+To have Herdr resume your agent after a Herdr server restart, report the command that resumes the current session after `--`. Include any options the session needs, such as the model or permission mode:
+
+```bash
+"$HERDR_BIN_PATH" pane report-agent-session "$HERDR_PANE_ID" \
+ --source custom:my-agent \
+ --agent my-agent \
+ --agent-session-id "$SESSION_ID" \
+ -- my-agent --resume "$SESSION_ID" --model my-model
+```
+
+Herdr runs that command in the restored pane's directory. The first word must be a plain command name on `PATH`, not a path, and no argument may contain an apostrophe. Report the agent's state with `report-agent` first so your source holds the pane, and report the command again whenever the session changes. Herdr keeps it only while your source holds the pane and drops it on release. `[session] resume_agents_on_restore = false` disables it.
+
+Herdr cannot identify a custom agent's process, so it treats the pane returning to its idle shell prompt as the agent exiting and clears the agent and its resume command. Still release authority on exit so the pane updates immediately.
Use `HERDR_BIN_PATH` and the CLI wrappers for portable integrations. Code that needs direct IPC can send the equivalent `pane.report_agent`, `pane.report_agent_session`, and `pane.release_agent` requests described in the [Socket API](/docs/socket-api/#agent-state-reporting).
diff --git a/docs/next/website/src/content/docs/session-state.mdx b/docs/next/website/src/content/docs/session-state.mdx
index b8706cd5..d333dab7 100644
--- a/docs/next/website/src/content/docs/session-state.mdx
+++ b/docs/next/website/src/content/docs/session-state.mdx
@@ -81,7 +81,7 @@ Native agent session restore is enabled by default. Disable it with:
resume_agents_on_restore = false
```
-Herdr only resumes panes that reported a native session reference through a current official Herdr integration.
+Herdr resumes panes that reported a native session reference through a current official Herdr integration, and panes whose agent reported its own resume command. See [Integrate your own agent](/docs/integrations/#integrate-your-own-agent).
After a client attaches and provides terminal size and theme context, Herdr resumes eligible restored agent panes across workspaces and tabs without waiting for each pane to be focused.
diff --git a/docs/next/website/src/content/docs/socket-api.mdx b/docs/next/website/src/content/docs/socket-api.mdx
index 4572b5ab..9e844d7e 100644
--- a/docs/next/website/src/content/docs/socket-api.mdx
+++ b/docs/next/website/src/content/docs/socket-api.mdx
@@ -687,6 +687,22 @@ Session-only official integrations report native session references with `pane.r
}
```
+Both methods accept an optional `resume_argv` array with the command that resumes the reporter's current session after a Herdr server restart. Its first element must be a plain command name, not a path, and no element may contain an apostrophe; invalid values fail with `invalid_resume_argv` and the report is not applied. A custom agent must hold the pane through `pane.report_agent` before it can attach a command; otherwise the request fails with `resume_not_accepted`. Herdr keeps the command only while the same `source` and `agent` hold the pane, and it takes precedence over Herdr's built-in resume command for that agent. Older Herdr versions ignore the field.
+
+```json
+{
+ "id": "req_3",
+ "method": "pane.report_agent_session",
+ "params": {
+ "pane_id": "w1:p1",
+ "source": "custom:my-agent",
+ "agent": "my-agent",
+ "agent_session_id": "...",
+ "resume_argv": ["my-agent", "--resume", "..."]
+ }
+}
+```
+
`pane.get`, `pane.list`, `agent.get`, and `agent.list` expose a read-only `agent_session` object when Herdr has a stored native session reference:
```json
diff --git a/src/agent_resume.rs b/src/agent_resume.rs
index 56554dd3..d6a68e45 100644
--- a/src/agent_resume.rs
+++ b/src/agent_resume.rs
@@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize};
const MAX_SESSION_ID_LEN: usize = 512;
const MAX_SESSION_PATH_LEN: usize = 4096;
+const MAX_RESUME_ARGS: usize = 64;
+const MAX_RESUME_ARGV_BYTES: usize = 8192;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AgentSessionRef {
@@ -25,6 +27,67 @@ pub struct AgentResumePlan {
pub dedupe_key: String,
}
+/// A resume command reported by the agent itself, run in the restored pane.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ReportedAgentResume {
+ pub source: String,
+ pub agent: String,
+ pub argv: Vec<String>,
+}
+
+impl ReportedAgentResume {
+ /// The same command can name different sessions in different directories,
+ /// for example `agent --continue`, so the directory is part of its identity.
+ pub fn plan(&self, cwd: &Path) -> AgentResumePlan {
+ AgentResumePlan {
+ agent: self.agent.clone(),
+ argv: self.argv.clone(),
+ dedupe_key: format!(
+ "{}\u{0}{}\u{0}{}\u{0}argv\u{0}{}",
+ self.source,
+ self.agent,
+ cwd.display(),
+ self.argv.join("\u{0}")
+ ),
+ }
+ }
+}
+
+/// Restore types the command into the pane's shell, so the executable must be a
+/// bare command name: shells disagree on how to invoke a quoted path.
+pub fn validate_resume_argv(argv: &[String]) -> Result<(), String> {
+ let Some(command) = argv.first() else {
+ return Err("resume_argv must not be empty".into());
+ };
+ if argv.len() > MAX_RESUME_ARGS {
+ return Err(format!(
+ "resume_argv allows at most {MAX_RESUME_ARGS} arguments"
+ ));
+ }
+ if argv.iter().map(String::len).sum::<usize>() > MAX_RESUME_ARGV_BYTES {
+ return Err(format!(
+ "resume_argv allows at most {MAX_RESUME_ARGV_BYTES} bytes"
+ ));
+ }
+ if argv.iter().any(|arg| arg.chars().any(char::is_control)) {
+ return Err("resume_argv must not contain control characters".into());
+ }
+ // Restore quotes arguments POSIX-style, which PowerShell reads differently
+ // only when an argument itself contains an apostrophe.
+ if argv.iter().any(|arg| arg.contains('\'')) {
+ return Err("resume_argv must not contain apostrophes".into());
+ }
+ let plain_command = !command.is_empty()
+ && !command.starts_with('-')
+ && command
+ .bytes()
+ .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'));
+ if !plain_command {
+ return Err("resume_argv must start with a plain command name, not a path".into());
+ }
+ Ok(())
+}
+
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PersistedAgentSession {
pub source: String,
@@ -309,6 +372,38 @@ mod tests {
.to_string()
}
+ fn argv(parts: &[&str]) -> Vec<String> {
+ parts.iter().map(|part| part.to_string()).collect()
+ }
+
+ #[test]
+ fn reported_resume_argv_requires_a_plain_command_name() {
+ assert!(validate_resume_argv(&argv(&[
+ "prime-agent",
+ "--resume",
+ "01a0de21",
+ "--model",
+ "gpt 6"
+ ]))
+ .is_ok());
+ assert!(validate_resume_argv(&argv(&["cursor-agent.cmd", "--resume", "id"])).is_ok());
+
+ for invalid in [
+ argv(&[]),
+ argv(&[""]),
+ argv(&["/usr/bin/prime-agent", "--resume", "id"]),
+ argv(&["C:\\Program Files\\Prime\\prime.exe"]),
+ argv(&["prime agent"]),
+ argv(&["-prime"]),
+ argv(&["prime-agent", "bad\nline"]),
+ argv(&["prime-agent", "--name", "can's session"]),
+ vec!["prime-agent".to_string(); MAX_RESUME_ARGS + 1],
+ argv(&["prime-agent", &"x".repeat(MAX_RESUME_ARGV_BYTES)]),
+ ] {
+ assert!(validate_resume_argv(&invalid).is_err(), "{invalid:?}");
+ }
+ }
+
#[test]
fn native_state_reservation_excludes_full_lifecycle_sources() {
assert!(is_reserved_native_state_source("herdr:claude", "claude"));
diff --git a/src/api/schema/panes.rs b/src/api/schema/panes.rs
index dfef31f8..9dbe0221 100644
--- a/src/api/schema/panes.rs
+++ b/src/api/schema/panes.rs
@@ -372,6 +372,10 @@ pub struct PaneReportAgentParams {
pub agent_session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_session_path: Option<String>,
+ /// Command that resumes this agent's session after a Herdr restart. The
+ /// first element must be a plain command name.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub resume_argv: Option<Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
@@ -387,6 +391,10 @@ pub struct PaneReportAgentSessionParams {
pub agent_session_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_start_source: Option<String>,
+ /// Command that resumes this agent's session after a Herdr restart. The
+ /// first element must be a plain command name.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub resume_argv: Option<Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
diff --git a/src/app/actions.rs b/src/app/actions.rs
index 6a6e99d2..e1ae2ffc 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -1500,6 +1500,28 @@ impl AppState {
.collect()
}
}
+ AppEvent::AgentResumeReported {
+ pane_id,
+ source,
+ agent_label,
+ seq,
+ argv,
+ } => self
+ .update_terminal_state(pane_id, |terminal| {
+ terminal.record_reported_resume(&source, &agent_label, seq, argv);
+ None
+ })
+ .into_iter()
+ .collect(),
+ AppEvent::ReportedAgentShellReturned {
+ pane_id,
+ observed_at,
+ } => self
+ .update_terminal_state(pane_id, |terminal| {
+ terminal.clear_self_reported_agent(observed_at)
+ })
+ .into_iter()
+ .collect(),
AppEvent::AgentSessionReported {
pane_id,
source,
@@ -1653,7 +1675,14 @@ impl AppState {
let terminal = self.terminals.get_mut(&terminal_id)?;
let previous_agent_name = terminal.agent_name.clone();
let had_completion = terminal.last_agent_completion_seq.is_some() || !previous_seen;
- let mutation = update(terminal)?;
+ let resume_revision = terminal.reported_resume_revision();
+ let mutation = update(terminal);
+ terminal.reconcile_reported_resume();
+ // Resume-only changes return no mutation but must still be saved.
+ if terminal.reported_resume_revision() != resume_revision {
+ self.session_dirty = true;
+ }
+ let mutation = mutation?;
let completion_reset = mutation.session_ref_changed
|| mutation
.effective_state_change
@@ -3541,6 +3570,200 @@ mod tests {
assert!(state.toast.is_none());
}
+ fn report_custom_agent_with_resume(state: &mut AppState, pane_id: PaneId, argv: &[&str]) {
+ state.handle_app_event(AppEvent::HookStateReported {
+ pane_id,
+ source: "prime-agent".into(),
+ agent_label: "prime-agent".into(),
+ state: AgentState::Idle,
+ message: None,
+ seq: Some(1),
+ session_ref: None,
+ });
+ state.handle_app_event(AppEvent::AgentResumeReported {
+ pane_id,
+ source: "prime-agent".into(),
+ agent_label: "prime-agent".into(),
+ seq: Some(1),
+ argv: argv.iter().map(|part| part.to_string()).collect(),
+ });
+ }
+
+ fn first_pane_terminal(state: &AppState) -> (PaneId, crate::terminal::TerminalId) {
+ let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
+ let terminal_id = state.workspaces[0].panes[&pane_id]
+ .attached_terminal_id
+ .clone();
+ (pane_id, terminal_id)
+ }
+
+ #[test]
+ fn reported_resume_follows_the_reporting_agent_until_release() {
+ let mut state = app_with_workspaces(&["one"]);
+ let (pane_id, terminal_id) = first_pane_terminal(&state);
+ report_custom_agent_with_resume(&mut state, pane_id, &["prime-agent", "--resume", "a"]);
+
+ let resume = state.terminals[&terminal_id].reported_resume().unwrap();
+ assert_eq!(resume.agent, "prime-agent");
+ assert_eq!(resume.argv, vec!["prime-agent", "--resume", "a"]);
+
+ state.handle_app_event(AppEvent::AgentResumeReported {
+ pane_id,
+ source: "someone-else".into(),
+ agent_label: "other".into(),
+ seq: None,
+ argv: vec!["other".into()],
+ });
+ assert_eq!(
+ state.terminals[&terminal_id]
+ .reported_resume()
+ .unwrap()
+ .argv,
+ vec!["prime-agent", "--resume", "a"]
+ );
+
+ state.handle_app_event(AppEvent::HookAgentReleased {
+ pane_id,
+ source: "prime-agent".into(),
+ agent_label: "prime-agent".into(),
+ known_agent: None,
+ seq: Some(2),
+ });
+ assert!(state.terminals[&terminal_id].reported_resume().is_none());
+ }
+
+ #[test]
+ fn reported_resume_is_dropped_and_saved_when_another_agent_takes_the_pane() {
+ let mut state = app_with_workspaces(&["one"]);
+ let (pane_id, terminal_id) = first_pane_terminal(&state);
+ report_custom_agent_with_resume(&mut state, pane_id, &["prime-agent", "--resume", "a"]);
+ state.session_dirty = false;
+
+ state.handle_app_event(AppEvent::HookStateReported {
+ pane_id,
+ source: "custom:other".into(),
+ agent_label: "other".into(),
+ state: AgentState::Working,
+ message: None,
+ seq: None,
+ session_ref: None,
+ });
+
+ assert!(state.terminals[&terminal_id].reported_resume().is_none());
+ assert!(state.session_dirty);
+ }
+
+ #[test]
+ fn reported_resume_of_recognized_agent_is_dropped_when_its_process_exits() {
+ let mut state = app_with_workspaces(&["one"]);
+ let (pane_id, terminal_id) = first_pane_terminal(&state);
+ state.handle_app_event(AppEvent::AgentProcessDetected {
+ pane_id,
+ agent: Agent::Pi,
+ observed_at: std::time::Instant::now(),
+ });
+ state.handle_app_event(AppEvent::AgentResumeReported {
+ pane_id,
+ source: "herdr:pi".into(),
+ agent_label: "pi".into(),
+ seq: None,
+ argv: vec!["pi".into(), "--continue".into()],
+ });
+ assert!(state.terminals[&terminal_id].reported_resume().is_some());
+ state.session_dirty = false;
+
+ state.handle_app_event(AppEvent::StateChanged {
+ pane_id,
+ agent: Some(Agent::Pi),
+ state: AgentState::Idle,
+ visible_blocker: false,
+ visible_working: false,
+ process_exited: true,
+ observed_at: std::time::Instant::now(),
+ });
+
+ assert!(state.terminals[&terminal_id].reported_resume().is_none());
+ assert!(state.session_dirty);
+
+ state.handle_app_event(AppEvent::AgentResumeReported {
+ pane_id,
+ source: "herdr:pi".into(),
+ agent_label: "pi".into(),
+ seq: None,
+ argv: vec!["pi".into(), "--continue".into()],
+ });
+ assert!(
+ state.terminals[&terminal_id].reported_resume().is_none(),
+ "a late report must not revive an exited agent"
+ );
+ }
+
+ #[test]
+ fn shell_return_drops_self_reported_agent_and_its_resume() {
+ let mut state = app_with_workspaces(&["one"]);
+ let (pane_id, terminal_id) = first_pane_terminal(&state);
+ report_custom_agent_with_resume(&mut state, pane_id, &["prime-agent", "--resume", "a"]);
+ assert!(state.terminals[&terminal_id].self_reported_agent_active());
+
+ state.handle_app_event(AppEvent::ReportedAgentShellReturned {
+ pane_id,
+ observed_at: std::time::Instant::now(),
+ });
+
+ let terminal = &state.terminals[&terminal_id];
+ assert_eq!(terminal.effective_agent_label(), None);
+ assert!(terminal.reported_resume().is_none());
+ assert!(!terminal.self_reported_agent_active());
+ }
+
+ #[test]
+ fn delayed_shell_return_keeps_an_agent_that_claimed_the_pane_afterwards() {
+ let mut state = app_with_workspaces(&["one"]);
+ let (pane_id, terminal_id) = first_pane_terminal(&state);
+ let shell_seen_idle_at = std::time::Instant::now();
+ report_custom_agent_with_resume(&mut state, pane_id, &["prime-agent", "--resume", "b"]);
+
+ state.handle_app_event(AppEvent::ReportedAgentShellReturned {
+ pane_id,
+ observed_at: shell_seen_idle_at,
+ });
+
+ let terminal = &state.terminals[&terminal_id];
+ assert_eq!(terminal.effective_agent_label(), Some("prime-agent"));
+ assert!(terminal.reported_resume().is_some());
+ }
+
+ #[test]
+ fn shell_return_keeps_agents_herdr_recognizes_by_process() {
+ let mut state = app_with_workspaces(&["one"]);
+ let (pane_id, terminal_id) = first_pane_terminal(&state);
+ state.handle_app_event(AppEvent::AgentProcessDetected {
+ pane_id,
+ agent: Agent::Pi,
+ observed_at: std::time::Instant::now(),
+ });
+ state.handle_app_event(AppEvent::HookStateReported {
+ pane_id,
+ source: "herdr:pi".into(),
+ agent_label: "pi".into(),
+ state: AgentState::Working,
+ message: None,
+ seq: Some(1),
+ session_ref: None,
+ });
+ assert!(!state.terminals[&terminal_id].self_reported_agent_active());
+
+ state.handle_app_event(AppEvent::ReportedAgentShellReturned {
+ pane_id,
+ observed_at: std::time::Instant::now(),
+ });
+
+ assert_eq!(
+ state.terminals[&terminal_id].effective_agent_label(),
+ Some("pi")
+ );
+ }
+
#[test]
fn hook_reported_unknown_agent_sets_toast_title_from_label() {
let mut state = app_with_workspaces(&["active", "background"]);
diff --git a/src/app/api.rs b/src/app/api.rs
index 62c8a6b5..20ca9d0f 100644
--- a/src/app/api.rs
+++ b/src/app/api.rs
@@ -462,6 +462,7 @@ impl App {
runtime.set_full_lifecycle_authority_active(
terminal.full_lifecycle_hook_authority_active(),
);
+ runtime.set_self_reported_agent_active(terminal.self_reported_agent_active());
}
}
}
diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs
index 119d9298..e4e03e71 100644
--- a/src/app/api/panes.rs
+++ b/src/app/api/panes.rs
@@ -1549,28 +1549,44 @@ impl App {
id: String,
params: PaneReportAgentParams,
) -> String {
- let Some((_ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
+ let Some((ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
return pane_not_found(id, &params.pane_id);
};
let Some(agent_label) = normalize_reported_agent_label(&params.agent) else {
return invalid_agent(id);
};
+ if let Err(message) = validate_optional_resume_argv(params.resume_argv.as_deref()) {
+ return encode_error(id, "invalid_resume_argv", message);
+ }
+ let report_is_newer = self
+ .pane_terminal(ws_idx, pane_id)
+ .is_some_and(|terminal| terminal.hook_report_is_newer(&params.source, params.seq));
+ let session_ref = crate::agent_resume::session_ref_from_report(
+ &params.source,
+ &agent_label,
+ params.agent_session_id,
+ params.agent_session_path,
+ );
self.handle_internal_event(crate::events::AppEvent::HookStateReported {
pane_id,
- session_ref: crate::agent_resume::session_ref_from_report(
- &params.source,
- &agent_label,
- params.agent_session_id,
- params.agent_session_path,
- ),
- source: params.source,
- agent_label,
+ session_ref: session_ref.clone(),
+ source: params.source.clone(),
+ agent_label: agent_label.clone(),
state: detect_state_from_api(params.state),
message: params.message,
seq: params.seq,
});
-
- encode_success(id, ResponseResult::Ok {})
+ let applied =
+ report_is_newer && self.session_report_applied(ws_idx, pane_id, session_ref.as_ref());
+ self.report_agent_resume(
+ id,
+ ws_idx,
+ pane_id,
+ params.source,
+ agent_label,
+ params.seq.filter(|_| applied),
+ applied.then_some(params.resume_argv).flatten(),
+ )
}
pub(super) fn handle_pane_report_agent_session(
@@ -1578,28 +1594,100 @@ impl App {
id: String,
params: PaneReportAgentSessionParams,
) -> String {
- let Some((_ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
+ let Some((ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
return pane_not_found(id, &params.pane_id);
};
let Some(agent_label) = normalize_reported_agent_label(&params.agent) else {
return invalid_agent(id);
};
+ if let Err(message) = validate_optional_resume_argv(params.resume_argv.as_deref()) {
+ return encode_error(id, "invalid_resume_argv", message);
+ }
+ let report_is_newer = self
+ .pane_terminal(ws_idx, pane_id)
+ .is_some_and(|terminal| terminal.hook_report_is_newer(&params.source, params.seq));
+ let session_ref = crate::agent_resume::session_ref_from_report(
+ &params.source,
+ &agent_label,
+ params.agent_session_id,
+ params.agent_session_path,
+ );
self.handle_internal_event(crate::events::AppEvent::AgentSessionReported {
pane_id,
- session_ref: crate::agent_resume::session_ref_from_report(
- &params.source,
- &agent_label,
- params.agent_session_id,
- params.agent_session_path,
- ),
- source: params.source,
- agent_label,
+ session_ref: session_ref.clone(),
+ source: params.source.clone(),
+ agent_label: agent_label.clone(),
seq: params.seq,
session_start_source: crate::agent_resume::normalize_session_start_source(
params.session_start_source,
),
});
+ let applied =
+ report_is_newer && self.session_report_applied(ws_idx, pane_id, session_ref.as_ref());
+ self.report_agent_resume(
+ id,
+ ws_idx,
+ pane_id,
+ params.source,
+ agent_label,
+ params.seq.filter(|_| applied),
+ applied.then_some(params.resume_argv).flatten(),
+ )
+ }
+ /// A resume command belongs to the session it was reported with, so it is
+ /// kept only when Herdr accepted that session.
+ fn session_report_applied(
+ &self,
+ ws_idx: usize,
+ pane_id: crate::layout::PaneId,
+ session_ref: Option<&crate::agent_resume::AgentSessionRef>,
+ ) -> bool {
+ session_ref.is_none_or(|session_ref| {
+ self.pane_terminal(ws_idx, pane_id)
+ .is_some_and(|terminal| terminal.session_ref_is_current(session_ref))
+ })
+ }
+
+ fn pane_terminal(
+ &self,
+ ws_idx: usize,
+ pane_id: crate::layout::PaneId,
+ ) -> Option<&crate::terminal::TerminalState> {
+ let pane = self.state.workspaces.get(ws_idx)?.pane_state(pane_id)?;
+ self.state.terminals.get(&pane.attached_terminal_id)
+ }
+
+ fn report_agent_resume(
+ &mut self,
+ id: String,
+ ws_idx: usize,
+ pane_id: crate::layout::PaneId,
+ source: String,
+ agent_label: String,
+ seq: Option<u64>,
+ resume_argv: Option<Vec<String>>,
+ ) -> String {
+ let Some(argv) = resume_argv else {
+ return encode_success(id, ResponseResult::Ok {});
+ };
+ let can_record = self
+ .pane_terminal(ws_idx, pane_id)
+ .is_some_and(|terminal| terminal.can_record_reported_resume(&source, &agent_label));
+ if !can_record {
+ return encode_error(
+ id,
+ "resume_not_accepted",
+ "resume_argv requires the reporter to hold the pane; report its state with pane.report_agent first",
+ );
+ }
+ self.handle_internal_event(crate::events::AppEvent::AgentResumeReported {
+ pane_id,
+ source,
+ agent_label,
+ seq,
+ argv,
+ });
encode_success(id, ResponseResult::Ok {})
}
@@ -2214,6 +2302,10 @@ fn invalid_agent(id: String) -> String {
encode_error(id, "invalid_agent", "agent label must not be empty")
}
+fn validate_optional_resume_argv(argv: Option<&[String]>) -> Result<(), String> {
+ argv.map_or(Ok(()), crate::agent_resume::validate_resume_argv)
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/src/app/api/plugins/mod.rs b/src/app/api/plugins/mod.rs
index 35dbee2f..c9b37bfe 100644
--- a/src/app/api/plugins/mod.rs
+++ b/src/app/api/plugins/mod.rs
@@ -3328,6 +3328,7 @@ action = "missing"
seq: None,
agent_session_id: None,
agent_session_path: None,
+ resume_argv: None,
},
);
diff --git a/src/cli/pane.rs b/src/cli/pane.rs
index 3b900e17..33583ee4 100644
--- a/src/cli/pane.rs
+++ b/src/cli/pane.rs
@@ -1156,8 +1156,9 @@ fn parse_pane_wait_output_args(args: &[String]) -> Result<PaneWaitForOutputParam
}
fn pane_report_agent(args: &[String]) -> std::io::Result<i32> {
- const USAGE: &str = "usage: herdr pane report-agent <pane_id> --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--seq N] [--agent-session-id ID] [--agent-session-path PATH]";
+ const USAGE: &str = "usage: herdr pane report-agent <pane_id> --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--seq N] [--agent-session-id ID] [--agent-session-path PATH] [-- <resume-command...>]";
+ let (args, resume_argv) = split_resume_argv(args);
let args = super::expand_equals_args(
args,
&[
@@ -1282,12 +1283,21 @@ fn pane_report_agent(args: &[String]) -> std::io::Result<i32> {
seq,
agent_session_id,
agent_session_path,
+ resume_argv,
}))
}
+fn split_resume_argv(args: &[String]) -> (&[String], Option<Vec<String>>) {
+ match args.iter().position(|arg| arg == "--") {
+ Some(separator) => (&args[..separator], Some(args[separator + 1..].to_vec())),
+ None => (args, None),
+ }
+}
+
fn pane_report_agent_session(args: &[String]) -> std::io::Result<i32> {
- const USAGE: &str = "usage: herdr pane report-agent-session <pane_id> --source ID --agent LABEL [--seq N] [--agent-session-id ID] [--agent-session-path PATH] [--session-start-source SOURCE]";
+ const USAGE: &str = "usage: herdr pane report-agent-session <pane_id> --source ID --agent LABEL [--seq N] [--agent-session-id ID] [--agent-session-path PATH] [--session-start-source SOURCE] [-- <resume-command...>]";
+ let (args, resume_argv) = split_resume_argv(args);
let args = super::expand_equals_args(
args,
&[
@@ -1398,6 +1408,7 @@ fn pane_report_agent_session(args: &[String]) -> std::io::Result<i32> {
agent_session_id,
agent_session_path,
session_start_source,
+ resume_argv,
},
))
}
diff --git a/src/cli/spec.rs b/src/cli/spec.rs
index 9465dcc3..1cfc2e7f 100644
--- a/src/cli/spec.rs
+++ b/src/cli/spec.rs
@@ -646,6 +646,7 @@ fn report_agent_command() -> Command {
.arg(option("seq", "N"))
.arg(option("agent-session-id", "ID"))
.arg(path_option("agent-session-path", "PATH"))
+ .arg(resume_argv_arg())
}
fn report_agent_session_command() -> Command {
@@ -658,6 +659,15 @@ fn report_agent_session_command() -> Command {
.arg(option("agent-session-id", "ID"))
.arg(path_option("agent-session-path", "PATH"))
.arg(option("session-start-source", "SOURCE"))
+ .arg(resume_argv_arg())
+}
+
+fn resume_argv_arg() -> Arg {
+ Arg::new("resume_argv")
+ .value_name("RESUME_ARG")
+ .num_args(0..)
+ .last(true)
+ .help("Command that resumes this session after a Herdr restart; starts with a plain command name")
}
fn release_agent_command() -> Command {
diff --git a/src/detect/mod.rs b/src/detect/mod.rs
index 408ee669..7d7759d2 100644
--- a/src/detect/mod.rs
+++ b/src/detect/mod.rs
@@ -366,6 +366,11 @@ pub fn foreground_process_group_id(child_pid: u32) -> Option<u32> {
crate::platform::foreground_process_group_id(child_pid)
}
+/// True when the pane's own shell is at its prompt with nothing running in it.
+pub fn pane_shell_is_idle(child_pid: u32) -> bool {
+ crate::platform::available_pane_shell(child_pid).is_some()
+}
+
fn normalized_process_name(process: &crate::platform::ForegroundProcess) -> String {
let effective = process.argv0.as_deref().unwrap_or(&process.name);
let lower_effective = effective.to_lowercase();
@@ -1928,6 +1933,49 @@ mod tests {
#[cfg(target_os = "linux")]
#[test]
+ fn pane_shell_is_idle_tracks_foreground_command() {
+ use portable_pty::CommandBuilder;
+ use std::io::Write;
+
+ fn wait_for(expected: bool, pid: u32) -> bool {
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
+ while std::time::Instant::now() < deadline {
+ if pane_shell_is_idle(pid) == expected {
+ return true;
+ }
+ std::thread::sleep(std::time::Duration::from_millis(20));
+ }
+ false
+ }
+
+ let pair = open_test_pty();
+ let mut cmd = CommandBuilder::new("sh");
+ cmd.arg("-i");
+ cmd.env("ENV", "/dev/null");
+ let mut child = pair.slave.spawn_command(cmd).expect("failed to spawn");
+ let pid = child.process_id().expect("no pid");
+ let mut reader = pair.master.try_clone_reader().expect("no reader");
+ std::thread::spawn(move || {
+ let mut buf = [0u8; 1024];
+ while matches!(std::io::Read::read(&mut reader, &mut buf), Ok(n) if n > 0) {}
+ });
+ let mut writer = pair.master.take_writer().expect("no writer");
+
+ assert!(wait_for(true, pid), "interactive shell should start idle");
+ writer.write_all(b"sleep 999\n").unwrap();
+ assert!(wait_for(false, pid), "shell running a command is not idle");
+ writer.write_all(&[3]).unwrap();
+ assert!(
+ wait_for(true, pid),
+ "shell should be idle after the command ends"
+ );
+
+ child.kill().ok();
+ child.wait().ok();
+ }
+
+ #[cfg(target_os = "linux")]
+ #[test]
fn proc_stat_parsing_handles_spaces_in_comm() {
// Verify our /proc/pid/stat parser correctly extracts fields
// even when (comm) could contain spaces.
diff --git a/src/events.rs b/src/events.rs
index 0e61f5f8..c87a50a3 100644
--- a/src/events.rs
+++ b/src/events.rs
@@ -120,6 +120,19 @@ pub enum AppEvent {
session_ref: Option<crate::agent_resume::AgentSessionRef>,
session_start_source: Option<String>,
},
+ /// A reporter supplied the command that resumes its own session.
+ AgentResumeReported {
+ pane_id: PaneId,
+ source: String,
+ agent_label: String,
+ seq: Option<u64>,
+ argv: Vec<String>,
+ },
+ /// A pane held by a self-reported agent is back at its idle shell.
+ ReportedAgentShellReturned {
+ pane_id: PaneId,
+ observed_at: std::time::Instant,
+ },
/// Display-only agent metadata was reported for a pane.
HookMetadataReported {
pane_id: PaneId,
diff --git a/src/pane.rs b/src/pane.rs
index 35534135..e5695675 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -363,6 +363,35 @@ async fn apply_agent_detection_publish_update(
}
const AGENT_MISS_CONFIRMATION_ATTEMPTS: u8 = 6;
+const SELF_REPORTED_AGENT_SHELL_RECHECK: std::time::Duration = std::time::Duration::from_secs(1);
+
+/// Herdr cannot identify a self-reported agent's process, so the pane's shell
+/// returning to an idle prompt is the only sign that the agent exited.
+async fn report_self_reported_agent_shell_return(
+ active: &AtomicBool,
+ pid: u32,
+ now: std::time::Instant,
+ last_check: &mut Option<std::time::Instant>,
+ state_events: &mpsc::Sender<AppEvent>,
+ pane_id: PaneId,
+) {
+ if pid == 0 || !active.load(Ordering::Acquire) {
+ *last_check = None;
+ return;
+ }
+ if last_check.is_some_and(|last| now.duration_since(last) < SELF_REPORTED_AGENT_SHELL_RECHECK) {
+ return;
+ }
+ *last_check = Some(now);
+ if crate::detect::pane_shell_is_idle(pid) {
+ let _ = state_events
+ .send(AppEvent::ReportedAgentShellReturned {
+ pane_id,
+ observed_at: now,
+ })
+ .await;
+ }
+}
const PROCESS_RECHECK_IDENTIFIED: std::time::Duration = std::time::Duration::from_secs(5);
const PROCESS_RECHECK_MISSING_FOREGROUND_GROUP: std::time::Duration =
std::time::Duration::from_secs(30);
@@ -766,6 +795,7 @@ fn spawn_basic_detection_task(
terminal: Arc<PaneTerminal>,
detection_content_seq: Arc<AtomicU64>,
full_lifecycle_authority_active: Arc<AtomicBool>,
+ self_reported_agent_active: Arc<AtomicBool>,
state_events: mpsc::Sender<AppEvent>,
) -> (
tokio::task::AbortHandle,
@@ -797,6 +827,7 @@ fn spawn_basic_detection_task(
let mut agent_startup_grace_until = None;
let mut pending_idle = PendingIdleConfirmation::default();
let mut last_codex_prompt_ready = false;
+ let mut last_self_reported_shell_check = None;
loop {
let sleep_duration = if pending_idle.active() {
@@ -845,6 +876,15 @@ fn spawn_basic_detection_task(
let mut agent = agent_presence.current_agent();
let lifecycle_authority_active =
full_lifecycle_authority_active.load(Ordering::Acquire);
+ report_self_reported_agent_shell_return(
+ &self_reported_agent_active,
+ pid,
+ now,
+ &mut last_self_reported_shell_check,
+ &state_events,
+ pane_id,
+ )
+ .await;
let foreground_pgid = (pid > 0)
.then(|| crate::detect::foreground_process_group_id(pid))
.flatten();
@@ -1328,6 +1368,7 @@ pub struct PaneRuntime {
content_write_lock: Arc<Mutex<()>>,
detection_content_seq: Arc<AtomicU64>,
full_lifecycle_authority_active: Arc<AtomicBool>,
+ self_reported_agent_active: Arc<AtomicBool>,
detect_reset_notify: Arc<Notify>,
pending_release: Arc<Mutex<Option<PendingAgentRelease>>>,
preserve_processes_on_drop: bool,
@@ -2461,12 +2502,14 @@ impl PaneRuntime {
};
let full_lifecycle_authority_active = Arc::new(AtomicBool::new(false));
+ let self_reported_agent_active = Arc::new(AtomicBool::new(false));
let (detect_handle, detect_reset_notify, pending_release) = spawn_basic_detection_task(
pane_id,
child_pid.clone(),
terminal.clone(),
detection_content_seq.clone(),
full_lifecycle_authority_active.clone(),
+ self_reported_agent_active.clone(),
events,
);
@@ -2485,6 +2528,7 @@ impl PaneRuntime {
content_write_lock,
detection_content_seq,
full_lifecycle_authority_active,
+ self_reported_agent_active,
detect_reset_notify,
pending_release,
preserve_processes_on_drop: true,
@@ -2544,6 +2588,7 @@ impl PaneRuntime {
let content_seq = Arc::new(AtomicU64::new(0));
let detection_content_seq = Arc::new(AtomicU64::new(0));
let full_lifecycle_authority_active = Arc::new(AtomicBool::new(false));
+ let self_reported_agent_active = Arc::new(AtomicBool::new(false));
{
let child_pid = child_pid.clone();
let child_wait_completed = child_wait_completed.clone();
@@ -2667,6 +2712,7 @@ impl PaneRuntime {
let state_events = events.clone();
let detection_content_seq = detection_content_seq.clone();
let full_lifecycle_authority_active_for_task = full_lifecycle_authority_active.clone();
+ let self_reported_agent_active_for_task = self_reported_agent_active.clone();
let render_notify = render_notify.clone();
let render_dirty = render_dirty.clone();
let detect_reset_notify = Arc::new(Notify::new());
@@ -2698,6 +2744,7 @@ impl PaneRuntime {
let mut agent_startup_grace_until = None;
let mut pending_idle = PendingIdleConfirmation::default();
let mut last_codex_prompt_ready = false;
+ let mut last_self_reported_shell_check = None;
tokio::time::sleep(Duration::from_millis(50)).await;
@@ -2755,6 +2802,15 @@ impl PaneRuntime {
let mut agent = agent_presence.current_agent();
let lifecycle_authority_active =
full_lifecycle_authority_active_for_task.load(Ordering::Acquire);
+ report_self_reported_agent_shell_return(
+ &self_reported_agent_active_for_task,
+ pid,
+ now,
+ &mut last_self_reported_shell_check,
+ &state_events,
+ pane_id,
+ )
+ .await;
let process_probe_input = ProcessProbeInput {
current_agent: agent,
suppressed_agent,
@@ -3075,6 +3131,7 @@ impl PaneRuntime {
content_write_lock,
detection_content_seq,
full_lifecycle_authority_active,
+ self_reported_agent_active,
detect_reset_notify,
pending_release,
preserve_processes_on_drop: false,
@@ -3102,6 +3159,11 @@ impl PaneRuntime {
self.detect_reset_notify.clone()
}
+ pub fn set_self_reported_agent_active(&self, active: bool) {
+ self.self_reported_agent_active
+ .store(active, Ordering::Release);
+ }
+
pub fn set_full_lifecycle_authority_active(&self, active: bool) {
let previous = self
.full_lifecycle_authority_active
@@ -3789,6 +3851,7 @@ impl PaneRuntime {
content_write_lock: Arc::new(Mutex::new(())),
detection_content_seq: Arc::new(AtomicU64::new(0)),
full_lifecycle_authority_active: Arc::new(AtomicBool::new(false)),
+ self_reported_agent_active: Arc::new(AtomicBool::new(false)),
detect_reset_notify: Arc::new(Notify::new()),
pending_release: Arc::new(Mutex::new(None)),
preserve_processes_on_drop: true,
@@ -4940,6 +5003,7 @@ mod tests {
));
let compression = TerminalCompressionTask::spawn(pane_id, terminal.clone());
let runtime = PaneRuntime {
+ self_reported_agent_active: Arc::new(AtomicBool::new(false)),
cwd_process_exited: Arc::new(AtomicBool::new(false)),
persistence_cwd: Mutex::new(None),
pane_id,
@@ -4979,6 +5043,7 @@ mod tests {
));
let compression = TerminalCompressionTask::spawn(pane_id, terminal.clone());
let runtime = PaneRuntime {
+ self_reported_agent_active: Arc::new(AtomicBool::new(false)),
cwd_process_exited: Arc::new(AtomicBool::new(false)),
persistence_cwd: Mutex::new(None),
pane_id,
diff --git a/src/persist/restore.rs b/src/persist/restore.rs
index 265b4874..e79a0f3b 100644
--- a/src/persist/restore.rs
+++ b/src/persist/restore.rs
@@ -15,7 +15,8 @@ use crate::terminal::{TerminalId, TerminalRuntime, TerminalState};
use crate::workspace::Workspace;
use super::snapshot::{
- PaneAgentSessionSnapshot, PaneHistorySnapshot, TabHistorySnapshot, WorkspaceHistorySnapshot,
+ PaneAgentResumeSnapshot, PaneAgentSessionSnapshot, PaneHistorySnapshot, TabHistorySnapshot,
+ WorkspaceHistorySnapshot,
};
use super::{
DirectionSnapshot, LayoutSnapshot, SessionHistorySnapshot, SessionSnapshot, TabSnapshot,
@@ -455,6 +456,9 @@ fn unavailable_restored_terminal(
if let Some(session) = restored_terminal_agent_session(pane.agent_session.as_ref(), false) {
terminal.set_persisted_agent_session(session);
}
+ if let Some(resume) = saved_reported_resume(pane) {
+ terminal.restore_reported_resume(reported_resume_from_snapshot(resume));
+ }
match (
pane.agent_name.as_ref(),
pane.managed_agent_kind
@@ -530,6 +534,7 @@ fn restore_tab(
.and_then(crate::detect::parse_canonical_agent_label);
let saved_launch_argv = saved_pane.and_then(|p| p.launch_argv.clone());
let saved_agent_session = saved_pane.and_then(|p| p.agent_session.as_ref());
+ let saved_agent_resume = saved_pane.and_then(saved_reported_resume);
let saved_history =
old_id.and_then(|old_id| history.and_then(|history| history.panes.get(old_id)));
let startup = {
@@ -537,8 +542,17 @@ fn restore_tab(
enabled: runtime_context.resume_agents_on_restore,
resumed_sessions: resumed_agent_sessions,
};
- pane_restore_startup(saved_agent_session, saved_history, &mut agent_restore)
+ pane_restore_startup(
+ saved_agent_session,
+ saved_agent_resume,
+ &cwd,
+ saved_history,
+ &mut agent_restore,
+ )
};
+ let restored_agent_resume = saved_agent_resume
+ .filter(|_| !startup.duplicate_agent_session)
+ .map(reported_resume_from_snapshot);
let restored_agent_session =
restored_terminal_agent_session(saved_agent_session, startup.duplicate_agent_session);
let initial_restore_agent = startup
@@ -580,6 +594,9 @@ fn restore_tab(
if let Some(session) = restored_agent_session {
terminal.set_persisted_agent_session(session);
}
+ if let Some(resume) = restored_agent_resume {
+ terminal.restore_reported_resume(resume);
+ }
match (saved_agent_name, saved_managed_agent) {
(Some(agent_name), Some(agent)) => {
terminal.restore_managed_agent(agent_name, agent)
@@ -677,6 +694,9 @@ fn restore_tab(
if let Some(session) = restored_agent_session {
terminal.set_persisted_agent_session(session);
}
+ if let Some(resume) = restored_agent_resume {
+ terminal.restore_reported_resume(resume);
+ }
match (saved_agent_name, saved_managed_agent) {
(Some(agent_name), Some(agent)) if was_imported => {
terminal.restore_managed_agent(agent_name, agent)
@@ -787,6 +807,8 @@ fn restore_tab(
fn pane_restore_startup<'a>(
session: Option<&PaneAgentSessionSnapshot>,
+ reported_resume: Option<&PaneAgentResumeSnapshot>,
+ cwd: &std::path::Path,
history: Option<&'a PaneHistorySnapshot>,
agent_restore: &mut AgentRestoreState<'_>,
) -> PaneRestoreStartup<'a> {
@@ -794,8 +816,13 @@ fn pane_restore_startup<'a>(
// resumable agent session and resume is enabled, do not replay saved pane
// presentation history into that terminal, even when this pane is a
// duplicate suppressed by session de-duplication.
- let restore_plan =
- session.and_then(|session| restore_plan_for_snapshot(session, agent_restore.enabled));
+ let restore_plan = if !agent_restore.enabled {
+ None
+ } else if let Some(resume) = reported_resume {
+ Some(reported_resume_from_snapshot(resume).plan(cwd))
+ } else {
+ session.and_then(|session| restore_plan_for_snapshot(session, true))
+ };
let has_native_agent_restore = restore_plan.is_some();
// Reserve before spawning so later panes in the same restore pass cannot
// launch the same native agent session. The caller rolls this reservation
@@ -830,6 +857,22 @@ fn pane_restore_startup<'a>(
}
}
+fn saved_reported_resume(pane: &super::snapshot::PaneSnapshot) -> Option<&PaneAgentResumeSnapshot> {
+ pane.agent_resume
+ .as_ref()
+ .filter(|resume| crate::agent_resume::validate_resume_argv(&resume.argv).is_ok())
+}
+
+fn reported_resume_from_snapshot(
+ resume: &PaneAgentResumeSnapshot,
+) -> crate::agent_resume::ReportedAgentResume {
+ crate::agent_resume::ReportedAgentResume {
+ source: resume.source.clone(),
+ agent: resume.agent.clone(),
+ argv: resume.argv.clone(),
+ }
+}
+
fn restore_plan_for_snapshot(
session: &PaneAgentSessionSnapshot,
resume_agents_on_restore: bool,
@@ -1124,7 +1167,13 @@ mod tests {
resumed_sessions: &mut resumed,
};
- let startup = pane_restore_startup(Some(&session), Some(&history), &mut agent_restore);
+ let startup = pane_restore_startup(
+ Some(&session),
+ None,
+ std::path::Path::new("/a"),
+ Some(&history),
+ &mut agent_restore,
+ );
assert!(startup.restore_plan.is_some());
assert!(startup.initial_history_ansi.is_none());
@@ -1149,8 +1198,20 @@ mod tests {
resumed_sessions: &mut resumed,
};
- let first = pane_restore_startup(Some(&session), Some(&history), &mut agent_restore);
- let duplicate = pane_restore_startup(Some(&session), Some(&history), &mut agent_restore);
+ let first = pane_restore_startup(
+ Some(&session),
+ None,
+ std::path::Path::new("/a"),
+ Some(&history),
+ &mut agent_restore,
+ );
+ let duplicate = pane_restore_startup(
+ Some(&session),
+ None,
+ std::path::Path::new("/a"),
+ Some(&history),
+ &mut agent_restore,
+ );
assert!(first.restore_plan.is_some());
assert!(first.initial_history_ansi.is_none());
@@ -1160,6 +1221,79 @@ mod tests {
}
#[test]
+ fn pane_restore_startup_prefers_reported_resume_argv() {
+ let session = super::super::snapshot::PaneAgentSessionSnapshot {
+ source: "herdr:pi".into(),
+ agent: "pi".into(),
+ kind: crate::agent_resume::AgentSessionRefKind::Path,
+ value: test_session_path("pi-session.jsonl"),
+ };
+ let resume = super::super::snapshot::PaneAgentResumeSnapshot {
+ source: "herdr:pi".into(),
+ agent: "pi".into(),
+ argv: vec![
+ "pi".into(),
+ "--continue".into(),
+ "--model".into(),
+ "m".into(),
+ ],
+ };
+ let history = super::super::snapshot::PaneHistorySnapshot {
+ ansi: "RESTORED_HISTORY\r\n".into(),
+ lines: 1,
+ };
+ let mut resumed = HashSet::new();
+ let mut agent_restore = AgentRestoreState {
+ enabled: true,
+ resumed_sessions: &mut resumed,
+ };
+
+ let project_a = std::path::Path::new("/project-a");
+ let project_b = std::path::Path::new("/project-b");
+ let startup = pane_restore_startup(
+ Some(&session),
+ Some(&resume),
+ project_a,
+ Some(&history),
+ &mut agent_restore,
+ );
+ let plan = startup.restore_plan.expect("reported resume plan");
+ assert_eq!(plan.agent, "pi");
+ assert_eq!(plan.argv, resume.argv);
+ assert!(startup.initial_history_ansi.is_none());
+
+ let custom = super::super::snapshot::PaneAgentResumeSnapshot {
+ source: "prime-agent".into(),
+ agent: "prime-agent".into(),
+ argv: vec!["prime-agent".into(), "--continue".into()],
+ };
+ let first = pane_restore_startup(None, Some(&custom), project_a, None, &mut agent_restore);
+ assert_eq!(first.restore_plan.unwrap().argv, custom.argv);
+ let other_project =
+ pane_restore_startup(None, Some(&custom), project_b, None, &mut agent_restore);
+ assert!(other_project.restore_plan.is_some());
+ let duplicate =
+ pane_restore_startup(None, Some(&custom), project_a, None, &mut agent_restore);
+ assert!(duplicate.restore_plan.is_none());
+ assert!(duplicate.duplicate_agent_session);
+
+ let mut resumed = HashSet::new();
+ let mut disabled = AgentRestoreState {
+ enabled: false,
+ resumed_sessions: &mut resumed,
+ };
+ let startup = pane_restore_startup(
+ None,
+ Some(&custom),
+ project_a,
+ Some(&history),
+ &mut disabled,
+ );
+ assert!(startup.restore_plan.is_none());
+ assert_eq!(startup.initial_history_ansi, Some("RESTORED_HISTORY\r\n"));
+ }
+
+ #[test]
fn pane_restore_startup_keeps_history_without_native_agent_resume() {
let session = super::super::snapshot::PaneAgentSessionSnapshot {
source: "herdr:pi".into(),
@@ -1177,7 +1311,13 @@ mod tests {
resumed_sessions: &mut resumed,
};
- let startup = pane_restore_startup(Some(&session), Some(&history), &mut agent_restore);
+ let startup = pane_restore_startup(
+ Some(&session),
+ None,
+ std::path::Path::new("/a"),
+ Some(&history),
+ &mut agent_restore,
+ );
assert!(startup.restore_plan.is_none());
assert_eq!(startup.initial_history_ansi, Some("RESTORED_HISTORY\r\n"));
@@ -1332,6 +1472,7 @@ mod tests {
kind: crate::agent_resume::AgentSessionRefKind::Id,
value: "opencode-session".into(),
}),
+ agent_resume: None,
launch_argv: None,
},
)]),
@@ -1413,6 +1554,7 @@ mod tests {
agent_name: None,
managed_agent_kind: None,
agent_session: None,
+ agent_resume: None,
launch_argv: None,
},
),
@@ -1424,6 +1566,7 @@ mod tests {
agent_name: None,
managed_agent_kind: None,
agent_session: None,
+ agent_resume: None,
launch_argv: None,
},
),
@@ -1477,6 +1620,7 @@ mod tests {
agent_name: None,
managed_agent_kind: None,
agent_session: None,
+ agent_resume: None,
launch_argv: None,
},
)
@@ -1492,6 +1636,7 @@ mod tests {
kind: crate::agent_resume::AgentSessionRefKind::Id,
value: "codex-session".into(),
}),
+ agent_resume: None,
launch_argv: None,
};
let snapshot = SessionSnapshot {
@@ -1643,6 +1788,7 @@ mod tests {
kind: crate::agent_resume::AgentSessionRefKind::Id,
value: "codex-session".into(),
}),
+ agent_resume: None,
launch_argv: None,
},
)]),
@@ -1948,6 +2094,7 @@ mod tests {
agent_name: None,
managed_agent_kind: None,
agent_session: None,
+ agent_resume: None,
launch_argv: None,
},
);
diff --git a/src/persist/snapshot.rs b/src/persist/snapshot.rs
index d7db3e97..3518ef41 100644
--- a/src/persist/snapshot.rs
+++ b/src/persist/snapshot.rs
@@ -108,10 +108,19 @@ pub struct PaneSnapshot {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_session: Option<PaneAgentSessionSnapshot>,
#[serde(default, skip_serializing_if = "Option::is_none")]
+ pub agent_resume: Option<PaneAgentResumeSnapshot>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
pub launch_argv: Option<Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PaneAgentResumeSnapshot {
+ pub source: String,
+ pub agent: String,
+ pub argv: Vec<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneAgentSessionSnapshot {
pub source: String,
pub agent: String,
@@ -362,6 +371,13 @@ fn capture_tab(
value: session.session_ref.value.clone(),
})
});
+ let agent_resume = terminal
+ .and_then(|terminal| terminal.reported_resume())
+ .map(|resume| PaneAgentResumeSnapshot {
+ source: resume.source.clone(),
+ agent: resume.agent.clone(),
+ argv: resume.argv.clone(),
+ });
panes.insert(
id.raw(),
PaneSnapshot {
@@ -370,6 +386,7 @@ fn capture_tab(
agent_name,
managed_agent_kind,
agent_session,
+ agent_resume,
launch_argv,
},
);
@@ -688,6 +705,7 @@ mod tests {
agent_name: None,
managed_agent_kind: None,
agent_session: None,
+ agent_resume: None,
launch_argv: None,
},
);
@@ -699,6 +717,7 @@ mod tests {
agent_name: None,
managed_agent_kind: None,
agent_session: None,
+ agent_resume: None,
launch_argv: None,
},
);
@@ -1287,6 +1306,40 @@ mod tests {
}
#[test]
+ fn capture_contract_includes_reported_agent_resume() {
+ 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();
+ let terminal = state.terminals.get_mut(&terminal_id).unwrap();
+ terminal.set_hook_authority(
+ "prime-agent".into(),
+ "prime-agent".into(),
+ crate::detect::AgentState::Idle,
+ None,
+ Some(1),
+ );
+ assert!(terminal.record_reported_resume(
+ "prime-agent",
+ "prime-agent",
+ Some(1),
+ vec!["prime-agent".into(), "--resume".into(), "a".into()],
+ ));
+
+ let snapshot = capture_from_state(&state);
+ let resume = snapshot.workspaces[0].tabs[0].panes[&root.raw()]
+ .agent_resume
+ .as_ref()
+ .expect("reported resume should be captured");
+
+ assert_eq!(resume.source, "prime-agent");
+ assert_eq!(resume.agent, "prime-agent");
+ assert_eq!(resume.argv, vec!["prime-agent", "--resume", "a"]);
+ }
+
+ #[test]
fn capture_contract_preserves_restored_agent_session() {
let mut state = state_with_workspaces(&["one"]);
let root = state.workspaces[0].tabs[0].root_pane;
@@ -1350,6 +1403,7 @@ mod tests {
agent_name: None,
managed_agent_kind: None,
agent_session: None,
+ agent_resume: None,
launch_argv: None,
},
);
@@ -1363,6 +1417,7 @@ mod tests {
agent_name: None,
managed_agent_kind: None,
agent_session: None,
+ agent_resume: None,
launch_argv: None,
},
);
diff --git a/src/server/headless/tests/mod.rs b/src/server/headless/tests/mod.rs
index 4def3fa4..bbb7bfe6 100644
--- a/src/server/headless/tests/mod.rs
+++ b/src/server/headless/tests/mod.rs
@@ -7289,6 +7289,142 @@ fn completion_guard_api_report(server: &mut HeadlessServer, method: api::schema:
serde_json::from_str::<api::schema::SuccessResponse>(&response).expect("successful report");
}
+#[test]
+fn api_report_agent_stores_valid_resume_argv_and_rejects_invalid() {
+ let (writer, _control_rx, _render_rx) = test_client_writer();
+ let (mut server, pane_id) = completion_guard_server(writer);
+ let public_pane_id = server.app.public_pane_id(0, pane_id).unwrap();
+ let terminal_id = server.app.state.workspaces[0].tabs[0].panes[&pane_id]
+ .attached_terminal_id
+ .clone();
+ let report = |resume_argv: Vec<&str>| {
+ api::schema::Method::PaneReportAgent(api::schema::PaneReportAgentParams {
+ pane_id: public_pane_id.clone(),
+ source: "prime-agent".into(),
+ agent: "prime-agent".into(),
+ state: api::schema::PaneAgentState::Idle,
+ message: None,
+ seq: Some(1),
+ agent_session_id: Some("01a0".into()),
+ agent_session_path: None,
+ resume_argv: Some(resume_argv.into_iter().map(String::from).collect()),
+ })
+ };
+
+ completion_guard_api_report(
+ &mut server,
+ report(vec!["prime-agent", "--resume", "01a0", "--model", "x"]),
+ );
+ assert_eq!(
+ server.app.state.terminals[&terminal_id]
+ .reported_resume()
+ .unwrap()
+ .argv,
+ vec!["prime-agent", "--resume", "01a0", "--model", "x"]
+ );
+
+ completion_guard_api_report(&mut server, report(vec!["prime-agent", "--resume", "dup"]));
+ assert_eq!(
+ server.app.state.terminals[&terminal_id]
+ .reported_resume()
+ .unwrap()
+ .argv[2],
+ "01a0",
+ "a duplicate sequence number must not replace the command"
+ );
+
+ let (respond_to, response_rx) = std::sync::mpsc::channel();
+ server.handle_api_request_with_shutdown_check(api::ApiRequestMessage {
+ request: api::schema::Request {
+ id: "invalid-resume".into(),
+ method: report(vec!["/opt/prime/prime-agent", "--resume", "01a0"]),
+ },
+ respond_to,
+ response_write_complete: None,
+ });
+ let response = response_rx
+ .recv_timeout(Duration::from_millis(100))
+ .unwrap();
+ assert!(response.contains("invalid_resume_argv"), "{response}");
+ assert_eq!(
+ server.app.state.terminals[&terminal_id]
+ .reported_resume()
+ .unwrap()
+ .argv[0],
+ "prime-agent"
+ );
+
+ let (respond_to, response_rx) = std::sync::mpsc::channel();
+ server.handle_api_request_with_shutdown_check(api::ApiRequestMessage {
+ request: api::schema::Request {
+ id: "not-owner".into(),
+ method: api::schema::Method::PaneReportAgentSession(
+ api::schema::PaneReportAgentSessionParams {
+ pane_id: public_pane_id.clone(),
+ source: "custom:intruder".into(),
+ agent: "intruder".into(),
+ seq: None,
+ agent_session_id: None,
+ agent_session_path: None,
+ session_start_source: None,
+ resume_argv: Some(vec!["intruder".into()]),
+ },
+ ),
+ },
+ respond_to,
+ response_write_complete: None,
+ });
+ let response = response_rx
+ .recv_timeout(Duration::from_millis(100))
+ .unwrap();
+ assert!(response.contains("resume_not_accepted"), "{response}");
+ assert_eq!(
+ server.app.state.terminals[&terminal_id]
+ .reported_resume()
+ .unwrap()
+ .agent,
+ "prime-agent"
+ );
+}
+
+#[test]
+fn api_resume_argv_is_ignored_when_its_session_report_is_refused() {
+ let (writer, _control_rx, _render_rx) = test_client_writer();
+ let (mut server, pane_id) = completion_guard_server(writer);
+ let public_pane_id = server.app.public_pane_id(0, pane_id).unwrap();
+ let terminal_id = server.app.state.workspaces[0].tabs[0].panes[&pane_id]
+ .attached_terminal_id
+ .clone();
+ server.handle_internal_event_with_forwarding(AppEvent::AgentProcessDetected {
+ pane_id,
+ agent: crate::detect::Agent::Claude,
+ observed_at: Instant::now(),
+ });
+ let report = |session: &str| {
+ api::schema::Method::PaneReportAgentSession(api::schema::PaneReportAgentSessionParams {
+ pane_id: public_pane_id.clone(),
+ source: "herdr:claude".into(),
+ agent: "claude".into(),
+ seq: None,
+ agent_session_id: Some(session.into()),
+ agent_session_path: None,
+ session_start_source: None,
+ resume_argv: Some(vec!["claude".into(), "--resume".into(), session.into()]),
+ })
+ };
+
+ completion_guard_api_report(&mut server, report("session-a"));
+ completion_guard_api_report(&mut server, report("session-b"));
+
+ let terminal = &server.app.state.terminals[&terminal_id];
+ assert!(terminal
+ .session_ref_is_current(&crate::agent_resume::AgentSessionRef::id("session-a").unwrap()));
+ assert_eq!(
+ terminal.reported_resume().unwrap().argv,
+ vec!["claude", "--resume", "session-a"]
+ );
+}
+
fn completion_guard_notifications(
server: &mut HeadlessServer,
receiver: &std::sync::mpsc::Receiver<Vec<u8>>,
@@ -7347,6 +7483,7 @@ fn completion_guard_api_startup_blocker_respects_suppression() {
seq: Some(seq as u64 + 1),
agent_session_id: None,
agent_session_path: None,
+ resume_argv: None,
}),
);
}
@@ -7410,6 +7547,7 @@ fn completion_guard_api_session_replacement_does_not_notify_finished() {
seq: Some(11),
agent_session_id: None,
agent_session_path: Some(new_session.clone()),
+ resume_argv: None,
session_start_source: Some(reason.into()),
}),
);
@@ -7422,6 +7560,7 @@ fn completion_guard_api_session_replacement_does_not_notify_finished() {
seq: Some(12),
agent_session_id: None,
agent_session_path: Some(new_session.clone()),
+ resume_argv: None,
};
completion_guard_api_report(&mut server, Method::PaneReportAgent(report.clone()));
let terminal = &server.app.state.terminals[&terminal_id];
@@ -7612,6 +7751,7 @@ fn stale_api_agent_report_does_not_forward_done_sound() {
seq: Some(19),
agent_session_id: None,
agent_session_path: None,
+ resume_argv: None,
}),
},
respond_to,
diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs
index ce7cab92..5f1c8a85 100644
--- a/src/terminal/runtime.rs
+++ b/src/terminal/runtime.rs
@@ -249,6 +249,10 @@ impl TerminalRuntime {
self.0.set_full_lifecycle_authority_active(active);
}
+ pub fn set_self_reported_agent_active(&self, active: bool) {
+ self.0.set_self_reported_agent_active(active);
+ }
+
pub fn resize(&self, rows: u16, cols: u16, cell_width_px: u32, cell_height_px: u32) {
self.0.resize(rows, cols, cell_width_px, cell_height_px);
}
diff --git a/src/terminal/state.rs b/src/terminal/state.rs
index 4a5abb12..11d540b1 100644
--- a/src/terminal/state.rs
+++ b/src/terminal/state.rs
@@ -137,6 +137,8 @@ pub struct TerminalState {
pub agent_metadata: HashMap<String, AgentMetadata>,
pub metadata_tokens: crate::metadata_tokens::MetadataTokens,
pub persisted_agent_session: Option<crate::agent_resume::PersistedAgentSession>,
+ reported_resume: Option<crate::agent_resume::ReportedAgentResume>,
+ reported_resume_revision: u64,
pub terminal_title: Option<String>,
pub manual_label: Option<String>,
pub agent_name: Option<String>,
@@ -175,6 +177,8 @@ impl TerminalState {
agent_metadata: HashMap::new(),
metadata_tokens: crate::metadata_tokens::MetadataTokens::default(),
persisted_agent_session: None,
+ reported_resume: None,
+ reported_resume_revision: 0,
terminal_title: None,
manual_label: None,
agent_name: None,
@@ -554,6 +558,15 @@ impl TerminalState {
{
self.persisted_agent_session = None;
}
+ if !newer_custom_authority
+ && agent.is_some()
+ && self
+ .reported_resume
+ .as_ref()
+ .is_some_and(|resume| crate::detect::parse_agent_label(&resume.agent) == agent)
+ {
+ self.set_reported_resume(None);
+ }
if let Some(agent) = agent {
let agent_label = crate::detect::agent_label(agent);
let mut cleared_metadata_sources = Vec::new();
@@ -1727,19 +1740,12 @@ impl TerminalState {
}
fn accept_hook_report(&mut self, source: &str, seq: Option<u64>) -> bool {
- let Some(seq) = seq else {
- return !self.hook_report_sequences.contains_key(source);
- };
-
- if self
- .hook_report_sequences
- .get(source)
- .is_some_and(|last_seq| seq <= *last_seq)
- {
+ if !self.hook_report_is_newer(source, seq) {
return false;
}
-
- self.hook_report_sequences.insert(source.to_string(), seq);
+ if let Some(seq) = seq {
+ self.hook_report_sequences.insert(source.to_string(), seq);
+ }
true
}
@@ -1785,7 +1791,9 @@ impl TerminalState {
self.suppress_current_full_lifecycle_hook_authority(
FullLifecycleHookSuppressionReason::HookClear,
);
- self.hook_authority = None;
+ if let Some(authority) = self.hook_authority.take() {
+ self.forget_reported_resume_of(&authority.source, &authority.agent_label);
+ }
self.persisted_agent_session = None;
Some(TerminalStateMutation {
effective_state_change: self.recompute_effective_state(
@@ -1848,6 +1856,7 @@ impl TerminalState {
self.clear_agent_name();
}
self.hook_authority = None;
+ self.forget_reported_resume_of(source, agent_label);
if !preserve_foreign_persisted_session {
self.persisted_agent_session = None;
}
@@ -1865,6 +1874,161 @@ impl TerminalState {
})
}
+ /// A reporter that Herdr cannot also identify by process holds this pane,
+ /// so only the pane returning to its shell can tell Herdr it exited.
+ pub fn self_reported_agent_active(&self) -> bool {
+ self.hook_authority.as_ref().is_some_and(|authority| {
+ crate::detect::parse_agent_label(&authority.agent_label).is_none()
+ })
+ }
+
+ /// `observed_at` is when the idle shell was seen; a claim made after that
+ /// belongs to a newer agent and must survive the delayed signal.
+ pub fn clear_self_reported_agent(
+ &mut self,
+ observed_at: Instant,
+ ) -> Option<TerminalStateMutation> {
+ if !self.self_reported_agent_active() || !self.hook_authority_not_newer_than(observed_at) {
+ return None;
+ }
+ let now = Instant::now();
+ 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_presentation = self.effective_presentation_for_state_at(previous_state, now);
+ let previous_session = self.current_session_identity_for_persistence();
+ self.hook_authority = None;
+ self.set_reported_resume(None);
+ self.detected_agent = None;
+ self.fallback_state = AgentState::Unknown;
+ self.fallback_visible_blocker = false;
+ self.fallback_observed_at = None;
+ self.clear_agent_name();
+ 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,
+ previous_presentation,
+ now,
+ ),
+ session_ref_changed: previous_session != current_session,
+ agent_released: true,
+ })
+ }
+
+ pub fn session_ref_is_current(
+ &self,
+ session_ref: &crate::agent_resume::AgentSessionRef,
+ ) -> bool {
+ self.current_session_identity_for_persistence()
+ .is_some_and(|(_, _, kind, value)| {
+ kind == session_ref.kind && value == session_ref.value
+ })
+ }
+
+ /// A reporter may own the resume command when it holds the pane, or when it
+ /// is an agent Herdr currently sees running there and can therefore see exit.
+ pub fn can_record_reported_resume(&self, source: &str, agent_label: &str) -> bool {
+ match self.hook_authority.as_ref() {
+ Some(authority) => authority.source == source && authority.agent_label == agent_label,
+ None => {
+ self.recent_agent_process_exit.is_none()
+ && self.detected_agent.is_some()
+ && crate::detect::parse_agent_label(agent_label) == self.detected_agent
+ }
+ }
+ }
+
+ /// Same ordering rule as lifecycle reports, evaluated before the report is
+ /// applied so a duplicate cannot pass as the report that was just accepted.
+ pub fn hook_report_is_newer(&self, source: &str, seq: Option<u64>) -> bool {
+ let last_seq = self.hook_report_sequences.get(source).copied();
+ match seq {
+ Some(seq) => last_seq.is_none_or(|last| seq > last),
+ None => last_seq.is_none(),
+ }
+ }
+
+ /// Callers must first check `hook_report_is_newer` against the state
+ /// before the carrying report was applied.
+ pub fn record_reported_resume(
+ &mut self,
+ source: &str,
+ agent_label: &str,
+ seq: Option<u64>,
+ argv: Vec<String>,
+ ) -> bool {
+ if !self.can_record_reported_resume(source, agent_label) {
+ return false;
+ }
+ if let Some(seq) = seq {
+ let last = self
+ .hook_report_sequences
+ .entry(source.to_string())
+ .or_insert(seq);
+ *last = (*last).max(seq);
+ }
+ let resume = crate::agent_resume::ReportedAgentResume {
+ source: source.to_string(),
+ agent: agent_label.to_string(),
+ argv,
+ };
+ if self.reported_resume.as_ref() == Some(&resume) {
+ return false;
+ }
+ self.set_reported_resume(Some(resume));
+ true
+ }
+
+ pub fn reported_resume(&self) -> Option<&crate::agent_resume::ReportedAgentResume> {
+ self.reported_resume.as_ref()
+ }
+
+ /// Changes whenever the stored resume command changes, so callers can
+ /// persist removals as well as new commands.
+ pub fn reported_resume_revision(&self) -> u64 {
+ self.reported_resume_revision
+ }
+
+ /// Drops the command once a different agent holds the pane.
+ pub fn reconcile_reported_resume(&mut self) {
+ let Some(resume) = self.reported_resume.as_ref() else {
+ return;
+ };
+ let other_authority = self.hook_authority.as_ref().is_some_and(|authority| {
+ authority.source != resume.source || authority.agent_label != resume.agent
+ });
+ let other_process = self
+ .detected_agent
+ .is_some_and(|agent| crate::detect::agent_label(agent) != resume.agent);
+ if other_authority || other_process {
+ self.set_reported_resume(None);
+ }
+ }
+
+ pub fn restore_reported_resume(&mut self, resume: crate::agent_resume::ReportedAgentResume) {
+ self.set_reported_resume(Some(resume));
+ }
+
+ fn set_reported_resume(&mut self, resume: Option<crate::agent_resume::ReportedAgentResume>) {
+ if self.reported_resume != resume {
+ self.reported_resume = resume;
+ self.reported_resume_revision += 1;
+ }
+ }
+
+ fn forget_reported_resume_of(&mut self, source: &str, agent_label: &str) {
+ if self
+ .reported_resume
+ .as_ref()
+ .is_some_and(|resume| resume.source == source && resume.agent == agent_label)
+ {
+ self.set_reported_resume(None);
+ }
+ }
+
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| {