Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/next/website/src/content/docs/session-state.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ Unsupported, missing, invalid, duplicated, or stale session references restore a

If native agent session restore applies to a pane, Herdr resumes the agent session instead of replaying saved pane history for that pane.

Herdr also remembers compatible options from the detected agent process, such as model, permission, approval, and sandbox choices. Each agent manifest declares zero-value flags and one-value options that are safe to replay; all other arguments are dropped. The active bundled, downloaded, or local override manifest is checked again when restoring, so removing an option from a manifest revokes it for future resumes.

## Live handoff

Live handoff is for update and remote attach flows that need to replace a running Herdr server. It asks the old server to transfer live panes to the new server, so pane processes can keep running across the server replacement.
Expand Down
43 changes: 38 additions & 5 deletions scripts/agent_detection_manifest_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,15 @@
DEFAULT_WEBSITE_DIR = PROJECT_ROOT / "website" / "agent-detection"
ENGINE_SOURCE = PROJECT_ROOT / "src" / "detect" / "manifest_update.rs"

MANIFEST_KEYS = {"id", "version", "min_engine_version", "updated_at", "aliases", "rules"}
MANIFEST_KEYS = {
"id",
"version",
"min_engine_version",
"updated_at",
"aliases",
"resume_options",
"rules",
}
RULE_KEYS = {
"id",
"state",
Expand Down Expand Up @@ -52,6 +60,8 @@
MAX_MATCHERS_PER_GATE = 32
MAX_TOTAL_MATCHERS = 1024
MAX_MATCHER_CHARS = 512
MAX_RESUME_OPTIONS_PER_MANIFEST = 128
MAX_RESUME_OPTION_CHARS = 128

# Keep engine-2 clients on the OSC-capable manifest until an engine-3 release
# can consume top_non_empty_lines. Remove this entry when the website publishes
Expand Down Expand Up @@ -143,9 +153,32 @@ def validate_manifest(path: Path, engine_version: int) -> dict:
if not isinstance(aliases, list) or not all(isinstance(item, str) for item in aliases):
raise CheckError(f"{path}: aliases must be an array of strings")

rules = manifest.get("rules")
if not isinstance(rules, list) or not rules:
raise CheckError(f"{path}: rules must be a non-empty array")
resume_options = manifest.get("resume_options", {})
if not isinstance(resume_options, dict) or set(resume_options) - {"flags", "options"}:
raise CheckError(f"{path}: resume_options may contain only flags and options")
seen_resume_options: set[str] = set()
for key in ("flags", "options"):
values = resume_options.get(key, [])
if not isinstance(values, list) or not all(isinstance(item, str) for item in values):
raise CheckError(f"{path}: resume_options.{key} must be an array of strings")
for value in values:
if len(value) > MAX_RESUME_OPTION_CHARS:
raise CheckError(f"{path}: resume option exceeds {MAX_RESUME_OPTION_CHARS} characters")
if not value.startswith("-") or value in {"-", "--"} or "=" in value:
raise CheckError(f"{path}: invalid resume option {value!r}")
if value in seen_resume_options:
raise CheckError(f"{path}: duplicate resume option {value!r}")
seen_resume_options.add(value)
if len(seen_resume_options) > MAX_RESUME_OPTIONS_PER_MANIFEST:
raise CheckError(
f"{path}: manifest exceeds max resume option count {MAX_RESUME_OPTIONS_PER_MANIFEST}"
)

rules = manifest.get("rules", [])
if not isinstance(rules, list):
raise CheckError(f"{path}: rules must be an array")
if not rules and not seen_resume_options:
raise CheckError(f"{path}: manifest must contain rules or resume options")
if len(rules) > MAX_RULES_PER_MANIFEST:
raise CheckError(f"{path}: manifest exceeds max rule count {MAX_RULES_PER_MANIFEST}")
complexity = {"gates": 0, "matchers": 0}
Expand Down Expand Up @@ -327,7 +360,7 @@ def validate_catalog(
stages_new_engine_manifest = (
staged_manifest
== (bundled_manifest["version"], manifest["version"], website_digest)
and bundled_manifest["min_engine_version"] == engine_version
and bundled_manifest["min_engine_version"] <= engine_version
and manifest["min_engine_version"] < bundled_manifest["min_engine_version"]
)
if cmp < 0 and not stages_new_engine_manifest:
Expand Down
90 changes: 90 additions & 0 deletions src/agent_resume_options.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
pub(crate) fn filter(args: &[String], flags: &[String], options: &[String]) -> Vec<String> {
let mut filtered = Vec::new();
let mut index = 0;

while let Some(arg) = args.get(index) {
if arg == "--" {
break;
}
if flags.iter().any(|flag| flag == arg) {
filtered.push(arg.clone());
index += 1;
continue;
}
if let Some((name, value)) = arg.split_once('=') {
if options.iter().any(|option| option == name) && !value.is_empty() {
filtered.push(arg.clone());
}
index += 1;
continue;
}
if options.iter().any(|option| option == arg) {
let Some(value) = args.get(index + 1) else {
break;
};
if value == "--" || value.starts_with('-') {
index += 1;
continue;
}
filtered.push(arg.clone());
filtered.push(value.clone());
index += 2;
continue;
}
index += 1;
}

filtered
}

#[cfg(test)]
mod tests {
use super::*;

fn strings(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_string()).collect()
}

#[test]
fn resume_options_filter_keeps_only_declared_flags_and_option_values() {
let args = strings(&[
"--dangerously-skip-permissions",
"--model",
"opus",
"--unknown",
"unknown-value",
"--permission-mode=bypassPermissions",
"fix the bug",
"--model",
"sonnet",
]);

assert_eq!(
filter(
&args,
&strings(&["--dangerously-skip-permissions"]),
&strings(&["--model", "--permission-mode"]),
),
strings(&[
"--dangerously-skip-permissions",
"--model",
"opus",
"--permission-mode=bypassPermissions",
"--model",
"sonnet",
])
);
}

#[test]
fn resume_options_filter_drops_missing_values_and_stops_at_argument_separator() {
let args = strings(&["--model", "--", "--dangerously-skip-permissions", "prompt"]);

assert!(filter(
&args,
&strings(&["--dangerously-skip-permissions"]),
&strings(&["--model"]),
)
.is_empty());
}
}
51 changes: 51 additions & 0 deletions src/app/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2792,6 +2792,26 @@ impl AppState {
})
.into_iter()
.collect(),
AppEvent::AgentResumeOptionsDetected {
pane_id,
agent,
options,
} => {
let terminal_id = self.workspaces.iter().find_map(|workspace| {
workspace
.pane_state(pane_id)
.map(|pane| pane.attached_terminal_id.clone())
});
let changed = terminal_id.and_then(|terminal_id| {
self.terminals
.get_mut(&terminal_id)
.map(|terminal| terminal.set_agent_resume_options(agent, options))
});
if changed == Some(true) {
self.mark_session_dirty();
}
Vec::new()
}
AppEvent::StateChanged {
pane_id,
agent,
Expand Down Expand Up @@ -4862,6 +4882,37 @@ mod tests {
state.assert_invariants_for_test();
}

#[test]
fn detected_agent_resume_options_are_cached_and_mark_session_dirty() {
let mut state = app_with_workspaces(&["test"]);
let pane_id = state.workspaces[0].active_tab().unwrap().root_pane;
state.session_dirty = false;

state.handle_app_event(AppEvent::AgentResumeOptionsDetected {
pane_id,
agent: Agent::Claude,
options: vec!["--model".into(), "opus".into()],
});

let terminal_id = state.workspaces[0].active_tab().unwrap().panes[&pane_id]
.attached_terminal_id
.clone();
let cached = state.terminals[&terminal_id]
.agent_resume_options
.as_ref()
.unwrap();
assert_eq!(cached.options, ["--model", "opus"]);
assert!(state.session_dirty);

state.session_dirty = false;
state.handle_app_event(AppEvent::AgentResumeOptionsDetected {
pane_id,
agent: Agent::Claude,
options: vec!["--model".into(), "opus".into()],
});
assert!(!state.session_dirty);
}

#[test]
fn state_changed_updates_pane() {
let mut state = app_with_workspaces(&["test"]);
Expand Down
6 changes: 4 additions & 2 deletions src/app/agent_resume.rs
Original file line number Diff line number Diff line change
Expand Up @@ -796,12 +796,14 @@ mod tests {
let argv = vec![
"claude".to_string(),
"--resume".to_string(),
"session with ' quote".to_string(),
"session-id".to_string(),
"--name".to_string(),
"worker seven's".to_string(),
];

assert_eq!(
shell_command_from_argv(&argv).as_deref(),
Some("claude --resume 'session with '\\'' quote'")
Some("claude --resume session-id --name 'worker seven'\\''s'")
);
assert_eq!(shell_command_from_argv(&[]), None);
}
Expand Down
6 changes: 5 additions & 1 deletion src/app/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ impl App {
segment_index,
result,
} => self.handle_tab_bar_command_finished(generation, segment_index, result),
ev @ AppEvent::TerminalBell { .. } => {
ev @ (AppEvent::TerminalBell { .. } | AppEvent::AgentResumeOptionsDetected { .. }) => {
self.handle_internal_event(ev);
false
}
Expand Down Expand Up @@ -306,6 +306,7 @@ impl App {
None
};
let terminal_cwd_reported = matches!(ev, AppEvent::TerminalCwdReported { .. });
let resume_options_detected = matches!(ev, AppEvent::AgentResumeOptionsDetected { .. });
let previous_toast = self.state.toast.clone();
let pane_updates = self.state.handle_app_event(ev);
if let Some(agents) = manifest_update_agents {
Expand All @@ -330,6 +331,9 @@ impl App {
self.render_dirty.request_generic();
self.render_notify.notify_one();
}
if resume_options_detected {
return pane_updates;
}
for update in &pane_updates {
self.refresh_new_herdr_toast_context_for_update(update, &previous_toast);
self.emit_pane_state_update(update);
Expand Down
60 changes: 56 additions & 4 deletions src/detect/manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,20 @@ pub(crate) struct AgentManifest {
#[serde(default)]
aliases: Vec<String>,
#[serde(default)]
resume_options: ManifestResumeOptions,
#[serde(default)]
rules: Vec<ManifestRule>,
}

#[derive(Debug, Default, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
struct ManifestResumeOptions {
#[serde(default)]
flags: Vec<String>,
#[serde(default)]
options: Vec<String>,
}

#[derive(Debug, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
struct ManifestRule {
Expand Down Expand Up @@ -253,6 +264,7 @@ const BUNDLED_MANIFESTS: &[(&str, &str)] = &[
("kiro", include_str!("manifests/kiro.toml")),
("maki", include_str!("manifests/maki.toml")),
("muse", include_str!("manifests/muse.toml")),
("omp", include_str!("manifests/omp.toml")),
("opencode", include_str!("manifests/opencode.toml")),
("pi", include_str!("manifests/pi.toml")),
("qodercli", include_str!("manifests/qodercli.toml")),
Expand All @@ -269,6 +281,8 @@ const MAX_TOTAL_GATES: usize = 512;
const MAX_MATCHERS_PER_GATE: usize = 32;
const MAX_TOTAL_MATCHERS: usize = 1024;
const MAX_MATCHER_CHARS: usize = 512;
const MAX_RESUME_OPTIONS_PER_MANIFEST: usize = 128;
const MAX_RESUME_OPTION_CHARS: usize = 128;

pub(crate) fn reload_manifests() -> Vec<AgentManifestSummary> {
let _reload_guard = MANIFEST_RELOAD_LOCK
Expand All @@ -295,7 +309,7 @@ pub(crate) fn reload_manifests_for_agents(agents: &[Agent]) {
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let lock = manifest_cache();
let replacements = Agent::SCREEN_MANIFEST_AGENTS
let replacements = Agent::MANIFEST_AGENTS
.into_iter()
.filter(|agent| agents.contains(agent))
.map(|agent| (agent, load_manifest_uncached(agent)))
Expand All @@ -321,7 +335,7 @@ fn manifest_cache() -> &'static RwLock<ManifestCache> {

fn build_manifest_cache() -> ManifestCache {
ManifestCache {
manifests: Agent::SCREEN_MANIFEST_AGENTS
manifests: Agent::MANIFEST_AGENTS
.into_iter()
.map(|agent| (agent, load_manifest_uncached(agent)))
.collect(),
Expand Down Expand Up @@ -888,6 +902,17 @@ pub(crate) struct ParsedRemoteManifest {
pub(crate) version: ManifestVersion,
}

pub(crate) fn filter_resume_options(agent: Agent, args: &[String]) -> Vec<String> {
let Some(loaded) = load_manifest(agent) else {
return Vec::new();
};
crate::agent_resume_options::filter(
args,
&loaded.manifest.resume_options.flags,
&loaded.manifest.resume_options.options,
)
}

pub(crate) fn parse_manifest(content: &str) -> Result<AgentManifest, String> {
let manifest = toml::from_str::<AgentManifest>(content).map_err(|err| err.to_string())?;
validate_manifest(&manifest)?;
Expand Down Expand Up @@ -922,9 +947,36 @@ pub(crate) fn parse_remote_manifest_for_agent(
Ok(ParsedRemoteManifest { manifest, version })
}

fn validate_resume_options(resume_options: &ManifestResumeOptions) -> Result<(), String> {
if resume_options.flags.len() + resume_options.options.len() > MAX_RESUME_OPTIONS_PER_MANIFEST {
return Err(format!(
"manifest contains too many resume options, max is {MAX_RESUME_OPTIONS_PER_MANIFEST}"
));
}
let mut seen = std::collections::HashSet::new();
for option in resume_options.flags.iter().chain(&resume_options.options) {
if option.chars().count() > MAX_RESUME_OPTION_CHARS {
return Err(format!(
"resume option exceeds {MAX_RESUME_OPTION_CHARS} characters"
));
}
if !option.starts_with('-') || option == "-" || option == "--" || option.contains('=') {
return Err(format!("invalid resume option {option:?}"));
}
if !seen.insert(option.as_str()) {
return Err(format!("duplicate resume option {option:?}"));
}
}
Ok(())
}

fn validate_manifest(manifest: &AgentManifest) -> Result<(), String> {
if manifest.rules.is_empty() {
return Err("manifest must contain at least one rule".to_string());
validate_resume_options(&manifest.resume_options)?;
if manifest.rules.is_empty()
&& manifest.resume_options.flags.is_empty()
&& manifest.resume_options.options.is_empty()
{
return Err("manifest must contain at least one rule or resume option".to_string());
}
if manifest.rules.len() > MAX_RULES_PER_MANIFEST {
return Err(format!(
Expand Down
Loading
Loading