From e164c381281f99451ecee2575bfd4c5f3d2cb9da Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 10 Aug 2026 11:28:43 +0200 Subject: [PATCH 1/3] feat: remember agent cli options when resuming sessions Native agent session restore rebuilt the resume command from the session reference alone, so a pane started as `claude --permission-mode bypassPermissions` came back with default permissions. Capture the options the agent process was started with during foreground process detection, persist them per agent in the session snapshot, and replay them after the session reference on resume. Two rules decide what survives. Options that select a conversation or a one-shot run are dropped per agent, and a bare word is kept only when it directly follows a kept option, where it is that option's value. The second rule is what makes the first cheap: the value of a dropped option disappears because it follows no kept option, so `--resume ` loses the id without Herdr knowing that `--resume` takes one, and a subcommand form such as `codex resume ` needs no special case. Everything else is replayed, including options Herdr does not know about. --- .../src/content/docs/session-state.mdx | 16 + src/agent_launch_args.rs | 287 ++++++++++++++++++ src/agent_resume.rs | 75 ++++- src/app/actions.rs | 48 +++ src/detect/mod.rs | 88 ++++++ src/events.rs | 7 + src/main.rs | 1 + src/pane.rs | 107 +++++++ src/persist/restore.rs | 102 ++++++- src/persist/snapshot.rs | 59 ++++ src/terminal/state.rs | 25 ++ 11 files changed, 801 insertions(+), 14 deletions(-) create mode 100644 src/agent_launch_args.rs diff --git a/docs/next/website/src/content/docs/session-state.mdx b/docs/next/website/src/content/docs/session-state.mdx index 548cf120a2..45c7eb8c5d 100644 --- a/docs/next/website/src/content/docs/session-state.mdx +++ b/docs/next/website/src/content/docs/session-state.mdx @@ -81,6 +81,22 @@ Native session restore requires these Herdr integration versions or newer: | Hermes Agent | `2` | `hermes --resume ` | | MastraCode | `1` | `mastracode --thread ` | +Herdr also replays the options the agent was started with, so a pane started as +`claude --permission-mode bypassPermissions` resumes as +`claude --resume --permission-mode bypassPermissions` instead of dropping back to +default permissions. + +Options that select or continue a conversation (`--resume`, `--continue`, `--session`) and +options that switch the agent to a one-shot run (`--print`, `--prompt`) are never replayed; +every other option is, including ones Herdr has never heard of. A bare word is replayed only +when it directly follows a replayed option, where it is that option's value, so prompts, +subcommands, and positional session ids are left behind. + +Two limits follow from that rule: an option given several separate values keeps only the +first (`--add-dir /a /b` resumes as `--add-dir /a`), and a prompt written directly after a +flag cannot be told apart from that flag's value, so it is replayed. Use `--option=value` +form if you want to be sure a following prompt is not mistaken for a value. + Run `herdr integration status` to check installed integration versions. Reinstall outdated integrations with `herdr integration install `. Unsupported, missing, invalid, duplicated, or stale session references restore as normal shells in the saved pane directory. diff --git a/src/agent_launch_args.rs b/src/agent_launch_args.rs new file mode 100644 index 0000000000..ae8ef6d52d --- /dev/null +++ b/src/agent_launch_args.rs @@ -0,0 +1,287 @@ +//! Remembering the options an agent CLI was started with. +//! +//! Herdr resumes a native agent session by running the agent again with its own +//! session reference. The rest of the original command line matters too: a pane +//! started with `claude --permission-mode bypassPermissions` must come back with +//! that permission mode, not with plain `claude --resume `. +//! +//! The captured argument list comes from the live agent process, so it can also +//! contain the session reference Herdr appended on a previous resume, a +//! subcommand, or the prompt the user typed at launch. Replaying those would +//! either point the agent at a stale conversation or restart work the user never +//! asked for, so two rules decide what survives a resume: +//! +//! - Options that select a conversation or a one-shot run are dropped, per +//! agent. Herdr supplies its own session reference. +//! - A bare word is kept only when it directly follows a kept option, where it +//! is that option's value. Bare words elsewhere are prompts, subcommands, or +//! positional paths, and none of those belong in a resume command. +//! +//! Together those drop the value of a dropped option without Herdr having to +//! know which options take values: `--resume ` loses the id because the id +//! follows an option that was dropped. +//! +//! Two deliberate limits: an option that takes several separate values keeps +//! only the first (`--add-dir /a /b` resumes as `--add-dir /a`), and a prompt +//! written directly after a flag is indistinguishable from that flag's value, so +//! it is replayed. + +/// Return the arguments worth replaying when resuming `agent`. +/// +/// `args` is the agent process command line with the executable removed. +pub fn replayable(agent: &str, args: &[String]) -> Vec { + let dropped = dropped_options(agent); + let mut kept: Vec = Vec::new(); + // Whether the previous argument was a kept option still waiting for a value. + let mut kept_option_wants_value = false; + + for arg in args { + if arg == "--" { + // Everything after the separator is positional. + break; + } + match option_name(arg) { + Some(name) => { + let inline_value = name.len() < arg.len(); + if dropped.contains(&name) { + kept_option_wants_value = false; + continue; + } + kept.push(arg.clone()); + kept_option_wants_value = !inline_value; + } + None => { + if kept_option_wants_value { + kept.push(arg.clone()); + } + kept_option_wants_value = false; + } + } + } + + kept +} + +fn option_name(arg: &str) -> Option<&str> { + if !arg.starts_with('-') || arg == "-" || arg == "--" { + return None; + } + Some(arg.split('=').next().unwrap_or(arg)) +} + +/// Options that select or continue a conversation, or switch the agent to a +/// one-shot run. Values are dropped with them, because a value following a +/// dropped option is a bare word that follows no kept option. +/// +/// Agents that select a session through a subcommand and a positional id, such +/// as `codex resume `, need no entry: those are bare words too. +fn dropped_options(agent: &str) -> &'static [&'static str] { + match agent { + "claude" => &[ + "-r", + "--resume", + "-c", + "--continue", + "-p", + "--print", + "--session-id", + "--fork-session", + "--cloud", + "--teleport", + "--from-pr", + "--bg", + "--background", + ], + "codex" => &["--last"], + "copilot" => &[ + "-r", + "--resume", + "--continue", + "-p", + "--prompt", + "-i", + "--interactive", + "--session-id", + "--acp", + "--connect", + ], + "grok" => &[ + "-r", + "--resume", + "-c", + "--continue", + "-p", + "--single", + "-s", + "--session-id", + "--fork-session", + "--prompt-file", + "--prompt-json", + ], + "opencode" => &["-s", "--session", "-c", "--continue", "--fork", "--prompt"], + "pi" => &[ + "-r", + "--resume", + "-c", + "--continue", + "-p", + "--print", + "--session", + "--session-id", + "--fork", + "--no-session", + "--export", + "--list-models", + ], + "omp" => &[ + "-r", + "--resume", + "-c", + "--continue", + "-p", + "--print", + "--from-claude", + "--from-codex", + "--no-session", + "--alias", + "--export", + ], + "cursor" => &["--resume", "-p", "--print"], + "droid" => &["--resume", "-p", "--print"], + "devin" | "hermes" | "qodercli" => &["--resume"], + "kimi" | "kilo" => &["--session"], + "mastracode" => &["--thread"], + "agy" => &["--conversation"], + _ => &[], + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_string()).collect() + } + + #[test] + fn keeps_options_and_their_values() { + assert_eq!( + replayable( + "claude", + &args(&["--permission-mode", "bypassPermissions", "--verbose"]) + ), + args(&["--permission-mode", "bypassPermissions", "--verbose"]) + ); + assert_eq!( + replayable("codex", &args(&["-s", "danger-full-access", "-a", "never"])), + args(&["-s", "danger-full-access", "-a", "never"]) + ); + assert_eq!( + replayable("claude", &args(&["--dangerously-skip-permissions"])), + args(&["--dangerously-skip-permissions"]) + ); + assert_eq!( + replayable("omp", &args(&["--model=opus", "--auto-approve"])), + args(&["--model=opus", "--auto-approve"]) + ); + } + + #[test] + fn drops_session_selection_from_an_earlier_resume() { + assert_eq!( + replayable( + "claude", + &args(&[ + "--permission-mode", + "bypassPermissions", + "--resume", + "c1893fd1-3b1a-46d0-9b4f-a09cd2a42c8b", + ]) + ), + args(&["--permission-mode", "bypassPermissions"]) + ); + assert_eq!( + replayable("copilot", &args(&["--allow-all-tools", "--resume=abc123"])), + args(&["--allow-all-tools"]) + ); + assert_eq!( + replayable("omp", &args(&["--model=opus", "--resume=abc123"])), + args(&["--model=opus"]) + ); + } + + #[test] + fn drops_session_subcommands_and_their_ids() { + assert_eq!( + replayable( + "codex", + &args(&[ + "resume", + "01997f1e-4b4a-7c31-9c0e-2f1f0a3f0f11", + "--full-auto" + ]) + ), + args(&["--full-auto"]) + ); + // Global options can precede the subcommand; the id still follows a bare + // word rather than an option, so both fall away. + assert_eq!( + replayable( + "codex", + &args(&[ + "-m", + "gpt-5.6", + "resume", + "01997f1e-4b4a-7c31-9c0e-2f1f0a3f0f11" + ]) + ), + args(&["-m", "gpt-5.6"]) + ); + } + + #[test] + fn drops_prompts_and_one_shot_options() { + assert_eq!( + replayable("claude", &args(&["fix the failing test"])), + Vec::::new() + ); + assert_eq!( + replayable("claude", &args(&["-p", "summarize this repo"])), + Vec::::new() + ); + assert_eq!( + replayable( + "grok", + &args(&["--always-approve", "--", "trailing prompt"]) + ), + args(&["--always-approve"]) + ); + } + + #[test] + fn keeps_options_herdr_has_never_heard_of() { + assert_eq!( + replayable( + "claude", + &args(&["--future-option", "value", "--future-flag"]) + ), + args(&["--future-option", "value", "--future-flag"]) + ); + assert_eq!( + replayable("something-else", &args(&["--flag", "value"])), + args(&["--flag", "value"]) + ); + } + + #[test] + fn keeps_only_the_first_value_of_a_repeated_option() { + assert_eq!( + replayable( + "claude", + &args(&["--add-dir", "/one", "/two", "--permission-mode", "plan"]) + ), + args(&["--add-dir", "/one", "--permission-mode", "plan"]) + ); + } +} diff --git a/src/agent_resume.rs b/src/agent_resume.rs index e2fc47e9b7..6d10cf87b7 100644 --- a/src/agent_resume.rs +++ b/src/agent_resume.rs @@ -114,11 +114,25 @@ pub fn session_ref_from_snapshot( } pub fn plan(source: &str, agent: &str, session_ref: &AgentSessionRef) -> Option { + plan_with_launch_args(source, agent, session_ref, &[]) +} + +/// Build the resume command, replaying the options the agent was started with. +/// +/// `launch_args` is the captured agent command line without the executable. +/// [`crate::agent_launch_args::replayable`] decides which of those arguments +/// belong in a resume command. +pub fn plan_with_launch_args( + source: &str, + agent: &str, + session_ref: &AgentSessionRef, + launch_args: &[String], +) -> Option { if !is_official_agent_source(source, agent) { return None; } - let argv = match (source, agent, session_ref.kind) { + let mut argv = match (source, agent, session_ref.kind) { ("herdr:claude", "claude", AgentSessionRefKind::Id) => { vec![ "claude".into(), @@ -205,6 +219,8 @@ pub fn plan(source: &str, agent: &str, session_ref: &AgentSessionRef) -> Option< _ => return None, }; + argv.extend(crate::agent_launch_args::replayable(agent, launch_args)); + Some(AgentResumePlan { agent: agent.to_string(), argv, @@ -450,6 +466,63 @@ mod tests { ); } + #[test] + fn planner_replays_the_options_the_agent_was_started_with() { + let launch_args = vec![ + "--permission-mode".to_string(), + "bypassPermissions".to_string(), + "--resume".to_string(), + "previous-session".to_string(), + ]; + + assert_eq!( + plan_with_launch_args( + "herdr:claude", + "claude", + &AgentSessionRef::id("claude-session").unwrap(), + &launch_args, + ) + .unwrap() + .argv, + vec![ + "claude", + "--resume", + "claude-session", + "--permission-mode", + "bypassPermissions", + ] + ); + + assert_eq!( + plan_with_launch_args( + "herdr:codex", + "codex", + &AgentSessionRef::id("codex-session").unwrap(), + &["-s".to_string(), "danger-full-access".to_string()], + ) + .unwrap() + .argv, + vec![ + "codex", + "resume", + "codex-session", + "-s", + "danger-full-access" + ] + ); + } + + #[test] + fn planner_ignores_launch_args_for_unsupported_sources() { + assert!(plan_with_launch_args( + "custom:claude", + "claude", + &AgentSessionRef::id("claude-session").unwrap(), + &["--dangerously-skip-permissions".to_string()], + ) + .is_none()); + } + #[test] fn planner_rejects_custom_and_unsupported_path_refs() { let claude_session = absolute_test_path("claude-session"); diff --git a/src/app/actions.rs b/src/app/actions.rs index d6e264f56f..facfe98f3c 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -2808,6 +2808,14 @@ impl AppState { }) .into_iter() .collect(), + AppEvent::AgentLaunchArgsDetected { + pane_id, + agent, + args, + } => { + self.record_agent_launch_args(pane_id, agent, args); + Vec::new() + } AppEvent::HookStateReported { pane_id, source, @@ -2955,6 +2963,21 @@ impl AppState { } } + fn record_agent_launch_args(&mut self, pane_id: PaneId, agent: Agent, args: Vec) { + let Some(terminal_id) = self.workspaces.iter().find_map(|ws| { + ws.pane_state(pane_id) + .map(|pane| pane.attached_terminal_id.clone()) + }) else { + return; + }; + let Some(terminal) = self.terminals.get_mut(&terminal_id) else { + return; + }; + if terminal.set_agent_launch_args(crate::detect::agent_label(agent), args) { + self.mark_session_dirty(); + } + } + fn update_terminal_state(&mut self, pane_id: PaneId, update: F) -> Option where F: FnOnce(&mut crate::terminal::TerminalState) -> Option, @@ -4856,6 +4879,31 @@ mod tests { state.assert_invariants_for_test(); } + #[test] + fn detected_agent_launch_args_are_recorded_for_the_pane() { + let mut state = app_with_workspaces(&["test"]); + let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); + + state.handle_app_event(AppEvent::AgentLaunchArgsDetected { + pane_id, + agent: Agent::Claude, + args: vec!["--permission-mode".into(), "bypassPermissions".into()], + }); + + let terminal_id = state.workspaces[0] + .panes + .get(&pane_id) + .unwrap() + .attached_terminal_id + .clone(); + let launch = state.terminals[&terminal_id] + .agent_launch_args + .as_ref() + .expect("launch options should be recorded"); + assert_eq!(launch.agent, "claude"); + assert_eq!(launch.args, vec!["--permission-mode", "bypassPermissions"]); + } + #[test] fn state_changed_updates_pane() { let mut state = app_with_workspaces(&["test"]); diff --git a/src/detect/mod.rs b/src/detect/mod.rs index b9f9655f5a..e68d9912b1 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -243,6 +243,42 @@ pub fn identify_agent_in_job(job: &crate::platform::ForegroundJob) -> Option<(Ag best.map(|(_, agent, name)| (agent, name)) } +/// Read the options `agent` was started with from a foreground job. +/// +/// Returns the command line after the agent token, so wrapped invocations such +/// as `node .../cli.js --permission-mode plan` yield the agent's own options. +/// Returns `None` when no process in the job exposes an argument vector that +/// starts with a recognizable agent token; the caller then keeps whatever it +/// already knows instead of recording a truncated command line. +pub fn agent_launch_args_in_job( + job: &crate::platform::ForegroundJob, + agent: Agent, +) -> Option> { + let leader = job + .processes + .iter() + .find(|process| process.pid == job.process_group_id); + leader + .and_then(|process| agent_launch_args(process, agent)) + .or_else(|| { + job.processes + .iter() + .find_map(|process| agent_launch_args(process, agent)) + }) +} + +fn agent_launch_args( + process: &crate::platform::ForegroundProcess, + agent: Agent, +) -> Option> { + let argv = process.argv.as_deref()?; + let label = agent_label(agent); + let agent_token = argv + .iter() + .position(|token| agent_name_from_path_token(token).as_deref() == Some(label))?; + Some(argv[agent_token + 1..].to_vec()) +} + /// Detect the state of an agent from the live terminal tail snapshot. /// If `agent` is `None`, returns `Unknown`. #[cfg(test)] @@ -652,6 +688,58 @@ mod tests { } } + #[test] + fn agent_launch_args_read_options_after_the_agent_token() { + let job = crate::platform::ForegroundJob { + process_group_id: 1, + processes: vec![foreground_process( + 1, + "claude", + &["claude", "--permission-mode", "bypassPermissions"], + )], + }; + + assert_eq!( + agent_launch_args_in_job(&job, Agent::Claude), + Some(vec![ + "--permission-mode".to_string(), + "bypassPermissions".to_string() + ]) + ); + } + + #[test] + fn agent_launch_args_skip_a_wrapping_runtime() { + let job = crate::platform::ForegroundJob { + process_group_id: 1, + processes: vec![ + foreground_process(1, "node", &["node", "/path/to/bin/codex", "--full-auto"]), + foreground_process(2, "bash", &["bash"]), + ], + }; + + assert_eq!( + agent_launch_args_in_job(&job, Agent::Codex), + Some(vec!["--full-auto".to_string()]) + ); + } + + #[test] + fn agent_launch_args_are_unknown_without_a_matching_token() { + let job = crate::platform::ForegroundJob { + process_group_id: 1, + processes: vec![crate::platform::ForegroundProcess { + pid: 1, + name: "claude".to_string(), + argv0: None, + argv: None, + cmdline: None, + }], + }; + + assert_eq!(agent_launch_args_in_job(&job, Agent::Claude), None); + } + #[cfg(unix)] fn temp_detection_path(name: &str) -> std::path::PathBuf { let unique = format!( diff --git a/src/events.rs b/src/events.rs index cc638e6018..e842f1151f 100644 --- a/src/events.rs +++ b/src/events.rs @@ -72,6 +72,13 @@ pub enum AppEvent { process_exited: bool, observed_at: Instant, }, + /// The options a detected agent process was started with. + /// Herdr replays them when it resumes that agent's session. + AgentLaunchArgsDetected { + pane_id: PaneId, + agent: Agent, + args: Vec, + }, /// Hook-authoritative agent state was reported for a pane. HookStateReported { pane_id: PaneId, diff --git a/src/main.rs b/src/main.rs index 4d915158d3..945bacb771 100644 --- a/src/main.rs +++ b/src/main.rs @@ -54,6 +54,7 @@ fn set_host_color_scheme_reports(enabled: bool) -> io::Result<()> { io::stdout().flush() } +mod agent_launch_args; mod agent_resume; mod api; mod app; diff --git a/src/pane.rs b/src/pane.rs index c95da331f3..b7d2cff7ee 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -183,6 +183,42 @@ fn active_pending_release( } } +/// Report the options a probed agent process was started with, once per change. +/// `last_reported` keeps the detector from resending the same options on every probe. +async fn publish_changed_agent_launch_args( + last_reported: &mut Option<(Agent, Vec)>, + state_events: &mpsc::Sender, + pane_id: PaneId, + agent: Option, + args: Option>, +) { + let (Some(agent), Some(args)) = (agent, args) else { + return; + }; + if last_reported + .as_ref() + .is_some_and(|(last_agent, last_args)| *last_agent == agent && *last_args == args) + { + return; + } + *last_reported = Some((agent, args.clone())); + + if let Err(e) = state_events + .send(AppEvent::AgentLaunchArgsDetected { + pane_id, + agent, + args, + }) + .await + { + warn!( + pane = pane_id.raw(), + err = %e, + "failed to deliver AgentLaunchArgsDetected event" + ); + } +} + async fn publish_state_changed_event( state_events: mpsc::Sender, pane_id: PaneId, @@ -529,6 +565,9 @@ struct ProcessProbeResult { foreground_is_pane_shell: bool, agent: Option, process_name: Option, + /// Options the detected agent was started with, without the executable. + /// Herdr replays them when it resumes the agent's session. + agent_launch_args: Option>, } fn agent_hint_for_foreground_job_members( @@ -574,6 +613,7 @@ fn process_probe_result( foreground_is_pane_shell: job.processes.iter().any(|process| process.pid == pid), agent: Some(agent), process_name: Some(process_name), + agent_launch_args: crate::detect::agent_launch_args_in_job(job, agent), } } @@ -634,6 +674,9 @@ fn probe_foreground_process_from_jobs( process_group_id: Some(job.process_group_id), foreground_is_pane_shell: job.processes.iter().any(|process| process.pid == pid), agent: identified.as_ref().map(|(agent, _)| *agent), + agent_launch_args: identified + .as_ref() + .and_then(|(agent, _)| crate::detect::agent_launch_args_in_job(job, *agent)), process_name: identified.map(|(_, process_name)| process_name), }; } @@ -643,6 +686,7 @@ fn probe_foreground_process_from_jobs( foreground_is_pane_shell: false, agent: None, process_name: None, + agent_launch_args: None, } } @@ -693,6 +737,7 @@ fn spawn_basic_detection_task( let mut last_screen_scan_detection_content_seq = None; let mut agent_startup_grace_until = None; let mut pending_idle = PendingIdleConfirmation::default(); + let mut last_agent_launch_args: Option<(Agent, Vec)> = None; loop { let sleep_duration = if pending_idle.active() { @@ -721,6 +766,7 @@ fn spawn_basic_detection_task( last_screen_scan_detection_content_seq = None; agent_startup_grace_until = None; pending_idle.clear(); + last_agent_launch_args = None; } } @@ -778,6 +824,14 @@ fn spawn_basic_detection_task( *pending_release = None; } } + publish_changed_agent_launch_args( + &mut last_agent_launch_args, + &state_events, + pane_id, + new_agent, + probe.agent_launch_args.clone(), + ) + .await; let previous_agent = agent_presence.current_agent(); let foreground_action = foreground_shell_agent_action( previous_agent, @@ -2171,6 +2225,7 @@ impl PaneRuntime { let mut last_screen_scan_detection_content_seq = None; let mut agent_startup_grace_until = None; let mut pending_idle = PendingIdleConfirmation::default(); + let mut last_agent_launch_args: Option<(detect::Agent, Vec)> = None; tokio::time::sleep(Duration::from_millis(50)).await; @@ -2209,6 +2264,7 @@ impl PaneRuntime { last_screen_scan_detection_content_seq = None; agent_startup_grace_until = None; pending_idle.clear(); + last_agent_launch_args = None; } } @@ -2274,6 +2330,15 @@ impl PaneRuntime { } } + publish_changed_agent_launch_args( + &mut last_agent_launch_args, + &state_events, + pane_id, + new_agent, + probe.agent_launch_args, + ) + .await; + let previous_agent = agent_presence.current_agent(); let foreground_action = foreground_shell_agent_action( previous_agent, @@ -3781,6 +3846,48 @@ mod tests { assert_eq!(result.process_name.as_deref(), Some("claude")); } + #[test] + fn process_probe_reads_the_options_the_agent_was_started_with() { + let job = crate::platform::ForegroundJob { + process_group_id: 99, + processes: vec![crate::platform::ForegroundProcess { + pid: 99, + name: "claude".to_string(), + argv0: None, + argv: Some(vec![ + "claude".to_string(), + "--permission-mode".to_string(), + "bypassPermissions".to_string(), + ]), + cmdline: None, + }], + }; + + let result = probe_foreground_process_from_jobs(42, Some(99), Some(job), || None, |_| None); + + assert_eq!(result.agent, Some(Agent::Claude)); + assert_eq!( + result.agent_launch_args, + Some(vec![ + "--permission-mode".to_string(), + "bypassPermissions".to_string() + ]) + ); + } + + #[test] + fn process_probe_has_no_options_without_an_agent() { + let job = crate::platform::ForegroundJob { + process_group_id: 99, + processes: vec![foreground_process(99, "bash")], + }; + + let result = probe_foreground_process_from_jobs(42, Some(99), None, || Some(job), |_| None); + + assert_eq!(result.agent, None); + assert_eq!(result.agent_launch_args, None); + } + fn process_probe_input() -> ProcessProbeInput { ProcessProbeInput { current_agent: None, diff --git a/src/persist/restore.rs b/src/persist/restore.rs index 3c5775c9e1..a2e79f6b11 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, + PaneAgentLaunchSnapshot, PaneAgentSessionSnapshot, PaneHistorySnapshot, TabHistorySnapshot, + WorkspaceHistorySnapshot, }; use super::{ DirectionSnapshot, LayoutSnapshot, SessionHistorySnapshot, SessionSnapshot, TabSnapshot, @@ -497,6 +498,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_launch = saved_pane.and_then(|p| p.agent_launch.as_ref()); let saved_history = old_id.and_then(|old_id| history.and_then(|history| history.panes.get(old_id))); let startup = { @@ -504,7 +506,12 @@ fn restore_tab( enabled: runtime_context.resume_agents_on_restore, resumed_sessions: resumed_agent_sessions, }; - pane_restore_startup(saved_agent_session, saved_history, &mut agent_restore) + pane_restore_startup( + saved_agent_session, + saved_agent_launch, + saved_history, + &mut agent_restore, + ) }; let restored_agent_session = restored_terminal_agent_session(saved_agent_session, startup.duplicate_agent_session); @@ -537,6 +544,9 @@ fn restore_tab( let terminal_id = TerminalId::alloc(); let mut terminal = TerminalState::new(terminal_id.clone(), cwd.clone()) .with_pending_agent_resume_plan(plan); + if let Some(launch) = saved_agent_launch { + terminal.set_agent_launch_args(&launch.agent, launch.args.clone()); + } if let Some(label) = saved_label { terminal.set_manual_label(label); } @@ -633,6 +643,9 @@ fn restore_tab( if let Some(argv) = saved_launch_argv { terminal = terminal.with_launch_argv(argv).with_respawn_shell_on_exit(); } + if let Some(launch) = saved_agent_launch { + terminal.set_agent_launch_args(&launch.agent, launch.args.clone()); + } } if let Some(label) = saved_label { terminal.set_manual_label(label); @@ -738,6 +751,7 @@ fn restore_tab( fn pane_restore_startup<'a>( session: Option<&PaneAgentSessionSnapshot>, + agent_launch: Option<&PaneAgentLaunchSnapshot>, history: Option<&'a PaneHistorySnapshot>, agent_restore: &mut AgentRestoreState<'_>, ) -> PaneRestoreStartup<'a> { @@ -745,8 +759,9 @@ 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 = session.and_then(|session| { + restore_plan_for_snapshot(session, agent_launch, agent_restore.enabled) + }); 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 @@ -783,13 +798,24 @@ fn pane_restore_startup<'a>( fn restore_plan_for_snapshot( session: &PaneAgentSessionSnapshot, + agent_launch: Option<&PaneAgentLaunchSnapshot>, 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) + // Options belong to the agent that was running. A pane that later ran a + // different agent must not resume with the previous agent's options. + let launch_args = agent_launch + .filter(|launch| launch.agent == session.agent) + .map_or(&[][..], |launch| launch.args.as_slice()); + crate::agent_resume::plan_with_launch_args( + &session.source, + &session.agent, + &persisted.session_ref, + launch_args, + ) } fn persisted_agent_session_from_snapshot( @@ -819,7 +845,7 @@ fn take_restore_plan_for_snapshot( resume_agents_on_restore: bool, resumed_agent_sessions: &mut HashSet, ) -> Option { - restore_plan_for_snapshot(session, resume_agents_on_restore) + restore_plan_for_snapshot(session, None, resume_agents_on_restore) .filter(|plan| resumed_agent_sessions.insert(plan.dedupe_key.clone())) } @@ -1009,6 +1035,44 @@ mod tests { assert_eq!(restored_worktree_space_membership(Some(membership)), None); } + #[test] + fn restore_plan_replays_saved_agent_options_for_the_same_agent() { + let session = super::super::snapshot::PaneAgentSessionSnapshot { + source: "herdr:claude".into(), + agent: "claude".into(), + kind: crate::agent_resume::AgentSessionRefKind::Id, + value: "claude-session".into(), + }; + let launch = super::super::snapshot::PaneAgentLaunchSnapshot { + agent: "claude".into(), + args: vec!["--permission-mode".into(), "bypassPermissions".into()], + }; + + assert_eq!( + restore_plan_for_snapshot(&session, Some(&launch), true) + .unwrap() + .argv, + vec![ + "claude", + "--resume", + "claude-session", + "--permission-mode", + "bypassPermissions", + ] + ); + + let other_agent = super::super::snapshot::PaneAgentLaunchSnapshot { + agent: "codex".into(), + args: vec!["-s".into(), "danger-full-access".into()], + }; + assert_eq!( + restore_plan_for_snapshot(&session, Some(&other_agent), true) + .unwrap() + .argv, + vec!["claude", "--resume", "claude-session"] + ); + } + #[test] fn restore_plan_respects_opt_in_and_allowlist() { let pi_session_path = test_session_path("pi-session.jsonl"); @@ -1019,9 +1083,11 @@ mod tests { value: pi_session_path.clone(), }; - assert!(restore_plan_for_snapshot(&session, false).is_none()); + assert!(restore_plan_for_snapshot(&session, None, false).is_none()); assert_eq!( - restore_plan_for_snapshot(&session, true).unwrap().argv, + restore_plan_for_snapshot(&session, None, true) + .unwrap() + .argv, vec!["pi", "--session", pi_session_path.as_str()] ); @@ -1031,7 +1097,7 @@ mod tests { kind: crate::agent_resume::AgentSessionRefKind::Path, value: test_session_path("claude-session"), }; - assert!(restore_plan_for_snapshot(&unsupported_path, true).is_none()); + assert!(restore_plan_for_snapshot(&unsupported_path, None, true).is_none()); } #[test] @@ -1075,7 +1141,8 @@ mod tests { resumed_sessions: &mut resumed, }; - let startup = pane_restore_startup(Some(&session), Some(&history), &mut agent_restore); + let startup = + pane_restore_startup(Some(&session), None, Some(&history), &mut agent_restore); assert!(startup.restore_plan.is_some()); assert!(startup.initial_history_ansi.is_none()); @@ -1100,8 +1167,9 @@ mod tests { resumed_sessions: &mut resumed, }; - let first = pane_restore_startup(Some(&session), Some(&history), &mut agent_restore); - let duplicate = pane_restore_startup(Some(&session), Some(&history), &mut agent_restore); + let first = pane_restore_startup(Some(&session), None, Some(&history), &mut agent_restore); + let duplicate = + pane_restore_startup(Some(&session), None, Some(&history), &mut agent_restore); assert!(first.restore_plan.is_some()); assert!(first.initial_history_ansi.is_none()); @@ -1128,7 +1196,8 @@ mod tests { resumed_sessions: &mut resumed, }; - let startup = pane_restore_startup(Some(&session), Some(&history), &mut agent_restore); + let startup = + pane_restore_startup(Some(&session), None, Some(&history), &mut agent_restore); assert!(startup.restore_plan.is_none()); assert_eq!(startup.initial_history_ansi, Some("RESTORED_HISTORY\r\n")); @@ -1198,6 +1267,7 @@ mod tests { value: "opencode-session".into(), }), launch_argv: None, + agent_launch: None, }, )]), zoomed: false, @@ -1279,6 +1349,7 @@ mod tests { managed_agent_kind: None, agent_session: None, launch_argv: None, + agent_launch: None, }, ), ( @@ -1290,6 +1361,7 @@ mod tests { managed_agent_kind: None, agent_session: None, launch_argv: None, + agent_launch: None, }, ), ]), @@ -1343,6 +1415,7 @@ mod tests { managed_agent_kind: None, agent_session: None, launch_argv: None, + agent_launch: None, }, ) }; @@ -1358,6 +1431,7 @@ mod tests { value: "codex-session".into(), }), launch_argv: None, + agent_launch: None, }; let snapshot = SessionSnapshot { version: super::super::snapshot::SNAPSHOT_VERSION, @@ -1509,6 +1583,7 @@ mod tests { value: "codex-session".into(), }), launch_argv: None, + agent_launch: None, }, )]), zoomed: false, @@ -1670,6 +1745,7 @@ mod tests { managed_agent_kind: None, agent_session: None, launch_argv: None, + agent_launch: None, }, ); let history = SessionHistorySnapshot { diff --git a/src/persist/snapshot.rs b/src/persist/snapshot.rs index 39f790ddda..055d680289 100644 --- a/src/persist/snapshot.rs +++ b/src/persist/snapshot.rs @@ -107,6 +107,16 @@ pub struct PaneSnapshot { pub agent_session: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub launch_argv: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_launch: Option, +} + +/// The options an agent was started with, replayed when Herdr resumes its +/// session. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneAgentLaunchSnapshot { + pub agent: String, + pub args: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -338,6 +348,16 @@ fn capture_tab( }) .unwrap_or_default(); let launch_argv = terminal.and_then(|terminal| terminal.launch_argv.clone()); + let agent_launch = terminal.and_then(|terminal| { + terminal + .agent_launch_args + .as_ref() + .filter(|launch| !launch.args.is_empty()) + .map(|launch| PaneAgentLaunchSnapshot { + agent: launch.agent.clone(), + args: launch.args.clone(), + }) + }); let agent_session = terminal.and_then(|terminal| { if let Some(authority) = terminal.hook_authority.as_ref() { if let Some(session_ref) = authority.session_ref.as_ref() { @@ -368,6 +388,7 @@ fn capture_tab( managed_agent_kind, agent_session, launch_argv, + agent_launch, }, ); } @@ -648,6 +669,7 @@ mod tests { managed_agent_kind: None, agent_session: None, launch_argv: None, + agent_launch: None, }, ); panes.insert( @@ -659,6 +681,7 @@ mod tests { managed_agent_kind: None, agent_session: None, launch_argv: None, + agent_launch: None, }, ); @@ -1099,6 +1122,40 @@ mod tests { assert!(second_history.ansi.contains("second-pane-history")); } + #[test] + fn capture_contract_tracks_agent_launch_options() { + 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(); + assert!(snapshot_agent_launch(&state, root).is_none()); + + let terminal = state.terminals.get_mut(&terminal_id).unwrap(); + terminal.set_agent_launch_args( + "claude", + vec!["--permission-mode".into(), "bypassPermissions".into()], + ); + + let launch = snapshot_agent_launch(&state, root).expect("options should be captured"); + assert_eq!(launch.agent, "claude"); + assert_eq!(launch.args, vec!["--permission-mode", "bypassPermissions"]); + + let terminal = state.terminals.get_mut(&terminal_id).unwrap(); + terminal.set_agent_launch_args("claude", Vec::new()); + assert!(snapshot_agent_launch(&state, root).is_none()); + } + + fn snapshot_agent_launch( + state: &crate::app::AppState, + pane: crate::layout::PaneId, + ) -> Option { + capture_from_state(state).workspaces[0].tabs[0].panes[&pane.raw()] + .agent_launch + .clone() + } + #[test] fn capture_contract_tracks_hook_authority_agent_session() { let mut state = state_with_workspaces(&["one"]); @@ -1207,6 +1264,7 @@ mod tests { managed_agent_kind: None, agent_session: None, launch_argv: None, + agent_launch: None, }, ); panes.insert( @@ -1220,6 +1278,7 @@ mod tests { managed_agent_kind: None, agent_session: None, launch_argv: None, + agent_launch: None, }, ); diff --git a/src/terminal/state.rs b/src/terminal/state.rs index 4b1fdaef4a..ed4d4f93e4 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -147,6 +147,15 @@ pub struct TerminalState { recent_agent_process_exit: Option, agent_process_acquisition_pending: bool, pub pending_agent_resume_plan: Option, + pub agent_launch_args: Option, +} + +/// The options an agent process was started with, kept per agent so a resume +/// never replays options that belonged to a different agent in the same pane. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentLaunchArgs { + pub agent: String, + pub args: Vec, } impl TerminalState { @@ -181,6 +190,7 @@ impl TerminalState { recent_agent_process_exit: None, agent_process_acquisition_pending: false, pending_agent_resume_plan: None, + agent_launch_args: None, } } @@ -216,6 +226,20 @@ impl TerminalState { suppress_completion } + /// Record the options the foreground agent process was started with. + /// Returns true when the stored options changed and the session needs saving. + pub fn set_agent_launch_args(&mut self, agent: &str, args: Vec) -> bool { + let observed = AgentLaunchArgs { + agent: agent.to_string(), + args, + }; + if self.agent_launch_args.as_ref() == Some(&observed) { + return false; + } + self.agent_launch_args = Some(observed); + true + } + pub(crate) fn terminal_title_stripped(&self) -> Option { self.terminal_title .as_deref() @@ -2059,6 +2083,7 @@ impl TerminalState { self.recent_agent_process_exit = None; self.agent_process_acquisition_pending = false; self.pending_agent_resume_plan = None; + self.agent_launch_args = None; self.clear_agent_name(); } From 46cbc9f319d23e910f63c09668cdff6b6a765b5f Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 10 Aug 2026 21:18:25 +0200 Subject: [PATCH 2/3] fix: keep saved agent options with their recorded agent --- src/persist/restore.rs | 54 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 48 insertions(+), 6 deletions(-) diff --git a/src/persist/restore.rs b/src/persist/restore.rs index a2e79f6b11..21cde0222c 100644 --- a/src/persist/restore.rs +++ b/src/persist/restore.rs @@ -544,7 +544,8 @@ fn restore_tab( let terminal_id = TerminalId::alloc(); let mut terminal = TerminalState::new(terminal_id.clone(), cwd.clone()) .with_pending_agent_resume_plan(plan); - if let Some(launch) = saved_agent_launch { + if let Some(launch) = agent_launch_for_session(saved_agent_launch, saved_agent_session) + { terminal.set_agent_launch_args(&launch.agent, launch.args.clone()); } if let Some(label) = saved_label { @@ -643,7 +644,9 @@ fn restore_tab( if let Some(argv) = saved_launch_argv { terminal = terminal.with_launch_argv(argv).with_respawn_shell_on_exit(); } - if let Some(launch) = saved_agent_launch { + if let Some(launch) = + agent_launch_for_session(saved_agent_launch, saved_agent_session) + { terminal.set_agent_launch_args(&launch.agent, launch.args.clone()); } } @@ -796,6 +799,16 @@ fn pane_restore_startup<'a>( } } +/// Options belong to the agent that was running. A pane that later ran a +/// different agent must not reuse the previous agent's options. A pane with no +/// recorded session carries no contradicting evidence, so its options stand. +fn agent_launch_for_session<'a>( + agent_launch: Option<&'a PaneAgentLaunchSnapshot>, + session: Option<&PaneAgentSessionSnapshot>, +) -> Option<&'a PaneAgentLaunchSnapshot> { + agent_launch.filter(|launch| session.is_none_or(|session| session.agent == launch.agent)) +} + fn restore_plan_for_snapshot( session: &PaneAgentSessionSnapshot, agent_launch: Option<&PaneAgentLaunchSnapshot>, @@ -805,10 +818,7 @@ fn restore_plan_for_snapshot( return None; } let persisted = persisted_agent_session_from_snapshot(session)?; - // Options belong to the agent that was running. A pane that later ran a - // different agent must not resume with the previous agent's options. - let launch_args = agent_launch - .filter(|launch| launch.agent == session.agent) + let launch_args = agent_launch_for_session(agent_launch, Some(session)) .map_or(&[][..], |launch| launch.args.as_slice()); crate::agent_resume::plan_with_launch_args( &session.source, @@ -1073,6 +1083,38 @@ mod tests { ); } + #[test] + fn saved_agent_options_survive_only_a_matching_or_absent_session() { + let session = super::super::snapshot::PaneAgentSessionSnapshot { + source: "herdr:claude".into(), + agent: "claude".into(), + kind: crate::agent_resume::AgentSessionRefKind::Id, + value: "claude-session".into(), + }; + let launch = super::super::snapshot::PaneAgentLaunchSnapshot { + agent: "claude".into(), + args: vec!["--permission-mode".into(), "bypassPermissions".into()], + }; + + assert_eq!( + agent_launch_for_session(Some(&launch), Some(&session)).map(|l| l.agent.as_str()), + Some("claude") + ); + // No session recorded, so nothing contradicts the saved options. + assert_eq!( + agent_launch_for_session(Some(&launch), None).map(|l| l.agent.as_str()), + Some("claude") + ); + + let other_agent = super::super::snapshot::PaneAgentSessionSnapshot { + agent: "codex".into(), + source: "herdr:codex".into(), + ..session + }; + assert!(agent_launch_for_session(Some(&launch), Some(&other_agent)).is_none()); + assert!(agent_launch_for_session(None, Some(&other_agent)).is_none()); + } + #[test] fn restore_plan_respects_opt_in_and_allowlist() { let pi_session_path = test_session_path("pi-session.jsonl"); From 0766aa57ee0ceb4410ae064e94c0f0557853eee7 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 10 Aug 2026 21:33:37 +0200 Subject: [PATCH 3/3] fix: drop agent options a later invocation did not report --- src/pane.rs | 111 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 105 insertions(+), 6 deletions(-) diff --git a/src/pane.rs b/src/pane.rs index b7d2cff7ee..b385d73d73 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -186,22 +186,43 @@ fn active_pending_release( /// Report the options a probed agent process was started with, once per change. /// `last_reported` keeps the detector from resending the same options on every probe. async fn publish_changed_agent_launch_args( - last_reported: &mut Option<(Agent, Vec)>, + last_reported: &mut Option<(Option, Agent, Vec)>, state_events: &mpsc::Sender, pane_id: PaneId, + process_group_id: Option, agent: Option, args: Option>, ) { - let (Some(agent), Some(args)) = (agent, args) else { + let Some(agent) = agent else { return; }; + let args = match args { + Some(args) => args, + None => { + // Options that cannot be read say nothing about what is running, so + // the last ones stand only while the same job is still in front. + let Some((last_group, last_agent, _)) = last_reported.as_ref() else { + // Nothing was reported yet, so anything already stored came + // from a restored snapshot and is not ours to overwrite. + return; + }; + if *last_agent == agent && *last_group == process_group_id { + return; + } + // A later invocation is in front, so the stored options are its + // predecessor's and must not be replayed on resume. + Vec::new() + } + }; if last_reported .as_ref() - .is_some_and(|(last_agent, last_args)| *last_agent == agent && *last_args == args) + .is_some_and(|(last_group, last_agent, last_args)| { + *last_group == process_group_id && *last_agent == agent && *last_args == args + }) { return; } - *last_reported = Some((agent, args.clone())); + *last_reported = Some((process_group_id, agent, args.clone())); if let Err(e) = state_events .send(AppEvent::AgentLaunchArgsDetected { @@ -737,7 +758,7 @@ fn spawn_basic_detection_task( let mut last_screen_scan_detection_content_seq = None; let mut agent_startup_grace_until = None; let mut pending_idle = PendingIdleConfirmation::default(); - let mut last_agent_launch_args: Option<(Agent, Vec)> = None; + let mut last_agent_launch_args: Option<(Option, Agent, Vec)> = None; loop { let sleep_duration = if pending_idle.active() { @@ -828,6 +849,7 @@ fn spawn_basic_detection_task( &mut last_agent_launch_args, &state_events, pane_id, + tracked_process_group_id, new_agent, probe.agent_launch_args.clone(), ) @@ -2225,7 +2247,8 @@ impl PaneRuntime { let mut last_screen_scan_detection_content_seq = None; let mut agent_startup_grace_until = None; let mut pending_idle = PendingIdleConfirmation::default(); - let mut last_agent_launch_args: Option<(detect::Agent, Vec)> = None; + let mut last_agent_launch_args: Option<(Option, detect::Agent, Vec)> = + None; tokio::time::sleep(Duration::from_millis(50)).await; @@ -2334,6 +2357,7 @@ impl PaneRuntime { &mut last_agent_launch_args, &state_events, pane_id, + tracked_process_group_id, new_agent, probe.agent_launch_args, ) @@ -3091,6 +3115,81 @@ mod tests { assert!(cmd.get_env("CODEX_THREAD_ID").is_none()); } + #[tokio::test] + async fn unreadable_options_never_carry_across_agent_invocations() { + let (events, mut event_rx) = mpsc::channel(8); + let pane_id = PaneId::alloc(); + let mut last = None; + + let args = |values: &[&str]| Some(values.iter().map(|v| v.to_string()).collect::>()); + let next = |rx: &mut mpsc::Receiver| match rx.try_recv() { + Ok(AppEvent::AgentLaunchArgsDetected { args, .. }) => Some(args), + _ => None, + }; + + publish_changed_agent_launch_args( + &mut last, + &events, + pane_id, + Some(10), + Some(Agent::Claude), + args(&["--permission-mode", "bypassPermissions"]), + ) + .await; + assert_eq!( + next(&mut event_rx), + Some(vec![ + "--permission-mode".to_string(), + "bypassPermissions".to_string() + ]) + ); + + // Same job, options momentarily unreadable: the last ones still hold. + publish_changed_agent_launch_args( + &mut last, + &events, + pane_id, + Some(10), + Some(Agent::Claude), + None, + ) + .await; + assert_eq!(next(&mut event_rx), None); + + // A later invocation of the same agent with unreadable options must not + // inherit its predecessor's, since resume matches on the agent alone. + publish_changed_agent_launch_args( + &mut last, + &events, + pane_id, + Some(11), + Some(Agent::Claude), + None, + ) + .await; + assert_eq!(next(&mut event_rx), Some(Vec::new())); + } + + #[tokio::test] + async fn unreadable_options_leave_restored_ones_alone() { + let (events, mut event_rx) = mpsc::channel(8); + let mut last = None; + + // Nothing probed yet, so whatever is stored came from a snapshot. + publish_changed_agent_launch_args( + &mut last, + &events, + PaneId::alloc(), + Some(10), + Some(Agent::Claude), + None, + ) + .await; + + assert!(event_rx.try_recv().is_err()); + assert!(last.is_none()); + } + #[tokio::test] async fn cwd_returns_accepted_report_without_rechecking_filesystem() { let stamp = std::time::SystemTime::now()