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..b385d73d73 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -183,6 +183,63 @@ 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<(Option, Agent, Vec)>, + state_events: &mpsc::Sender, + pane_id: PaneId, + process_group_id: Option, + agent: Option, + args: Option>, +) { + 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_group, last_agent, last_args)| { + *last_group == process_group_id && *last_agent == agent && *last_args == args + }) + { + return; + } + *last_reported = Some((process_group_id, 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 +586,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 +634,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 +695,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 +707,7 @@ fn probe_foreground_process_from_jobs( foreground_is_pane_shell: false, agent: None, process_name: None, + agent_launch_args: None, } } @@ -693,6 +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<(Option, Agent, Vec)> = None; loop { let sleep_duration = if pending_idle.active() { @@ -721,6 +787,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 +845,15 @@ fn spawn_basic_detection_task( *pending_release = None; } } + publish_changed_agent_launch_args( + &mut last_agent_launch_args, + &state_events, + pane_id, + tracked_process_group_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 +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<(Option, detect::Agent, Vec)> = + None; tokio::time::sleep(Duration::from_millis(50)).await; @@ -2209,6 +2287,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 +2353,16 @@ impl PaneRuntime { } } + publish_changed_agent_launch_args( + &mut last_agent_launch_args, + &state_events, + pane_id, + tracked_process_group_id, + new_agent, + probe.agent_launch_args, + ) + .await; + let previous_agent = agent_presence.current_agent(); let foreground_action = foreground_shell_agent_action( previous_agent, @@ -3026,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() @@ -3781,6 +3945,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..21cde0222c 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,10 @@ 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) = 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 { terminal.set_manual_label(label); } @@ -633,6 +644,11 @@ 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) = + 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 { terminal.set_manual_label(label); @@ -738,6 +754,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 +762,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 @@ -781,15 +799,33 @@ 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>, 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) + 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, + &session.agent, + &persisted.session_ref, + launch_args, + ) } fn persisted_agent_session_from_snapshot( @@ -819,7 +855,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 +1045,76 @@ 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 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"); @@ -1019,9 +1125,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 +1139,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 +1183,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 +1209,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 +1238,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 +1309,7 @@ mod tests { value: "opencode-session".into(), }), launch_argv: None, + agent_launch: None, }, )]), zoomed: false, @@ -1279,6 +1391,7 @@ mod tests { managed_agent_kind: None, agent_session: None, launch_argv: None, + agent_launch: None, }, ), ( @@ -1290,6 +1403,7 @@ mod tests { managed_agent_kind: None, agent_session: None, launch_argv: None, + agent_launch: None, }, ), ]), @@ -1343,6 +1457,7 @@ mod tests { managed_agent_kind: None, agent_session: None, launch_argv: None, + agent_launch: None, }, ) }; @@ -1358,6 +1473,7 @@ mod tests { value: "codex-session".into(), }), launch_argv: None, + agent_launch: None, }; let snapshot = SessionSnapshot { version: super::super::snapshot::SNAPSHOT_VERSION, @@ -1509,6 +1625,7 @@ mod tests { value: "codex-session".into(), }), launch_argv: None, + agent_launch: None, }, )]), zoomed: false, @@ -1670,6 +1787,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(); }