aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-27 20:57:00 +0300
committerGitHub <noreply@github.com>2026-09-27 20:57:00 +0300
commit2552d101afe46b8622a7695f38f6cd1766dd1afc (patch)
treee560c2cc8ef02689711af0865a5b541ee3050bf1 /src/app
parent0d1a39dcee6a0f244a036c55935970f40cfa76b8 (diff)
feat: let agents report their own resume command (#4687)
* feat: let agents report their own resume command * fix: keep reported resume commands tied to accepted sessions and current owners
Diffstat (limited to 'src/app')
-rw-r--r--src/app/actions.rs225
-rw-r--r--src/app/api.rs1
-rw-r--r--src/app/api/panes.rs132
-rw-r--r--src/app/api/plugins/mod.rs1
4 files changed, 338 insertions, 21 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs
index 6a6e99d2..e1ae2ffc 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -1500,6 +1500,28 @@ impl AppState {
.collect()
}
}
+ AppEvent::AgentResumeReported {
+ pane_id,
+ source,
+ agent_label,
+ seq,
+ argv,
+ } => self
+ .update_terminal_state(pane_id, |terminal| {
+ terminal.record_reported_resume(&source, &agent_label, seq, argv);
+ None
+ })
+ .into_iter()
+ .collect(),
+ AppEvent::ReportedAgentShellReturned {
+ pane_id,
+ observed_at,
+ } => self
+ .update_terminal_state(pane_id, |terminal| {
+ terminal.clear_self_reported_agent(observed_at)
+ })
+ .into_iter()
+ .collect(),
AppEvent::AgentSessionReported {
pane_id,
source,
@@ -1653,7 +1675,14 @@ impl AppState {
let terminal = self.terminals.get_mut(&terminal_id)?;
let previous_agent_name = terminal.agent_name.clone();
let had_completion = terminal.last_agent_completion_seq.is_some() || !previous_seen;
- let mutation = update(terminal)?;
+ let resume_revision = terminal.reported_resume_revision();
+ let mutation = update(terminal);
+ terminal.reconcile_reported_resume();
+ // Resume-only changes return no mutation but must still be saved.
+ if terminal.reported_resume_revision() != resume_revision {
+ self.session_dirty = true;
+ }
+ let mutation = mutation?;
let completion_reset = mutation.session_ref_changed
|| mutation
.effective_state_change
@@ -3541,6 +3570,200 @@ mod tests {
assert!(state.toast.is_none());
}
+ fn report_custom_agent_with_resume(state: &mut AppState, pane_id: PaneId, argv: &[&str]) {
+ state.handle_app_event(AppEvent::HookStateReported {
+ pane_id,
+ source: "prime-agent".into(),
+ agent_label: "prime-agent".into(),
+ state: AgentState::Idle,
+ message: None,
+ seq: Some(1),
+ session_ref: None,
+ });
+ state.handle_app_event(AppEvent::AgentResumeReported {
+ pane_id,
+ source: "prime-agent".into(),
+ agent_label: "prime-agent".into(),
+ seq: Some(1),
+ argv: argv.iter().map(|part| part.to_string()).collect(),
+ });
+ }
+
+ fn first_pane_terminal(state: &AppState) -> (PaneId, crate::terminal::TerminalId) {
+ let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
+ let terminal_id = state.workspaces[0].panes[&pane_id]
+ .attached_terminal_id
+ .clone();
+ (pane_id, terminal_id)
+ }
+
+ #[test]
+ fn reported_resume_follows_the_reporting_agent_until_release() {
+ let mut state = app_with_workspaces(&["one"]);
+ let (pane_id, terminal_id) = first_pane_terminal(&state);
+ report_custom_agent_with_resume(&mut state, pane_id, &["prime-agent", "--resume", "a"]);
+
+ let resume = state.terminals[&terminal_id].reported_resume().unwrap();
+ assert_eq!(resume.agent, "prime-agent");
+ assert_eq!(resume.argv, vec!["prime-agent", "--resume", "a"]);
+
+ state.handle_app_event(AppEvent::AgentResumeReported {
+ pane_id,
+ source: "someone-else".into(),
+ agent_label: "other".into(),
+ seq: None,
+ argv: vec!["other".into()],
+ });
+ assert_eq!(
+ state.terminals[&terminal_id]
+ .reported_resume()
+ .unwrap()
+ .argv,
+ vec!["prime-agent", "--resume", "a"]
+ );
+
+ state.handle_app_event(AppEvent::HookAgentReleased {
+ pane_id,
+ source: "prime-agent".into(),
+ agent_label: "prime-agent".into(),
+ known_agent: None,
+ seq: Some(2),
+ });
+ assert!(state.terminals[&terminal_id].reported_resume().is_none());
+ }
+
+ #[test]
+ fn reported_resume_is_dropped_and_saved_when_another_agent_takes_the_pane() {
+ let mut state = app_with_workspaces(&["one"]);
+ let (pane_id, terminal_id) = first_pane_terminal(&state);
+ report_custom_agent_with_resume(&mut state, pane_id, &["prime-agent", "--resume", "a"]);
+ state.session_dirty = false;
+
+ state.handle_app_event(AppEvent::HookStateReported {
+ pane_id,
+ source: "custom:other".into(),
+ agent_label: "other".into(),
+ state: AgentState::Working,
+ message: None,
+ seq: None,
+ session_ref: None,
+ });
+
+ assert!(state.terminals[&terminal_id].reported_resume().is_none());
+ assert!(state.session_dirty);
+ }
+
+ #[test]
+ fn reported_resume_of_recognized_agent_is_dropped_when_its_process_exits() {
+ let mut state = app_with_workspaces(&["one"]);
+ let (pane_id, terminal_id) = first_pane_terminal(&state);
+ state.handle_app_event(AppEvent::AgentProcessDetected {
+ pane_id,
+ agent: Agent::Pi,
+ observed_at: std::time::Instant::now(),
+ });
+ state.handle_app_event(AppEvent::AgentResumeReported {
+ pane_id,
+ source: "herdr:pi".into(),
+ agent_label: "pi".into(),
+ seq: None,
+ argv: vec!["pi".into(), "--continue".into()],
+ });
+ assert!(state.terminals[&terminal_id].reported_resume().is_some());
+ state.session_dirty = false;
+
+ state.handle_app_event(AppEvent::StateChanged {
+ pane_id,
+ agent: Some(Agent::Pi),
+ state: AgentState::Idle,
+ visible_blocker: false,
+ visible_working: false,
+ process_exited: true,
+ observed_at: std::time::Instant::now(),
+ });
+
+ assert!(state.terminals[&terminal_id].reported_resume().is_none());
+ assert!(state.session_dirty);
+
+ state.handle_app_event(AppEvent::AgentResumeReported {
+ pane_id,
+ source: "herdr:pi".into(),
+ agent_label: "pi".into(),
+ seq: None,
+ argv: vec!["pi".into(), "--continue".into()],
+ });
+ assert!(
+ state.terminals[&terminal_id].reported_resume().is_none(),
+ "a late report must not revive an exited agent"
+ );
+ }
+
+ #[test]
+ fn shell_return_drops_self_reported_agent_and_its_resume() {
+ let mut state = app_with_workspaces(&["one"]);
+ let (pane_id, terminal_id) = first_pane_terminal(&state);
+ report_custom_agent_with_resume(&mut state, pane_id, &["prime-agent", "--resume", "a"]);
+ assert!(state.terminals[&terminal_id].self_reported_agent_active());
+
+ state.handle_app_event(AppEvent::ReportedAgentShellReturned {
+ pane_id,
+ observed_at: std::time::Instant::now(),
+ });
+
+ let terminal = &state.terminals[&terminal_id];
+ assert_eq!(terminal.effective_agent_label(), None);
+ assert!(terminal.reported_resume().is_none());
+ assert!(!terminal.self_reported_agent_active());
+ }
+
+ #[test]
+ fn delayed_shell_return_keeps_an_agent_that_claimed_the_pane_afterwards() {
+ let mut state = app_with_workspaces(&["one"]);
+ let (pane_id, terminal_id) = first_pane_terminal(&state);
+ let shell_seen_idle_at = std::time::Instant::now();
+ report_custom_agent_with_resume(&mut state, pane_id, &["prime-agent", "--resume", "b"]);
+
+ state.handle_app_event(AppEvent::ReportedAgentShellReturned {
+ pane_id,
+ observed_at: shell_seen_idle_at,
+ });
+
+ let terminal = &state.terminals[&terminal_id];
+ assert_eq!(terminal.effective_agent_label(), Some("prime-agent"));
+ assert!(terminal.reported_resume().is_some());
+ }
+
+ #[test]
+ fn shell_return_keeps_agents_herdr_recognizes_by_process() {
+ let mut state = app_with_workspaces(&["one"]);
+ let (pane_id, terminal_id) = first_pane_terminal(&state);
+ state.handle_app_event(AppEvent::AgentProcessDetected {
+ pane_id,
+ agent: Agent::Pi,
+ observed_at: std::time::Instant::now(),
+ });
+ state.handle_app_event(AppEvent::HookStateReported {
+ pane_id,
+ source: "herdr:pi".into(),
+ agent_label: "pi".into(),
+ state: AgentState::Working,
+ message: None,
+ seq: Some(1),
+ session_ref: None,
+ });
+ assert!(!state.terminals[&terminal_id].self_reported_agent_active());
+
+ state.handle_app_event(AppEvent::ReportedAgentShellReturned {
+ pane_id,
+ observed_at: std::time::Instant::now(),
+ });
+
+ assert_eq!(
+ state.terminals[&terminal_id].effective_agent_label(),
+ Some("pi")
+ );
+ }
+
#[test]
fn hook_reported_unknown_agent_sets_toast_title_from_label() {
let mut state = app_with_workspaces(&["active", "background"]);
diff --git a/src/app/api.rs b/src/app/api.rs
index 62c8a6b5..20ca9d0f 100644
--- a/src/app/api.rs
+++ b/src/app/api.rs
@@ -462,6 +462,7 @@ impl App {
runtime.set_full_lifecycle_authority_active(
terminal.full_lifecycle_hook_authority_active(),
);
+ runtime.set_self_reported_agent_active(terminal.self_reported_agent_active());
}
}
}
diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs
index 119d9298..e4e03e71 100644
--- a/src/app/api/panes.rs
+++ b/src/app/api/panes.rs
@@ -1549,28 +1549,44 @@ impl App {
id: String,
params: PaneReportAgentParams,
) -> String {
- let Some((_ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
+ let Some((ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
return pane_not_found(id, &params.pane_id);
};
let Some(agent_label) = normalize_reported_agent_label(&params.agent) else {
return invalid_agent(id);
};
+ if let Err(message) = validate_optional_resume_argv(params.resume_argv.as_deref()) {
+ return encode_error(id, "invalid_resume_argv", message);
+ }
+ let report_is_newer = self
+ .pane_terminal(ws_idx, pane_id)
+ .is_some_and(|terminal| terminal.hook_report_is_newer(&params.source, params.seq));
+ let session_ref = crate::agent_resume::session_ref_from_report(
+ &params.source,
+ &agent_label,
+ params.agent_session_id,
+ params.agent_session_path,
+ );
self.handle_internal_event(crate::events::AppEvent::HookStateReported {
pane_id,
- session_ref: crate::agent_resume::session_ref_from_report(
- &params.source,
- &agent_label,
- params.agent_session_id,
- params.agent_session_path,
- ),
- source: params.source,
- agent_label,
+ session_ref: session_ref.clone(),
+ source: params.source.clone(),
+ agent_label: agent_label.clone(),
state: detect_state_from_api(params.state),
message: params.message,
seq: params.seq,
});
-
- encode_success(id, ResponseResult::Ok {})
+ let applied =
+ report_is_newer && self.session_report_applied(ws_idx, pane_id, session_ref.as_ref());
+ self.report_agent_resume(
+ id,
+ ws_idx,
+ pane_id,
+ params.source,
+ agent_label,
+ params.seq.filter(|_| applied),
+ applied.then_some(params.resume_argv).flatten(),
+ )
}
pub(super) fn handle_pane_report_agent_session(
@@ -1578,28 +1594,100 @@ impl App {
id: String,
params: PaneReportAgentSessionParams,
) -> String {
- let Some((_ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
+ let Some((ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
return pane_not_found(id, &params.pane_id);
};
let Some(agent_label) = normalize_reported_agent_label(&params.agent) else {
return invalid_agent(id);
};
+ if let Err(message) = validate_optional_resume_argv(params.resume_argv.as_deref()) {
+ return encode_error(id, "invalid_resume_argv", message);
+ }
+ let report_is_newer = self
+ .pane_terminal(ws_idx, pane_id)
+ .is_some_and(|terminal| terminal.hook_report_is_newer(&params.source, params.seq));
+ let session_ref = crate::agent_resume::session_ref_from_report(
+ &params.source,
+ &agent_label,
+ params.agent_session_id,
+ params.agent_session_path,
+ );
self.handle_internal_event(crate::events::AppEvent::AgentSessionReported {
pane_id,
- session_ref: crate::agent_resume::session_ref_from_report(
- &params.source,
- &agent_label,
- params.agent_session_id,
- params.agent_session_path,
- ),
- source: params.source,
- agent_label,
+ session_ref: session_ref.clone(),
+ source: params.source.clone(),
+ agent_label: agent_label.clone(),
seq: params.seq,
session_start_source: crate::agent_resume::normalize_session_start_source(
params.session_start_source,
),
});
+ let applied =
+ report_is_newer && self.session_report_applied(ws_idx, pane_id, session_ref.as_ref());
+ self.report_agent_resume(
+ id,
+ ws_idx,
+ pane_id,
+ params.source,
+ agent_label,
+ params.seq.filter(|_| applied),
+ applied.then_some(params.resume_argv).flatten(),
+ )
+ }
+ /// A resume command belongs to the session it was reported with, so it is
+ /// kept only when Herdr accepted that session.
+ fn session_report_applied(
+ &self,
+ ws_idx: usize,
+ pane_id: crate::layout::PaneId,
+ session_ref: Option<&crate::agent_resume::AgentSessionRef>,
+ ) -> bool {
+ session_ref.is_none_or(|session_ref| {
+ self.pane_terminal(ws_idx, pane_id)
+ .is_some_and(|terminal| terminal.session_ref_is_current(session_ref))
+ })
+ }
+
+ fn pane_terminal(
+ &self,
+ ws_idx: usize,
+ pane_id: crate::layout::PaneId,
+ ) -> Option<&crate::terminal::TerminalState> {
+ let pane = self.state.workspaces.get(ws_idx)?.pane_state(pane_id)?;
+ self.state.terminals.get(&pane.attached_terminal_id)
+ }
+
+ fn report_agent_resume(
+ &mut self,
+ id: String,
+ ws_idx: usize,
+ pane_id: crate::layout::PaneId,
+ source: String,
+ agent_label: String,
+ seq: Option<u64>,
+ resume_argv: Option<Vec<String>>,
+ ) -> String {
+ let Some(argv) = resume_argv else {
+ return encode_success(id, ResponseResult::Ok {});
+ };
+ let can_record = self
+ .pane_terminal(ws_idx, pane_id)
+ .is_some_and(|terminal| terminal.can_record_reported_resume(&source, &agent_label));
+ if !can_record {
+ return encode_error(
+ id,
+ "resume_not_accepted",
+ "resume_argv requires the reporter to hold the pane; report its state with pane.report_agent first",
+ );
+ }
+ self.handle_internal_event(crate::events::AppEvent::AgentResumeReported {
+ pane_id,
+ source,
+ agent_label,
+ seq,
+ argv,
+ });
encode_success(id, ResponseResult::Ok {})
}
@@ -2214,6 +2302,10 @@ fn invalid_agent(id: String) -> String {
encode_error(id, "invalid_agent", "agent label must not be empty")
}
+fn validate_optional_resume_argv(argv: Option<&[String]>) -> Result<(), String> {
+ argv.map_or(Ok(()), crate::agent_resume::validate_resume_argv)
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/src/app/api/plugins/mod.rs b/src/app/api/plugins/mod.rs
index 35dbee2f..c9b37bfe 100644
--- a/src/app/api/plugins/mod.rs
+++ b/src/app/api/plugins/mod.rs
@@ -3328,6 +3328,7 @@ action = "missing"
seq: None,
agent_session_id: None,
agent_session_path: None,
+ resume_argv: None,
},
);