aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-24 02:49:33 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-24 02:50:58 +0300
commite608a75159fc5cca70485d83ffb8ee681c9da385 (patch)
tree5ffdec62d0bee9a798418d6414ecbddae7ff8ae5 /src
parent169a4fd90c20a00aec77d684ecec98b9f0f996cc (diff)
fix: make known-agent exit process-owned
refs #1792 refs #1648
Diffstat (limited to 'src')
-rw-r--r--src/agent_resume.rs2
-rw-r--r--src/app/actions.rs104
-rw-r--r--src/app/api.rs22
-rw-r--r--src/app/api/panes.rs142
-rw-r--r--src/integration/assets/hermes/__init__.py17
-rw-r--r--src/integration/assets/kilo/herdr-agent-state.js7
-rw-r--r--src/integration/assets/kimi/herdr-agent-state.ps14
-rw-r--r--src/integration/assets/kimi/herdr-agent-state.sh3
-rw-r--r--src/integration/assets/mastracode/herdr-agent-state.sh12
-rw-r--r--src/integration/assets/omp/herdr-agent-state.ts35
-rw-r--r--src/integration/assets/pi/herdr-agent-state.ts34
-rw-r--r--src/integration/mod.rs19
-rw-r--r--src/integration/targets.rs17
-rw-r--r--src/integration/tests.rs117
-rw-r--r--src/pane.rs122
-rw-r--r--src/persist/snapshot.rs30
-rw-r--r--src/server/headless.rs32
-rw-r--r--src/terminal/metadata.rs115
-rw-r--r--src/terminal/state.rs1899
19 files changed, 1719 insertions, 1014 deletions
diff --git a/src/agent_resume.rs b/src/agent_resume.rs
index facb9cf1..1cb0f9dd 100644
--- a/src/agent_resume.rs
+++ b/src/agent_resume.rs
@@ -203,7 +203,7 @@ pub fn dedupe_key(source: &str, agent: &str, session_ref: &AgentSessionRef) -> S
)
}
-fn is_official_agent_source(source: &str, agent: &str) -> bool {
+pub(crate) fn is_official_agent_source(source: &str, agent: &str) -> bool {
matches!(
(source, agent),
("herdr:claude", "claude")
diff --git a/src/app/actions.rs b/src/app/actions.rs
index b7d335b1..ff786846 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -2793,7 +2793,7 @@ impl AppState {
seq,
..
} => {
- if crate::agent_resume::is_reserved_native_state_source(&source, &agent_label) {
+ if crate::agent_resume::is_official_agent_source(&source, &agent_label) {
Vec::new()
} else {
self.update_terminal_state(pane_id, |terminal| {
@@ -2890,8 +2890,16 @@ impl AppState {
previous_state: change.previous_state,
previous_seen,
previous_presentation: change.previous_presentation.clone(),
- agent_label: change.agent_label.clone(),
- known_agent: change.known_agent,
+ agent_label: if agent_released {
+ change.previous_agent_label.clone()
+ } else {
+ change.agent_label.clone()
+ },
+ known_agent: if agent_released {
+ change.previous_known_agent
+ } else {
+ change.known_agent
+ },
state: change.state,
seen,
presentation: change.presentation.clone(),
@@ -3011,7 +3019,11 @@ impl AppState {
return None;
}
- let agent_label = change.agent_label.clone()?;
+ let agent_label = change
+ .agent_label
+ .clone()
+ .or_else(|| change.previous_agent_label.clone())?;
+ let known_agent = change.known_agent.or(change.previous_known_agent);
let kind = client_notification_kind.unwrap_or(match sound {
Some(crate::sound::Sound::Request) => ToastKind::NeedsAttention,
Some(crate::sound::Sound::Done) | None => ToastKind::Finished,
@@ -3024,7 +3036,7 @@ impl AppState {
pane_id,
workspace_id,
agent_label,
- change.known_agent,
+ known_agent,
kind,
change.state,
);
@@ -3036,7 +3048,7 @@ impl AppState {
pane_id,
workspace_id,
agent_label,
- known_agent: change.known_agent,
+ known_agent,
kind,
state: change.state,
deadline: {
@@ -3071,7 +3083,10 @@ impl AppState {
if terminal_state.state != expected_state {
return None;
}
- if terminal_state.effective_agent_label() != Some(agent_label.as_str()) {
+ if terminal_state
+ .effective_agent_label()
+ .is_some_and(|current| current != agent_label)
+ {
return None;
}
@@ -4978,7 +4993,7 @@ mod tests {
}
#[test]
- fn reserved_native_release_report_does_not_clear_screen_state() {
+ fn official_release_preserves_process_owned_agent_identity() {
let mut state = app_with_workspaces(&["active"]);
let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
let terminal_id = state.workspaces[0]
@@ -4990,24 +5005,51 @@ mod tests {
state.handle_app_event(AppEvent::StateChanged {
pane_id,
- agent: Some(Agent::Claude),
+ agent: Some(Agent::Pi),
state: AgentState::Working,
visible_blocker: false,
visible_working: true,
process_exited: false,
observed_at: std::time::Instant::now(),
});
- state.handle_app_event(AppEvent::HookAgentReleased {
+ let terminal = state.terminals.get_mut(&terminal_id).unwrap();
+ terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession {
+ source: "herdr:pi".into(),
+ agent: "pi".into(),
+ session_ref: crate::agent_resume::AgentSessionRef::path(
+ std::env::current_dir()
+ .unwrap()
+ .join("release-session.jsonl")
+ .display()
+ .to_string(),
+ )
+ .unwrap(),
+ });
+ terminal.set_hook_authority(
+ "herdr:pi".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ Some(1),
+ );
+ terminal.set_agent_name("reviewer".into());
+ state.session_dirty = false;
+
+ let updates = state.handle_app_event(AppEvent::HookAgentReleased {
pane_id,
- source: "herdr:claude".into(),
- agent_label: "claude".into(),
- known_agent: Some(Agent::Claude),
- seq: Some(1),
+ source: "herdr:pi".into(),
+ agent_label: "pi".into(),
+ known_agent: Some(Agent::Pi),
+ seq: Some(2),
});
- let terminal = state.terminals.get(&terminal_id).unwrap();
+ assert!(updates.is_empty());
+ let terminal = &state.terminals[&terminal_id];
assert_eq!(terminal.state, AgentState::Working);
- assert_eq!(terminal.detected_agent, Some(Agent::Claude));
+ assert_eq!(terminal.detected_agent, Some(Agent::Pi));
+ assert_eq!(terminal.agent_name.as_deref(), Some("reviewer"));
+ assert!(terminal.full_lifecycle_hook_authority_active());
+ assert!(!state.session_dirty);
}
#[test]
@@ -5081,7 +5123,7 @@ mod tests {
}
#[test]
- fn releasing_an_agent_alias_marks_the_session_dirty() {
+ fn custom_release_clears_report_owned_agent() {
let mut state = app_with_workspaces(&["active"]);
let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
let terminal_id = state.workspaces[0]
@@ -5089,21 +5131,29 @@ mod tests {
.unwrap()
.attached_terminal_id
.clone();
- let terminal = state.terminals.get_mut(&terminal_id).unwrap();
- terminal.set_detected_state(Some(Agent::Pi), AgentState::Working);
- terminal.set_agent_name("reviewer".into());
- state.session_dirty = false;
+ state
+ .terminals
+ .get_mut(&terminal_id)
+ .unwrap()
+ .set_hook_authority(
+ "custom:agent".into(),
+ "custom-agent".into(),
+ AgentState::Working,
+ None,
+ Some(1),
+ );
state.handle_app_event(AppEvent::HookAgentReleased {
pane_id,
- source: "herdr:pi".into(),
- agent_label: "pi".into(),
- known_agent: Some(Agent::Pi),
- seq: Some(1),
+ source: "custom:agent".into(),
+ agent_label: "custom-agent".into(),
+ known_agent: None,
+ seq: Some(2),
});
- assert!(state.terminals[&terminal_id].agent_name.is_none());
- assert!(state.session_dirty);
+ let terminal = &state.terminals[&terminal_id];
+ assert!(terminal.hook_authority.is_none());
+ assert_eq!(terminal.state, AgentState::Unknown);
}
#[test]
diff --git a/src/app/api.rs b/src/app/api.rs
index 0cf55fd5..02a61067 100644
--- a/src/app/api.rs
+++ b/src/app/api.rs
@@ -1529,7 +1529,7 @@ mod tests {
}
#[tokio::test]
- async fn agent_explain_reports_hook_only_full_lifecycle_authority() {
+ async fn agent_explain_rejects_hook_only_full_lifecycle_authority() {
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
let mut app = App::new(
&crate::config::Config::default(),
@@ -1567,17 +1567,7 @@ mod tests {
});
let response: serde_json::Value = serde_json::from_str(&response).unwrap();
- assert_eq!(response["result"]["type"], "agent_explain");
- assert_eq!(response["result"]["explain"]["agent"], "omp");
- assert_eq!(response["result"]["explain"]["state"], "working");
- assert_eq!(
- response["result"]["explain"]["screen_detection_skip_reason"],
- "full_lifecycle_hook_authority"
- );
- assert_eq!(
- response["result"]["explain"]["matched_rule"],
- serde_json::Value::Null
- );
+ assert_eq!(response["error"]["code"], "agent_not_found");
}
#[tokio::test]
@@ -1941,7 +1931,7 @@ mod tests {
}
#[test]
- fn stale_detector_exit_does_not_release_a_newer_hook_owned_agent() {
+ fn process_exit_releases_a_newer_hook_owned_agent() {
let event_hub = crate::api::EventHub::default();
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
let mut app = App::new(
@@ -1983,9 +1973,9 @@ mod tests {
});
let terminal = &app.state.terminals[&terminal_id];
- assert_eq!(terminal.state, AgentState::Working);
- assert_eq!(terminal.agent_name.as_deref(), Some("reviewer"));
- assert!(!event_hub.events_after(0).iter().any(|(_, event)| matches!(
+ assert_eq!(terminal.state, AgentState::Idle);
+ assert!(terminal.agent_name.is_none());
+ assert!(event_hub.events_after(0).iter().any(|(_, event)| matches!(
event.data,
crate::api::schema::EventData::PaneAgentDetected { released: true, .. }
)));
diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs
index 2da6d308..43206d1e 100644
--- a/src/app/api/panes.rs
+++ b/src/app/api/panes.rs
@@ -1357,9 +1357,21 @@ impl App {
let Some(terminal) = self.state.terminals.get_mut(&terminal_id) else {
return pane_not_found(id, &params.pane_id);
};
+ if terminal.metadata_report_blocked_by_process_exit(
+ &source,
+ agent_label.as_deref(),
+ applies_to_source.as_deref(),
+ ) {
+ return encode_success(id, ResponseResult::Ok {});
+ }
if !terminal.metadata_report_sequence_is_fresh(&source, params.seq) {
return encode_success(id, ResponseResult::Ok {});
}
+ let metadata_agent = crate::terminal::TerminalState::metadata_report_agent(
+ &source,
+ agent_label.as_deref(),
+ applies_to_source.as_deref(),
+ );
if let Some(tokens) = tokens.as_ref() {
if terminal.metadata_tokens.key_count_after_patch(tokens)
> MAX_METADATA_TOKEN_KEYS_PER_RESOURCE
@@ -1373,7 +1385,8 @@ impl App {
);
}
}
- match terminal.accept_metadata_report(&source, params.seq, tokens.is_some()) {
+ match terminal.accept_metadata_report(&source, params.seq, tokens.is_some(), metadata_agent)
+ {
Ok(true) => {}
Ok(false) => return encode_success(id, ResponseResult::Ok {}),
Err(()) => {
@@ -3819,6 +3832,133 @@ mod tests {
}
#[test]
+ fn pane_metadata_ignored_after_process_exit_does_not_poison_sequence() {
+ let (mut app, pane_id) = app_with_test_workspace();
+ let (_, internal_pane_id) = app.parse_pane_id(&pane_id).unwrap();
+ let terminal_id = app.state.workspaces[0]
+ .pane_state(internal_pane_id)
+ .unwrap()
+ .attached_terminal_id
+ .clone();
+ app.state
+ .terminals
+ .get_mut(&terminal_id)
+ .unwrap()
+ .set_detected_state(Some(Agent::Pi), AgentState::Idle);
+
+ let mut initial = metadata_params(pane_id.clone());
+ initial.source = "custom:pi-metadata".into();
+ initial.agent = Some("pi".into());
+ initial.seq = Some(100);
+ let response = app.handle_pane_report_metadata("initial".into(), initial);
+ let _: SuccessResponse = serde_json::from_str(&response).unwrap();
+
+ let mut initial_tokens = metadata_params(pane_id.clone());
+ initial_tokens.source = "custom:pi-tokens".into();
+ initial_tokens.agent = Some("pi".into());
+ initial_tokens.title = None;
+ initial_tokens.tokens =
+ std::collections::HashMap::from([("generation".into(), Some("old".into()))]);
+ initial_tokens.seq = Some(100);
+ let response = app.handle_pane_report_metadata("initial-tokens".into(), initial_tokens);
+ let _: SuccessResponse = serde_json::from_str(&response).unwrap();
+
+ let exit_at = std::time::Instant::now() + std::time::Duration::from_millis(1);
+ app.state
+ .terminals
+ .get_mut(&terminal_id)
+ .unwrap()
+ .set_detected_state_with_screen_signals_at(
+ Some(Agent::Pi),
+ AgentState::Idle,
+ false,
+ false,
+ false,
+ true,
+ exit_at,
+ );
+ app.state
+ .terminals
+ .get_mut(&terminal_id)
+ .unwrap()
+ .set_detected_state_with_screen_signals_at(
+ None,
+ AgentState::Unknown,
+ false,
+ false,
+ false,
+ false,
+ exit_at + std::time::Duration::from_millis(1),
+ );
+
+ let mut stale = metadata_params(pane_id.clone());
+ stale.source = "custom:pi-metadata".into();
+ stale.agent = Some("pi".into());
+ stale.title = Some("stale".into());
+ stale.seq = Some(200);
+ let response = app.handle_pane_report_metadata("stale".into(), stale);
+ let _: SuccessResponse = serde_json::from_str(&response).unwrap();
+
+ let mut official = metadata_params(pane_id.clone());
+ official.source = "herdr:pi".into();
+ official.seq = Some(200);
+ let response = app.handle_pane_report_metadata("official".into(), official);
+ let _: SuccessResponse = serde_json::from_str(&response).unwrap();
+
+ let terminal = &app.state.terminals[&terminal_id];
+ assert!(terminal.metadata_report_sequence_is_fresh("custom:pi-metadata", Some(1)));
+ assert!(terminal.metadata_report_sequence_is_fresh("custom:pi-tokens", Some(1)));
+ assert!(terminal.metadata_report_sequence_is_fresh("herdr:pi", Some(1)));
+
+ app.state
+ .terminals
+ .get_mut(&terminal_id)
+ .unwrap()
+ .set_detected_state_with_screen_signals_at(
+ Some(Agent::Pi),
+ AgentState::Idle,
+ false,
+ false,
+ false,
+ false,
+ exit_at + std::time::Duration::from_millis(2),
+ );
+ let mut fresh = metadata_params(pane_id.clone());
+ fresh.source = "custom:pi-metadata".into();
+ fresh.agent = Some("pi".into());
+ fresh.title = Some("fresh".into());
+ fresh.seq = Some(1);
+ let response = app.handle_pane_report_metadata("fresh".into(), fresh);
+ let _: SuccessResponse = serde_json::from_str(&response).unwrap();
+
+ let mut fresh_tokens = metadata_params(pane_id);
+ fresh_tokens.source = "custom:pi-tokens".into();
+ fresh_tokens.agent = Some("pi".into());
+ fresh_tokens.title = None;
+ fresh_tokens.tokens =
+ std::collections::HashMap::from([("generation".into(), Some("new".into()))]);
+ fresh_tokens.seq = Some(1);
+ let response = app.handle_pane_report_metadata("fresh-tokens".into(), fresh_tokens);
+ let _: SuccessResponse = serde_json::from_str(&response).unwrap();
+
+ let terminal = &app.state.terminals[&terminal_id];
+ assert_eq!(
+ terminal.agent_metadata["custom:pi-metadata"]
+ .title
+ .as_deref(),
+ Some("fresh")
+ );
+ assert_eq!(
+ terminal
+ .metadata_tokens
+ .values()
+ .get("generation")
+ .map(String::as_str),
+ Some("new")
+ );
+ }
+
+ #[test]
fn pane_report_metadata_accepts_documented_source_chars_and_max_ttl() {
let (mut app, pane_id) = app_with_test_workspace();
let mut params = metadata_params(pane_id);
diff --git a/src/integration/assets/hermes/__init__.py b/src/integration/assets/hermes/__init__.py
index e6f6971d..5474e5e3 100644
--- a/src/integration/assets/hermes/__init__.py
+++ b/src/integration/assets/hermes/__init__.py
@@ -1,7 +1,7 @@
"""Hermes plugin installed by Herdr to report agent lifecycle state."""
# HERDR_INTEGRATION_ID=hermes
-# HERDR_INTEGRATION_VERSION=3
+# HERDR_INTEGRATION_VERSION=4
from __future__ import annotations
@@ -71,6 +71,19 @@ def _report(state: str, **kwargs) -> None:
_send("pane.report_agent", params)
+def _session_started(**kwargs) -> None:
+ session_id = _session_id(kwargs)
+ if session_id:
+ _send(
+ "pane.report_agent_session",
+ {
+ "agent_session_id": session_id,
+ "session_start_source": "startup",
+ },
+ )
+ _idle(**kwargs)
+
+
def _working(**kwargs) -> None:
_report("working", **kwargs)
@@ -84,7 +97,7 @@ def _idle(**kwargs) -> None:
def register(ctx):
- ctx.register_hook("on_session_start", _idle)
+ ctx.register_hook("on_session_start", _session_started)
ctx.register_hook("pre_llm_call", _working)
ctx.register_hook("pre_api_request", _working)
ctx.register_hook("pre_tool_call", _working)
diff --git a/src/integration/assets/kilo/herdr-agent-state.js b/src/integration/assets/kilo/herdr-agent-state.js
index e8600ab1..8071670c 100644
--- a/src/integration/assets/kilo/herdr-agent-state.js
+++ b/src/integration/assets/kilo/herdr-agent-state.js
@@ -2,7 +2,7 @@
// managed by herdr; reinstalling or updating the integration overwrites this file.
// add custom hooks/plugins beside this file instead of editing it.
// HERDR_INTEGRATION_ID=kilo
-// HERDR_INTEGRATION_VERSION=3
+// HERDR_INTEGRATION_VERSION=4
import net from "node:net";
@@ -88,7 +88,10 @@ function reportSession(sessionID) {
if (!sessionID) {
return Promise.resolve();
}
- return request("pane.report_agent_session", { agent_session_id: sessionID });
+ return request("pane.report_agent_session", {
+ agent_session_id: sessionID,
+ session_start_source: "startup",
+ });
}
function reportState(state, sessionID) {
diff --git a/src/integration/assets/kimi/herdr-agent-state.ps1 b/src/integration/assets/kimi/herdr-agent-state.ps1
index 16c9eb4d..c298dda4 100644
--- a/src/integration/assets/kimi/herdr-agent-state.ps1
+++ b/src/integration/assets/kimi/herdr-agent-state.ps1
@@ -2,7 +2,7 @@
# managed by herdr; reinstalling or updating the integration overwrites this file.
# add custom hooks beside this file instead of editing it.
# HERDR_INTEGRATION_ID=kimi
-# HERDR_INTEGRATION_VERSION=5
+# HERDR_INTEGRATION_VERSION=6
param([string]$Action = "")
@@ -23,7 +23,7 @@ $sessionId = if ($null -ne $payload -and -not [string]::IsNullOrWhiteSpace($payl
try {
if ($Action -eq "session") {
if ([string]::IsNullOrWhiteSpace($sessionId)) { exit 0 }
- & herdr pane report-agent-session $env:HERDR_PANE_ID --source herdr:kimi --agent kimi --agent-session-id $sessionId --seq $seq 2>$null | Out-Null
+ & herdr pane report-agent-session $env:HERDR_PANE_ID --source herdr:kimi --agent kimi --agent-session-id $sessionId --session-start-source startup --seq $seq 2>$null | Out-Null
} else {
if ([string]::IsNullOrWhiteSpace($sessionId)) {
& herdr pane report-agent $env:HERDR_PANE_ID --source herdr:kimi --agent kimi --state $Action --seq $seq 2>$null | Out-Null
diff --git a/src/integration/assets/kimi/herdr-agent-state.sh b/src/integration/assets/kimi/herdr-agent-state.sh
index 7dac1b49..e919300f 100644
--- a/src/integration/assets/kimi/herdr-agent-state.sh
+++ b/src/integration/assets/kimi/herdr-agent-state.sh
@@ -1,7 +1,7 @@
#!/bin/sh
# managed by herdr; reinstalling the integration replaces this file.
# HERDR_INTEGRATION_ID=kimi
-# HERDR_INTEGRATION_VERSION=5
+# HERDR_INTEGRATION_VERSION=6
action="${1:-}"
case "$action" in
@@ -42,6 +42,7 @@ if action == "session":
if session_id is None:
raise SystemExit(0)
method = "pane.report_agent_session"
+ params["session_start_source"] = "startup"
else:
method = "pane.report_agent"
params["state"] = action
diff --git a/src/integration/assets/mastracode/herdr-agent-state.sh b/src/integration/assets/mastracode/herdr-agent-state.sh
index b8e0a26a..39a8461b 100644
--- a/src/integration/assets/mastracode/herdr-agent-state.sh
+++ b/src/integration/assets/mastracode/herdr-agent-state.sh
@@ -3,7 +3,7 @@
# managed by herdr; reinstalling or updating the integration overwrites this file.
# add custom hooks beside this file instead of editing it.
# HERDR_INTEGRATION_ID=mastracode
-# HERDR_INTEGRATION_VERSION=1
+# HERDR_INTEGRATION_VERSION=2
set -eu
@@ -13,7 +13,7 @@ trap 'rm -f "$hook_input_file"' EXIT HUP INT TERM
cat >"$hook_input_file" 2>/dev/null || true
case "$action" in
- working|idle|blocked|release) ;;
+ session|working|idle|blocked) ;;
*) exit 0 ;;
esac
@@ -55,14 +55,18 @@ if isinstance(session_id, str) and session_id:
agent_session_id = session_id
else:
agent_session_id = None
-if action == "release":
+if action == "session":
+ if not agent_session_id:
+ raise SystemExit(0)
request = {
"id": request_id,
- "method": "pane.release_agent",
+ "method": "pane.report_agent_session",
"params": {
"pane_id": pane_id,
"source": source,
"agent": "mastracode",
+ "agent_session_id": agent_session_id,
+ "session_start_source": "startup",
"seq": report_seq,
},
}
diff --git a/src/integration/assets/omp/herdr-agent-state.ts b/src/integration/assets/omp/herdr-agent-state.ts
index 350cdea7..b3f88c93 100644
--- a/src/integration/assets/omp/herdr-agent-state.ts
+++ b/src/integration/assets/omp/herdr-agent-state.ts
@@ -2,7 +2,7 @@
// managed by herdr; reinstalling or updating the integration overwrites this file.
// add custom hooks/plugins beside this file instead of editing it.
// HERDR_INTEGRATION_ID=omp
-// HERDR_INTEGRATION_VERSION=6
+// HERDR_INTEGRATION_VERSION=7
// @ts-nocheck
import net from "node:net";
@@ -168,29 +168,6 @@ function sendState(state: AgentState, message?: string, seq = nextReportSeq()):
});
}
-function releaseAgent(): Promise<void> {
- return sendRequest({
- id: `${source}:release:${Date.now()}:${Math.random().toString(36).slice(2)}`,
- method: "pane.release_agent",
- params: {
- pane_id: paneId,
- source,
- agent: "omp",
- seq: nextReportSeq(),
- },
- });
-}
-
-function shouldReleaseOnSessionShutdown(event: any): boolean {
- // OMP tears down and rebinds extension runtimes for internal lifecycle actions
- // such as /reload, /new, /resume, and /fork. Those do not mean the pane's
- // agent process has exited, and releasing hook authority there can suppress
- // legitimate reports from the replacement runtime. Only a user/process quit
- // should release Herdr's full-lifecycle authority.
- const reason = event?.reason;
- return reason === "quit";
-}
-
let sendInFlight = false;
let queuedState: QueuedState | undefined;
@@ -470,13 +447,9 @@ export default function (pi) {
scheduleIdle();
});
- pi.on("session_shutdown", async (event) => {
- if (!rootSession) {
- return;
- }
- clearPendingTimers();
- if (shouldReleaseOnSessionShutdown(event)) {
- await releaseAgent();
+ pi.on("session_shutdown", () => {
+ if (rootSession) {
+ clearPendingTimers();
}
});
}
diff --git a/src/integration/assets/pi/herdr-agent-state.ts b/src/integration/assets/pi/herdr-agent-state.ts
index caa50ed9..2e79c7c6 100644
--- a/src/integration/assets/pi/herdr-agent-state.ts
+++ b/src/integration/assets/pi/herdr-agent-state.ts
@@ -2,7 +2,7 @@
// managed by herdr; reinstalling or updating the integration overwrites this file.
// add custom hooks/plugins beside this file instead of editing it.
// HERDR_INTEGRATION_ID=pi
-// HERDR_INTEGRATION_VERSION=6
+// HERDR_INTEGRATION_VERSION=7
// @ts-nocheck
import net from "node:net";
@@ -142,29 +142,6 @@ function sendState(state: AgentState, message?: string, seq = nextReportSeq()):
});
}
-function releaseAgent(): Promise<void> {
- return sendRequest({
- id: `${source}:release:${Date.now()}:${Math.random().toString(36).slice(2)}`,
- method: "pane.release_agent",
- params: {
- pane_id: paneId,
- source,
- agent: "pi",
- seq: nextReportSeq(),
- },
- });
-}
-
-function shouldReleaseOnSessionShutdown(event: any): boolean {
- // Pi tears down and rebinds extension runtimes for internal lifecycle actions
- // such as /reload, /new, /resume, and /fork. Those do not mean the pane's
- // agent process has exited, and releasing hook authority there can suppress
- // legitimate reports from the replacement runtime. Only a user/process quit
- // should release Herdr's full-lifecycle authority.
- const reason = event?.reason;
- return reason === "quit";
-}
-
let sendInFlight = false;
let queuedState: QueuedState | undefined;
@@ -275,13 +252,4 @@ export default function (pi) {
agentActive = false;
publishState();
});
-
- pi.on("session_shutdown", async (event) => {
- if (!rootSession) {
- return;
- }
- if (shouldReleaseOnSessionShutdown(event)) {
- await releaseAgent();
- }
- });
}
diff --git a/src/integration/mod.rs b/src/integration/mod.rs
index 73c23fc6..d7bd8b3f 100644
--- a/src/integration/mod.rs
+++ b/src/integration/mod.rs
@@ -22,10 +22,10 @@ pub(crate) use types::{IntegrationRecommendation, IntegrationStatus, Integration
const PI_EXTENSION_INSTALL_NAME: &str = "herdr-agent-state.ts";
const PI_EXTENSION_ASSET: &str = include_str!("assets/pi/herdr-agent-state.ts");
-const PI_INTEGRATION_VERSION: u32 = 6;
+const PI_INTEGRATION_VERSION: u32 = 7;
const OMP_EXTENSION_INSTALL_NAME: &str = "herdr-omp-agent-state.ts";
const OMP_EXTENSION_ASSET: &str = include_str!("assets/omp/herdr-agent-state.ts");
-const OMP_INTEGRATION_VERSION: u32 = 6;
+const OMP_INTEGRATION_VERSION: u32 = 7;
const CLAUDE_HOOK_INSTALL_NAME: &str = if cfg!(windows) {
"herdr-agent-state.ps1"
} else {
@@ -58,7 +58,7 @@ const KIMI_HOOK_ASSET: &str = if cfg!(windows) {
} else {
include_str!("assets/kimi/herdr-agent-state.sh")
};
-const KIMI_INTEGRATION_VERSION: u32 = 5;
+const KIMI_INTEGRATION_VERSION: u32 = 6;
const KIMI_CONFIG_BLOCK_BEGIN: &str = "# >>> herdr kimi integration";
const KIMI_CONFIG_BLOCK_END: &str = "# <<< herdr kimi integration";
const KIMI_MIN_VERSION: &str = "0.14.0";
@@ -160,13 +160,13 @@ const OPENCODE_PLUGIN_ASSET: &str = include_str!("assets/opencode/herdr-agent-st
const OPENCODE_INTEGRATION_VERSION: u32 = 9;
const KILO_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state.js";
const KILO_PLUGIN_ASSET: &str = include_str!("assets/kilo/herdr-agent-state.js");
-const KILO_INTEGRATION_VERSION: u32 = 3;
+const KILO_INTEGRATION_VERSION: u32 = 4;
const HERMES_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state";
const HERMES_PLUGIN_MANIFEST_INSTALL_NAME: &str = "plugin.yaml";
const HERMES_PLUGIN_INIT_INSTALL_NAME: &str = "__init__.py";
const HERMES_PLUGIN_MANIFEST_ASSET: &str = include_str!("assets/hermes/plugin.yaml");
const HERMES_PLUGIN_INIT_ASSET: &str = include_str!("assets/hermes/__init__.py");
-const HERMES_INTEGRATION_VERSION: u32 = 3;
+const HERMES_INTEGRATION_VERSION: u32 = 4;
const QODERCLI_HOOK_INSTALL_NAME: &str = if cfg!(windows) {
"herdr-agent-state.ps1"
} else {
@@ -198,10 +198,12 @@ const CURSOR_HOOK_ASSET: &str = include_str!("assets/cursor/herdr-agent-state.sh
const CURSOR_INTEGRATION_VERSION: u32 = 1;
const MASTRACODE_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh";
const MASTRACODE_HOOK_ASSET: &str = include_str!("assets/mastracode/herdr-agent-state.sh");
-const MASTRACODE_INTEGRATION_VERSION: u32 = 1;
+const MASTRACODE_INTEGRATION_VERSION: u32 = 2;
const MASTRACODE_HOOK_TIMEOUT_MS: u64 = 10_000;
-const MASTRACODE_HOOK_EVENTS: [(&str, &str); 12] = [
- ("SessionStart", "idle"),
+const MASTRACODE_REMOVED_HOOK_EVENTS: [(&str, &str); 2] =
+ [("SessionStart", "idle"), ("SessionEnd", "release")];
+const MASTRACODE_HOOK_EVENTS: [(&str, &str); 11] = [
+ ("SessionStart", "session"),
("UserPromptSubmit", "working"),
("AgentStart", "working"),
("PreToolUse", "working"),
@@ -212,7 +214,6 @@ const MASTRACODE_HOOK_EVENTS: [(&str, &str); 12] = [
("Interrupt", "idle"),
("AgentEnd", "idle"),
("Stop", "idle"),
- ("SessionEnd", "release"),
];
const INTEGRATION_VERSION_MARKER: &str = "HERDR_INTEGRATION_VERSION=";
diff --git a/src/integration/targets.rs b/src/integration/targets.rs
index 5d5e730c..488b7032 100644
--- a/src/integration/targets.rs
+++ b/src/integration/targets.rs
@@ -39,10 +39,11 @@ use super::{
HERMES_PLUGIN_INIT_INSTALL_NAME, HERMES_PLUGIN_MANIFEST_ASSET,
HERMES_PLUGIN_MANIFEST_INSTALL_NAME, KILO_PLUGIN_ASSET, KILO_PLUGIN_INSTALL_NAME,
KIMI_HOOK_ASSET, KIMI_HOOK_INSTALL_NAME, MASTRACODE_HOOK_ASSET, MASTRACODE_HOOK_EVENTS,
- MASTRACODE_HOOK_INSTALL_NAME, MASTRACODE_HOOK_TIMEOUT_MS, OMP_EXTENSION_ASSET,
- OMP_EXTENSION_INSTALL_NAME, OPENCODE_PLUGIN_ASSET, OPENCODE_PLUGIN_INSTALL_NAME,
- PI_EXTENSION_ASSET, PI_EXTENSION_INSTALL_NAME, QODERCLI_HOOK_ASSET, QODERCLI_HOOK_EVENTS,
- QODERCLI_HOOK_INSTALL_NAME, QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS,
+ MASTRACODE_HOOK_INSTALL_NAME, MASTRACODE_HOOK_TIMEOUT_MS, MASTRACODE_REMOVED_HOOK_EVENTS,
+ OMP_EXTENSION_ASSET, OMP_EXTENSION_INSTALL_NAME, OPENCODE_PLUGIN_ASSET,
+ OPENCODE_PLUGIN_INSTALL_NAME, PI_EXTENSION_ASSET, PI_EXTENSION_INSTALL_NAME,
+ QODERCLI_HOOK_ASSET, QODERCLI_HOOK_EVENTS, QODERCLI_HOOK_INSTALL_NAME,
+ QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS,
};
fn ensure_extension_dir(dir: &Path, agent: &str) -> io::Result<()> {
@@ -1133,6 +1134,9 @@ pub(crate) fn install_mastracode() -> io::Result<MastracodeInstallPaths> {
})?;
let quoted_hook_path = shell_single_quote(&hook_path.display().to_string());
+ for (event, action) in MASTRACODE_REMOVED_HOOK_EVENTS {
+ remove_flat_command_hook(hooks, event, &format!("bash {quoted_hook_path} {action}"))?;
+ }
for (event, action) in MASTRACODE_HOOK_EVENTS {
ensure_flat_command_hook(
hooks,
@@ -1171,7 +1175,10 @@ pub(crate) fn uninstall_mastracode() -> io::Result<MastracodeUninstallResult> {
})?;
let quoted_hook_path = shell_single_quote(&hook_path.display().to_string());
- for (event, action) in MASTRACODE_HOOK_EVENTS {
+ for (event, action) in MASTRACODE_HOOK_EVENTS
+ .into_iter()
+ .chain(MASTRACODE_REMOVED_HOOK_EVENTS)
+ {
updated_hooks |= remove_flat_command_hook(
hooks,
event,
diff --git a/src/integration/tests.rs b/src/integration/tests.rs
index 78b728f9..076a22b6 100644
--- a/src/integration/tests.rs
+++ b/src/integration/tests.rs
@@ -2667,8 +2667,7 @@ fn bundled_integration_assets_report_session_refs() {
assert!(PI_EXTENSION_ASSET.contains("pane.report_agent\""));
assert!(PI_EXTENSION_ASSET.contains("pi.on(\"agent_start\""));
assert!(PI_EXTENSION_ASSET.contains("pi.on(\"agent_settled\""));
- assert!(PI_EXTENSION_ASSET.contains("pane.release_agent"));
- assert!(PI_EXTENSION_ASSET.contains("pi.on(\"session_shutdown\""));
+ assert!(!PI_EXTENSION_ASSET.contains("pi.on(\"session_shutdown\""));
assert!(OMP_EXTENSION_ASSET.contains("agent_session_path"));
assert!(OMP_EXTENSION_ASSET.contains("agent_session_id"));
assert!(OMP_EXTENSION_ASSET.contains("ctx?.hasUI !== true"));
@@ -2676,7 +2675,6 @@ fn bundled_integration_assets_report_session_refs() {
assert!(OMP_EXTENSION_ASSET.contains("pane.report_agent\""));
assert!(OMP_EXTENSION_ASSET.contains("pi.on(\"agent_start\""));
assert!(OMP_EXTENSION_ASSET.contains("pi.on(\"agent_end\""));
- assert!(OMP_EXTENSION_ASSET.contains("pane.release_agent"));
assert!(OMP_EXTENSION_ASSET.contains("pi.on(\"session_shutdown\""));
assert!(
CLAUDE_HOOK_ASSET.contains("agent_session_id")
@@ -2718,6 +2716,7 @@ fn bundled_integration_assets_report_session_refs() {
assert!(KIMI_HOOK_ASSET.contains("source\": \"herdr:kimi"));
assert!(KIMI_HOOK_ASSET.contains("agent_session_id"));
assert!(KIMI_HOOK_ASSET.contains("method = \"pane.report_agent_session\""));
+ assert!(KIMI_HOOK_ASSET.contains("params[\"session_start_source\"] = \"startup\""));
assert!(KIMI_HOOK_ASSET.contains("method = \"pane.report_agent\""));
assert!(KIMI_HOOK_ASSET.contains("params[\"state\"] = action"));
assert!(!KIMI_HOOK_ASSET.contains("pane.release_agent"));
@@ -2743,10 +2742,13 @@ fn bundled_integration_assets_report_session_refs() {
assert!(KILO_PLUGIN_ASSET.contains("SOURCE = \"herdr:kilo\""));
assert!(KILO_PLUGIN_ASSET.contains("AGENT = \"kilo\""));
assert!(KILO_PLUGIN_ASSET.contains("pane.report_agent_session"));
+ assert!(KILO_PLUGIN_ASSET.contains("session_start_source: \"startup\""));
assert!(KILO_PLUGIN_ASSET.contains("reportState"));
assert!(!KILO_PLUGIN_ASSET.contains("pane.release_agent"));
assert!(HERMES_PLUGIN_INIT_ASSET.contains("session_id = _session_id(kwargs)"));
assert!(HERMES_PLUGIN_INIT_ASSET.contains("agent_session_id"));
+ assert!(HERMES_PLUGIN_INIT_ASSET.contains("pane.report_agent_session\","));
+ assert!(HERMES_PLUGIN_INIT_ASSET.contains("\"session_start_source\": \"startup\""));
assert!(HERMES_PLUGIN_INIT_ASSET.contains("pane.report_agent\","));
assert!(HERMES_PLUGIN_INIT_ASSET.contains("on_session_end"));
assert!(!HERMES_PLUGIN_INIT_ASSET.contains("on_session_finalize"));
@@ -2768,33 +2770,30 @@ fn bundled_integration_assets_report_session_refs() {
assert!(!CURSOR_HOOK_ASSET.contains("\"state\":"));
assert!(!CURSOR_HOOK_ASSET.contains("pane.release_agent"));
assert!(MASTRACODE_HOOK_ASSET.contains("HERDR_INTEGRATION_ID=mastracode"));
- assert!(MASTRACODE_HOOK_ASSET.contains("HERDR_INTEGRATION_VERSION=1"));
+ assert!(MASTRACODE_HOOK_ASSET.contains("HERDR_INTEGRATION_VERSION=2"));
assert!(MASTRACODE_HOOK_ASSET.contains("session_id"));
assert!(!MASTRACODE_HOOK_ASSET.contains("run_id"));
assert!(MASTRACODE_HOOK_ASSET.contains("agent_session_id"));
+ assert!(MASTRACODE_HOOK_ASSET.contains("pane.report_agent_session"));
+ assert!(MASTRACODE_HOOK_ASSET.contains("session_start_source"));
assert!(MASTRACODE_HOOK_ASSET.contains("pane.report_agent"));
- assert!(MASTRACODE_HOOK_ASSET.contains("pane.release_agent"));
}
#[test]
-fn pi_extension_releases_only_for_quit_session_shutdown() {
- let release_policy = PI_EXTENSION_ASSET
- .find("function shouldReleaseOnSessionShutdown")
- .expect("pi extension should centralize session shutdown release policy");
- let quit_check = PI_EXTENSION_ASSET
- .find("reason === \"quit\"")
- .expect("pi extension should release only for true quit shutdowns");
- let shutdown_handler = PI_EXTENSION_ASSET
- .find("pi.on(\"session_shutdown\", async (event)")
- .expect("pi extension should inspect the session_shutdown event");
- let guarded_release = PI_EXTENSION_ASSET[shutdown_handler..]
- .find("if (shouldReleaseOnSessionShutdown(event))")
- .expect("pi extension should guard releaseAgent by shutdown reason");
-
- assert!(release_policy < shutdown_handler);
- assert!(release_policy < quit_check);
- assert!(quit_check < shutdown_handler);
- assert!(guarded_release > 0);
+fn process_owned_integration_assets_do_not_report_release() {
+ for (name, asset) in [
+ ("pi", PI_EXTENSION_ASSET),
+ ("omp", OMP_EXTENSION_ASSET),
+ ("mastracode", MASTRACODE_HOOK_ASSET),
+ ("kimi", KIMI_HOOK_ASSET),
+ ("kilo", KILO_PLUGIN_ASSET),
+ ("hermes", HERMES_PLUGIN_INIT_ASSET),
+ ] {
+ assert!(
+ !asset.contains("pane.release_agent"),
+ "{name} process exit should own lifecycle release"
+ );
+ }
}
#[test]
@@ -2818,27 +2817,6 @@ fn pi_extension_refreshes_session_ref_before_agent_start_state() {
}
#[test]
-fn omp_extension_releases_only_for_quit_session_shutdown() {
- let release_policy = OMP_EXTENSION_ASSET
- .find("function shouldReleaseOnSessionShutdown")
- .expect("omp extension should centralize session shutdown release policy");
- let quit_check = OMP_EXTENSION_ASSET
- .find("reason === \"quit\"")
- .expect("omp extension should release only for true quit shutdowns");
- let shutdown_handler = OMP_EXTENSION_ASSET
- .find("pi.on(\"session_shutdown\", async (event)")
- .expect("omp extension should inspect the session_shutdown event");
- let guarded_release = OMP_EXTENSION_ASSET[shutdown_handler..]
- .find("if (shouldReleaseOnSessionShutdown(event))")
- .expect("omp extension should guard releaseAgent by shutdown reason");
-
- assert!(release_policy < shutdown_handler);
- assert!(release_policy < quit_check);
- assert!(quit_check < shutdown_handler);
- assert!(guarded_release > 0);
-}
-
-#[test]
fn omp_extension_refreshes_session_ref_before_agent_start_state() {
let agent_start = OMP_EXTENSION_ASSET
.find("pi.on(\"agent_start\", (_event, ctx)")
@@ -3354,6 +3332,57 @@ fn install_mastracode_writes_hook_and_updates_hooks_json() {
}
#[test]
+fn install_mastracode_removes_v1_lifecycle_hooks() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let original_home = std::env::var_os("HOME");
+ let mastracode_dir = base.join(".mastracode");
+ let hook_path = mastracode_dir
+ .join("hooks")
+ .join(MASTRACODE_HOOK_INSTALL_NAME);
+ fs::create_dir_all(&mastracode_dir).unwrap();
+ fs::write(
+ mastracode_dir.join("hooks.json"),
+ serde_json::to_string(&json!({
+ "SessionStart": [{
+ "type": "command",
+ "command": format!("bash '{}' idle", hook_path.display()),
+ "timeout": MASTRACODE_HOOK_TIMEOUT_MS
+ }],
+ "SessionEnd": [{
+ "type": "command",
+ "command": format!("bash '{}' release", hook_path.display()),
+ "timeout": MASTRACODE_HOOK_TIMEOUT_MS
+ }]
+ }))
+ .unwrap(),
+ )
+ .unwrap();
+ std::env::set_var("HOME", &base);
+
+ install_mastracode().unwrap();
+
+ let hooks_file: Value =
+ serde_json::from_str(&fs::read_to_string(mastracode_dir.join("hooks.json")).unwrap())
+ .unwrap();
+ let hooks = hooks_file.as_object().unwrap();
+ assert!(!hooks.contains_key("SessionEnd"));
+ let session_start = hooks["SessionStart"].as_array().unwrap();
+ assert_eq!(session_start.len(), 1);
+ assert!(session_start[0]["command"]
+ .as_str()
+ .unwrap()
+ .ends_with("session"));
+
+ if let Some(home) = original_home {
+ std::env::set_var("HOME", home);
+ } else {
+ std::env::remove_var("HOME");
+ }
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
fn install_mastracode_is_idempotent_for_hook_entries() {
let _lock = integration_env_lock();
let base = unique_base();
diff --git a/src/pane.rs b/src/pane.rs
index ee3aee12..ea6a13fd 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -290,6 +290,7 @@ fn foreground_member_cwd_different_from_shell(
enum ForegroundShellAgentAction {
ObserveProbe,
ReportProcessExit,
+ ReportReplacementProcess,
ClearAgent,
}
@@ -299,12 +300,20 @@ fn foreground_shell_agent_action(
foreground_is_pane_shell: bool,
process_exit_reported: bool,
) -> ForegroundShellAgentAction {
- if previous_agent.is_none() || new_agent.is_some() {
+ let Some(previous_agent) = previous_agent else {
return ForegroundShellAgentAction::ObserveProbe;
- }
-
+ };
if process_exit_reported {
- return ForegroundShellAgentAction::ClearAgent;
+ return if new_agent == Some(previous_agent) {
+ ForegroundShellAgentAction::ReportReplacementProcess
+ } else if new_agent.is_none() {
+ ForegroundShellAgentAction::ClearAgent
+ } else {
+ ForegroundShellAgentAction::ObserveProbe
+ };
+ }
+ if new_agent.is_some() {
+ return ForegroundShellAgentAction::ObserveProbe;
}
if foreground_is_pane_shell {
@@ -317,6 +326,38 @@ fn foreground_shell_agent_action(
ForegroundShellAgentAction::ObserveProbe
}
+fn apply_foreground_shell_agent_action(
+ agent_presence: &mut AgentDetectionPresence,
+ action: ForegroundShellAgentAction,
+ previous_agent: Option<Agent>,
+ new_agent: Option<Agent>,
+ pending_foreground_shell_clear: &mut bool,
+ foreground_shell_exit_reported: &mut bool,
+) -> bool {
+ match action {
+ ForegroundShellAgentAction::ReportReplacementProcess => {
+ *pending_foreground_shell_clear = false;
+ *foreground_shell_exit_reported = false;
+ agent_presence.observe_process_probe(previous_agent);
+ true
+ }
+ ForegroundShellAgentAction::ReportProcessExit => {
+ *pending_foreground_shell_clear = true;
+ false
+ }
+ ForegroundShellAgentAction::ClearAgent => {
+ *pending_foreground_shell_clear = false;
+ *foreground_shell_exit_reported = false;
+ agent_presence.clear_current_agent()
+ }
+ ForegroundShellAgentAction::ObserveProbe => {
+ *pending_foreground_shell_clear = false;
+ *foreground_shell_exit_reported = false;
+ agent_presence.observe_process_probe(new_agent)
+ }
+ }
+}
+
#[derive(Debug, Clone, Copy)]
struct ProcessProbeInput {
current_agent: Option<Agent>,
@@ -680,27 +721,20 @@ fn spawn_basic_detection_task(
}
}
let previous_agent = agent_presence.current_agent();
- let changed = match foreground_shell_agent_action(
+ let foreground_action = foreground_shell_agent_action(
previous_agent,
new_agent,
foreground_is_pane_shell,
foreground_shell_exit_reported,
- ) {
- ForegroundShellAgentAction::ReportProcessExit => {
- pending_foreground_shell_clear = true;
- false
- }
- ForegroundShellAgentAction::ClearAgent => {
- pending_foreground_shell_clear = false;
- foreground_shell_exit_reported = false;
- agent_presence.clear_current_agent()
- }
- ForegroundShellAgentAction::ObserveProbe => {
- pending_foreground_shell_clear = false;
- foreground_shell_exit_reported = false;
- agent_presence.observe_process_probe(new_agent)
- }
- };
+ );
+ let changed = apply_foreground_shell_agent_action(
+ &mut agent_presence,
+ foreground_action,
+ previous_agent,
+ new_agent,
+ &mut pending_foreground_shell_clear,
+ &mut foreground_shell_exit_reported,
+ );
if new_agent.is_some() {
last_foreground_pgid = process_group_id.or(foreground_pgid);
acquisition_started_at = None;
@@ -715,7 +749,9 @@ fn spawn_basic_detection_task(
}
if changed {
agent = agent_presence.current_agent();
- agent_changed = previous_agent != agent;
+ agent_changed = previous_agent != agent
+ || foreground_action
+ == ForegroundShellAgentAction::ReportReplacementProcess;
if agent_changed {
pending_idle.clear();
last_screen_scan_detection_content_seq = None;
@@ -2105,27 +2141,20 @@ impl PaneRuntime {
}
let previous_agent = agent_presence.current_agent();
- let changed = match foreground_shell_agent_action(
+ let foreground_action = foreground_shell_agent_action(
previous_agent,
new_agent,
foreground_is_pane_shell,
foreground_shell_exit_reported,
- ) {
- ForegroundShellAgentAction::ReportProcessExit => {
- pending_foreground_shell_clear = true;
- false
- }
- ForegroundShellAgentAction::ClearAgent => {
- pending_foreground_shell_clear = false;
- foreground_shell_exit_reported = false;
- agent_presence.clear_current_agent()
- }
- ForegroundShellAgentAction::ObserveProbe => {
- pending_foreground_shell_clear = false;
- foreground_shell_exit_reported = false;
- agent_presence.observe_process_probe(new_agent)
- }
- };
+ );
+ let changed = apply_foreground_shell_agent_action(
+ &mut agent_presence,
+ foreground_action,
+ previous_agent,
+ new_agent,
+ &mut pending_foreground_shell_clear,
+ &mut foreground_shell_exit_reported,
+ );
if new_agent.is_some() {
last_foreground_pgid = process_group_id;
acquisition_started_at = None;
@@ -2142,7 +2171,10 @@ impl PaneRuntime {
}
if changed {
agent = agent_presence.current_agent();
- if agent != previous_agent {
+ if agent != previous_agent
+ || foreground_action
+ == ForegroundShellAgentAction::ReportReplacementProcess
+ {
pending_idle.clear();
last_screen_scan_detection_content_seq = None;
// A new foreground agent must not inherit OSC
@@ -3370,6 +3402,14 @@ mod tests {
}
#[test]
+ fn same_agent_after_reported_exit_is_a_replacement_process() {
+ assert_eq!(
+ foreground_shell_agent_action(Some(Agent::Pi), Some(Agent::Pi), false, true),
+ ForegroundShellAgentAction::ReportReplacementProcess
+ );
+ }
+
+ #[test]
fn unknown_non_shell_foreground_job_is_not_immediate_clear_signal() {
assert_eq!(
foreground_shell_agent_action(Some(Agent::Claude), None, false, false),
@@ -3388,7 +3428,7 @@ mod tests {
#[test]
fn foreground_agent_job_is_not_clear_signal() {
assert_eq!(
- foreground_shell_agent_action(Some(Agent::Claude), Some(Agent::OpenCode), true, false),
+ foreground_shell_agent_action(Some(Agent::Claude), Some(Agent::OpenCode), true, false,),
ForegroundShellAgentAction::ObserveProbe
);
}
diff --git a/src/persist/snapshot.rs b/src/persist/snapshot.rs
index e1e112a0..39f790dd 100644
--- a/src/persist/snapshot.rs
+++ b/src/persist/snapshot.rs
@@ -1108,18 +1108,24 @@ mod tests {
let terminal_id = state.workspaces[0].tabs[0].panes[&root]
.attached_terminal_id
.clone();
- state
- .terminals
- .get_mut(&terminal_id)
- .unwrap()
- .set_hook_authority_with_session_ref(
- "herdr:pi".into(),
- "pi".into(),
- crate::detect::AgentState::Working,
- None,
- crate::agent_resume::AgentSessionRef::path(session_path.clone()),
- Some(20),
- );
+ let terminal = state.terminals.get_mut(&terminal_id).unwrap();
+ terminal.set_detected_state(
+ Some(crate::detect::Agent::Pi),
+ crate::detect::AgentState::Idle,
+ );
+ terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession {
+ source: "herdr:pi".into(),
+ agent: "pi".into(),
+ session_ref: crate::agent_resume::AgentSessionRef::path(session_path.clone()).unwrap(),
+ });
+ terminal.set_hook_authority_with_session_ref(
+ "herdr:pi".into(),
+ "pi".into(),
+ crate::detect::AgentState::Working,
+ None,
+ crate::agent_resume::AgentSessionRef::path(session_path.clone()),
+ Some(20),
+ );
let snapshot = capture_from_state(&state);
let agent_session = snapshot.workspaces[0].tabs[0].panes[&root.raw()]
diff --git a/src/server/headless.rs b/src/server/headless.rs
index aaa3742a..942a17f1 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -5840,7 +5840,7 @@ next_tab = ""
assert!(
server.handle_internal_event_with_forwarding(AppEvent::HookStateReported {
pane_id,
- source: "herdr:pi".into(),
+ source: "custom:pi".into(),
agent_label: "pi".into(),
state: crate::detect::AgentState::Working,
message: None,
@@ -5853,7 +5853,7 @@ next_tab = ""
pane_id,
source: "user:pi-display".into(),
agent_label: Some("pi".into()),
- applies_to_source: Some("herdr:pi".into()),
+ applies_to_source: Some("custom:pi".into()),
title: Some("short lived".into()),
display_agent: None,
state_labels: HashMap::new(),
@@ -9528,6 +9528,34 @@ next_tab = ""
.terminals
.get_mut(&terminal_id)
.unwrap()
+ .set_detected_state(
+ Some(crate::detect::Agent::Pi),
+ crate::detect::AgentState::Idle,
+ );
+ server
+ .app
+ .state
+ .terminals
+ .get_mut(&terminal_id)
+ .unwrap()
+ .set_persisted_agent_session(crate::agent_resume::PersistedAgentSession {
+ source: "herdr:pi".into(),
+ agent: "pi".into(),
+ session_ref: crate::agent_resume::AgentSessionRef::path(
+ std::env::current_dir()
+ .unwrap()
+ .join("headless-pi-session.jsonl")
+ .display()
+ .to_string(),
+ )
+ .unwrap(),
+ });
+ server
+ .app
+ .state
+ .terminals
+ .get_mut(&terminal_id)
+ .unwrap()
.set_hook_authority(
"herdr:pi".into(),
"pi".into(),
diff --git a/src/terminal/metadata.rs b/src/terminal/metadata.rs
index 27ac8528..2a912b27 100644
--- a/src/terminal/metadata.rs
+++ b/src/terminal/metadata.rs
@@ -58,11 +58,47 @@ impl TerminalState {
crate::metadata_tokens::sequence_is_fresh(&self.metadata_report_sequences, source, seq)
}
+ pub(crate) fn metadata_report_agent(
+ source: &str,
+ agent_label: Option<&str>,
+ applies_to_source: Option<&str>,
+ ) -> Option<crate::detect::Agent> {
+ agent_label
+ .and_then(crate::detect::parse_agent_label)
+ .or_else(|| {
+ crate::detect::Agent::ALL.iter().copied().find(|agent| {
+ let agent_label = crate::detect::agent_label(*agent);
+ crate::agent_resume::is_official_agent_source(source, agent_label)
+ || applies_to_source.is_some_and(|source| {
+ crate::agent_resume::is_official_agent_source(source, agent_label)
+ })
+ })
+ })
+ }
+
+ pub(crate) fn metadata_report_blocked_by_process_exit(
+ &self,
+ source: &str,
+ agent_label: Option<&str>,
+ applies_to_source: Option<&str>,
+ ) -> bool {
+ let Some(exit) = self.recent_agent_process_exit else {
+ return false;
+ };
+ let exited_agent_label = crate::detect::agent_label(exit.agent);
+ agent_label.and_then(crate::detect::parse_agent_label) == Some(exit.agent)
+ || crate::agent_resume::is_official_agent_source(source, exited_agent_label)
+ || applies_to_source.is_some_and(|source| {
+ crate::agent_resume::is_official_agent_source(source, exited_agent_label)
+ })
+ }
+
pub(crate) fn accept_metadata_report(
&mut self,
source: &str,
seq: Option<u64>,
includes_tokens: bool,
+ agent: Option<crate::detect::Agent>,
) -> Result<bool, ()> {
let Some(seq) = seq else {
return Ok(true);
@@ -79,6 +115,10 @@ impl TerminalState {
}
self.metadata_report_sequences
.insert(source.to_string(), seq);
+ if let Some(agent) = agent {
+ self.metadata_report_agents
+ .insert(source.to_string(), agent);
+ }
if includes_tokens {
self.metadata_token_sequence_sources
.insert(source.to_string());
@@ -105,8 +145,20 @@ impl TerminalState {
&mut self,
report: AgentMetadataReport,
) -> Option<TerminalStateMutation> {
+ if self.metadata_report_blocked_by_process_exit(
+ &report.source,
+ report.agent_label.as_deref(),
+ report.applies_to_source.as_deref(),
+ ) {
+ return None;
+ }
+ let report_agent = Self::metadata_report_agent(
+ &report.source,
+ report.agent_label.as_deref(),
+ report.applies_to_source.as_deref(),
+ );
if !matches!(
- self.accept_metadata_report(&report.source, report.seq, false),
+ self.accept_metadata_report(&report.source, report.seq, false, report_agent),
Ok(true)
) {
return None;
@@ -482,13 +534,13 @@ mod tests {
let mut terminal = test_terminal();
for index in 0..=crate::metadata_tokens::MAX_SEQUENCE_SOURCES {
assert_eq!(
- terminal.accept_metadata_report(&format!("source-{index}"), Some(1), false),
+ terminal.accept_metadata_report(&format!("source-{index}"), Some(1), false, None,),
Ok(true)
);
}
for index in 0..crate::metadata_tokens::MAX_SEQUENCE_SOURCES {
assert_eq!(
- terminal.accept_metadata_report(&format!("source-{index}"), Some(2), true),
+ terminal.accept_metadata_report(&format!("source-{index}"), Some(2), true, None,),
Ok(true)
);
}
@@ -497,12 +549,69 @@ mod tests {
&format!("source-{}", crate::metadata_tokens::MAX_SEQUENCE_SOURCES),
Some(2),
true,
+ None,
),
Err(())
);
}
#[test]
+ fn custom_metadata_reanchors_sequence_after_process_restart() {
+ let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ let report = |seq, ttl| AgentMetadataReport {
+ source: "custom:pi-metadata".into(),
+ agent_label: Some("pi".into()),
+ applies_to_source: None,
+ title: Some("Pi task".into()),
+ display_agent: None,
+ state_labels: HashMap::new(),
+ clear_title: false,
+ clear_display_agent: false,
+ clear_state_labels: false,
+ ttl,
+ seq: Some(seq),
+ };
+ assert!(terminal
+ .set_agent_metadata(report(100, Some(Duration::ZERO)))
+ .is_some());
+ let deadline = terminal.next_agent_metadata_expiry().unwrap();
+ terminal.expire_agent_metadata_at(deadline, deadline);
+ assert!(terminal.agent_metadata.is_empty());
+ let exit_at = Instant::now() + Duration::from_millis(1);
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Pi),
+ AgentState::Idle,
+ false,
+ false,
+ false,
+ true,
+ exit_at,
+ );
+ terminal.set_detected_state_with_screen_signals_at(
+ None,
+ AgentState::Unknown,
+ false,
+ false,
+ false,
+ false,
+ exit_at + Duration::from_millis(1),
+ );
+ assert!(terminal.set_agent_metadata(report(1, None)).is_none());
+
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Pi),
+ AgentState::Idle,
+ false,
+ false,
+ false,
+ false,
+ exit_at + Duration::from_millis(2),
+ );
+ assert!(terminal.set_agent_metadata(report(1, None)).is_some());
+ }
+
+ #[test]
fn user_agent_metadata_overrides_presentation_fields_only() {
let mut terminal = test_terminal();
terminal.set_hook_authority(
diff --git a/src/terminal/state.rs b/src/terminal/state.rs
index 38e4739e..4485c773 100644
--- a/src/terminal/state.rs
+++ b/src/terminal/state.rs
@@ -30,6 +30,14 @@ struct SuppressedFullLifecycleHookReport {
session_ref: Option<crate::agent_resume::AgentSessionRef>,
observed_at: Instant,
reason: FullLifecycleHookSuppressionReason,
+ replacement_session_ref: Option<crate::agent_resume::AgentSessionRef>,
+ pending_replacement_report: Option<PendingFullLifecycleHookReport>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct PendingFullLifecycleHookReport {
+ authority: HookAuthority,
+ seq: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -38,6 +46,12 @@ enum FullLifecycleHookSuppressionReason {
ProcessExit,
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum FullLifecycleHookReportRoute {
+ Accept { reanchor_sequence: bool },
+ Ignore,
+}
+
#[derive(Debug, Clone, PartialEq, Eq)]
struct StaleFullLifecycleHookSession {
agent_label: String,
@@ -91,6 +105,12 @@ struct AgentNameOwner {
session_ref: Option<crate::agent_resume::AgentSessionRef>,
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+struct RecentAgentProcessExit {
+ agent: Agent,
+ observed_at: Instant,
+}
+
/// Pure state for a server-owned terminal.
///
/// During the migration this is still one-to-one with a pane-backed PTY, but
@@ -116,13 +136,14 @@ pub struct TerminalState {
suppressed_full_lifecycle_hook_reports: HashMap<String, SuppressedFullLifecycleHookReport>,
stale_full_lifecycle_hook_sessions: HashMap<String, Vec<StaleFullLifecycleHookSession>>,
metadata_report_sequences: HashMap<String, u64>,
+ metadata_report_agents: HashMap<String, Agent>,
metadata_token_sequence_sources: std::collections::HashSet<String>,
pub state: AgentState,
pub last_agent_state_change_seq: Option<u64>,
pub revision: u64,
pub launch_argv: Option<Vec<String>>,
pub respawn_shell_on_exit: bool,
- recent_agent_process_exit_at: Option<Instant>,
+ recent_agent_process_exit: Option<RecentAgentProcessExit>,
pub pending_agent_resume_plan: Option<crate::agent_resume::AgentResumePlan>,
}
@@ -148,13 +169,14 @@ impl TerminalState {
suppressed_full_lifecycle_hook_reports: HashMap::new(),
stale_full_lifecycle_hook_sessions: HashMap::new(),
metadata_report_sequences: HashMap::new(),
+ metadata_report_agents: HashMap::new(),
metadata_token_sequence_sources: std::collections::HashSet::new(),
state: AgentState::Unknown,
last_agent_state_change_seq: None,
revision: 0,
launch_argv: None,
respawn_shell_on_exit: false,
- recent_agent_process_exit_at: None,
+ recent_agent_process_exit: None,
pending_agent_resume_plan: None,
}
}
@@ -193,8 +215,8 @@ impl TerminalState {
#[cfg(any(windows, test))]
pub(crate) fn agent_process_exited_within(&self, now: Instant, max_age: Duration) -> bool {
- self.recent_agent_process_exit_at
- .is_some_and(|exited_at| now.saturating_duration_since(exited_at) <= max_age)
+ self.recent_agent_process_exit
+ .is_some_and(|exit| now.saturating_duration_since(exit.observed_at) <= max_age)
}
pub fn with_pending_agent_resume_plan(
@@ -268,8 +290,17 @@ impl TerminalState {
let previous_presentation = self.effective_presentation_for_state_at(previous_state, now);
let previous_detected_agent = self.detected_agent;
let previous_session = self.current_session_identity_for_persistence();
+ let newer_custom_authority = process_exited
+ && self.hook_authority.as_ref().is_some_and(|authority| {
+ crate::detect::parse_agent_label(&authority.agent_label) == agent
+ && !crate::agent_resume::is_official_agent_source(
+ &authority.source,
+ &authority.agent_label,
+ )
+ && authority.reported_at > now
+ });
let agent_released = process_exited
- && self.hook_authority_not_newer_than(now)
+ && !newer_custom_authority
&& (previous_agent_label.is_some() || self.agent_name.is_some());
if self.should_ignore_detected_state_under_full_lifecycle_hook(agent, process_exited) {
if self
@@ -293,6 +324,11 @@ impl TerminalState {
agent_released: false,
};
}
+ let replacement_process_detected = !process_exited
+ && agent.is_some()
+ && self
+ .recent_agent_process_exit
+ .is_some_and(|exit| Some(exit.agent) == agent && exit.observed_at < now);
if !process_exited && self.detected_state_observed_before_release_suppression(agent, now) {
return TerminalStateMutation {
effective_state_change: self.recompute_effective_state(
@@ -314,43 +350,168 @@ impl TerminalState {
}
if !process_exited {
self.clear_full_lifecycle_hook_suppression_for_detected_agent(
- previous_detected_agent,
+ if replacement_process_detected {
+ None
+ } else {
+ previous_detected_agent
+ },
agent,
);
}
self.fallback_state = fallback_state;
self.fallback_visible_blocker = visible_blocker && fallback_state == AgentState::Blocked;
self.fallback_observed_at = Some(now);
- if process_exited && agent.is_some() {
- self.recent_agent_process_exit_at = Some(now);
+ if process_exited {
+ if let Some(agent) = agent {
+ self.recent_agent_process_exit = Some(RecentAgentProcessExit {
+ agent,
+ observed_at: now,
+ });
+ }
} else if agent.is_some() {
- self.recent_agent_process_exit_at = None;
+ self.recent_agent_process_exit = None;
}
- if process_exited
- && self.hook_authority_not_newer_than(now)
- && self.hook_authority.as_ref().is_some_and(|authority| {
- crate::detect::parse_agent_label(&authority.agent_label) == agent
- })
- {
- let cleared_source = self
+ if process_exited {
+ let mut reset_sources = Vec::new();
+ let mut stale_sessions = Vec::new();
+ for (source, suppressed) in &mut self.suppressed_full_lifecycle_hook_reports {
+ if crate::detect::parse_agent_label(&suppressed.agent_label) != agent
+ || suppressed.reason == FullLifecycleHookSuppressionReason::HookClear
+ {
+ continue;
+ }
+ let exited_session_ref = suppressed
+ .replacement_session_ref
+ .take()
+ .or_else(|| {
+ suppressed
+ .pending_replacement_report
+ .as_ref()
+ .and_then(|pending| pending.authority.session_ref.clone())
+ })
+ .or_else(|| suppressed.session_ref.clone());
+ if let (Some(previous), Some(exited)) =
+ (suppressed.session_ref.as_ref(), exited_session_ref.as_ref())
+ {
+ if previous != exited {
+ stale_sessions.push((
+ source.clone(),
+ suppressed.agent_label.clone(),
+ previous.clone(),
+ ));
+ }
+ }
+ suppressed.session_ref = exited_session_ref;
+ suppressed.pending_replacement_report = None;
+ suppressed.observed_at = now;
+ reset_sources.push(source.clone());
+ }
+ for (source, agent_label, session_ref) in stale_sessions {
+ self.remember_stale_full_lifecycle_hook_session(source, agent_label, session_ref);
+ }
+ for source in reset_sources {
+ self.hook_report_sequences.remove(&source);
+ }
+
+ let official_session = self
.hook_authority
.as_ref()
- .map(|authority| authority.source.clone());
- self.suppress_current_full_lifecycle_hook_authority(
- FullLifecycleHookSuppressionReason::ProcessExit,
- );
- if let Some(source) = cleared_source {
+ .filter(|authority| {
+ crate::agent_resume::is_official_agent_source(
+ &authority.source,
+ &authority.agent_label,
+ ) && crate::detect::parse_agent_label(&authority.agent_label) == agent
+ })
+ .map(|authority| {
+ (
+ authority.source.clone(),
+ authority.agent_label.clone(),
+ authority.session_ref.clone(),
+ )
+ })
+ .or_else(|| {
+ self.persisted_agent_session.as_ref().and_then(|session| {
+ (crate::agent_resume::is_official_agent_source(
+ &session.source,
+ &session.agent,
+ ) && crate::detect::parse_agent_label(&session.agent) == agent)
+ .then(|| {
+ (
+ session.source.clone(),
+ session.agent.clone(),
+ Some(session.session_ref.clone()),
+ )
+ })
+ })
+ });
+ if let Some((source, agent_label, session_ref)) = official_session {
self.hook_report_sequences.remove(&source);
+ self.suppress_full_lifecycle_hook_report_with_session_ref(
+ source,
+ agent_label,
+ session_ref,
+ FullLifecycleHookSuppressionReason::ProcessExit,
+ now,
+ );
+ }
+ let cleared_hook_source = self.hook_authority.as_ref().and_then(|authority| {
+ (crate::detect::parse_agent_label(&authority.agent_label) == agent
+ && !newer_custom_authority)
+ .then(|| authority.source.clone())
+ });
+ if let Some(source) = cleared_hook_source {
+ self.hook_report_sequences.remove(&source);
+ self.hook_authority = None;
+ }
+ if !newer_custom_authority
+ && self
+ .persisted_agent_session
+ .as_ref()
+ .is_some_and(|session| {
+ crate::detect::parse_agent_label(&session.agent) == agent
+ })
+ {
+ self.persisted_agent_session = None;
+ }
+ if let Some(agent) = agent {
+ let agent_label = crate::detect::agent_label(agent);
+ let mut cleared_metadata_sources = Vec::new();
+ self.agent_metadata.retain(|source, metadata| {
+ let official_metadata = crate::agent_resume::is_official_agent_source(
+ &metadata.source,
+ agent_label,
+ ) || metadata.applies_to_source.as_deref().is_some_and(
+ |applies_to| {
+ crate::agent_resume::is_official_agent_source(applies_to, agent_label)
+ },
+ );
+ let matches_agent =
+ metadata.agent_label.as_deref() == Some(agent_label) || official_metadata;
+ let clear = matches_agent && (official_metadata || metadata.reported_at <= now);
+ if clear {
+ cleared_metadata_sources.push(source.clone());
+ }
+ !clear
+ });
+ for source in cleared_metadata_sources {
+ self.metadata_report_sequences.remove(&source);
+ self.metadata_report_agents.remove(&source);
+ self.metadata_token_sequence_sources.remove(&source);
+ }
+ let mut exited_generation_sources = Vec::new();
+ self.metadata_report_agents.retain(|source, owner| {
+ if *owner == agent {
+ exited_generation_sources.push(source.clone());
+ false
+ } else {
+ true
+ }
+ });
+ for source in exited_generation_sources {
+ self.metadata_report_sequences.remove(&source);
+ self.metadata_token_sequence_sources.remove(&source);
+ }
}
- self.hook_authority = None;
- }
- if process_exited
- && self
- .persisted_agent_session
- .as_ref()
- .is_some_and(|session| crate::detect::parse_agent_label(&session.agent) == agent)
- {
- self.persisted_agent_session = None;
}
if self.hook_authority_not_newer_than(now)
&& (self.hook_authority_conflicts_with_detected_agent(agent)
@@ -444,26 +605,25 @@ impl TerminalState {
seq: Option<u64>,
now: Instant,
) -> Option<TerminalStateMutation> {
- if self.full_lifecycle_hook_report_is_suppressed(&source, &agent_label, &session_ref) {
+ if !crate::detect::full_lifecycle_hook_authority(&source, &agent_label)
+ && self.recent_agent_process_exit.is_some_and(|exit| {
+ crate::detect::parse_agent_label(&agent_label) == Some(exit.agent)
+ })
+ {
return None;
}
- if self.full_lifecycle_hook_report_matches_stale_session(
+ let reanchor_sequence = match self.route_full_lifecycle_hook_report(
&source,
&agent_label,
+ state,
+ message.as_deref(),
&session_ref,
+ seq,
+ now,
) {
- return None;
- }
- let reanchor_sequence =
- self.full_lifecycle_hook_report_has_fresh_session_after_suppression(
- &source,
- &agent_label,
- &session_ref,
- ) || self.full_lifecycle_hook_report_has_fresh_session_after_stale_session(
- &source,
- &agent_label,
- &session_ref,
- );
+ FullLifecycleHookReportRoute::Accept { reanchor_sequence } => reanchor_sequence,
+ FullLifecycleHookReportRoute::Ignore => return None,
+ };
if self.known_agent_label_conflicts_with_detected_agent(&agent_label) {
return None;
}
@@ -514,7 +674,7 @@ impl TerminalState {
FullLifecycleHookSuppressionReason::HookClear,
);
}
- if session_ref.is_some() {
+ if session_ref.is_some() || reanchor_sequence {
if let Some(suppressed) = self.suppressed_full_lifecycle_hook_reports.remove(&source) {
if let Some(suppressed_ref) = suppressed.session_ref {
self.remember_stale_full_lifecycle_hook_session(
@@ -611,6 +771,7 @@ impl TerminalState {
agent_label,
session_ref,
reason,
+ Instant::now(),
);
}
}
@@ -631,6 +792,7 @@ impl TerminalState {
agent_label.to_string(),
session_ref,
reason,
+ Instant::now(),
);
}
}
@@ -641,65 +803,143 @@ impl TerminalState {
agent_label: String,
session_ref: Option<crate::agent_resume::AgentSessionRef>,
reason: FullLifecycleHookSuppressionReason,
+ observed_at: Instant,
) {
self.suppressed_full_lifecycle_hook_reports.insert(
source,
SuppressedFullLifecycleHookReport {
agent_label,
session_ref,
- observed_at: Instant::now(),
+ observed_at,
reason,
+ replacement_session_ref: None,
+ pending_replacement_report: None,
},
);
}
- fn full_lifecycle_hook_report_is_suppressed(
- &self,
+ fn route_full_lifecycle_hook_report(
+ &mut self,
source: &str,
agent_label: &str,
+ state: AgentState,
+ message: Option<&str>,
session_ref: &Option<crate::agent_resume::AgentSessionRef>,
- ) -> bool {
+ seq: Option<u64>,
+ reported_at: Instant,
+ ) -> FullLifecycleHookReportRoute {
if !crate::detect::full_lifecycle_hook_authority(source, agent_label) {
- return false;
+ return FullLifecycleHookReportRoute::Accept {
+ reanchor_sequence: false,
+ };
}
- self.suppressed_full_lifecycle_hook_reports
- .get(source)
- .is_some_and(|suppressed| {
- if suppressed.agent_label != agent_label {
- return false;
- }
- if suppressed.reason == FullLifecycleHookSuppressionReason::ProcessExit {
- return true;
- }
- match (&suppressed.session_ref, session_ref) {
- (Some(suppressed_ref), Some(incoming_ref)) => incoming_ref == suppressed_ref,
- (Some(_), None) => true,
- (None, Some(_)) => false,
- (None, None) => true,
- }
+ if self.full_lifecycle_hook_report_matches_stale_session(source, agent_label, session_ref) {
+ return FullLifecycleHookReportRoute::Ignore;
+ }
+
+ let known_agent = crate::detect::parse_agent_label(agent_label);
+ let process_present = known_agent.is_some()
+ && self.detected_agent == known_agent
+ && self.recent_agent_process_exit.is_none();
+ let session_anchored = self
+ .hook_authority
+ .as_ref()
+ .filter(|authority| authority.source == source && authority.agent_label == agent_label)
+ .and_then(|authority| authority.session_ref.as_ref())
+ .or_else(|| {
+ self.persisted_agent_session
+ .as_ref()
+ .filter(|session| session.source == source && session.agent == agent_label)
+ .map(|session| &session.session_ref)
})
- }
+ .is_some_and(|anchored| {
+ session_ref
+ .as_ref()
+ .is_none_or(|incoming| incoming == anchored)
+ });
+ if let Some(suppressed) = self.suppressed_full_lifecycle_hook_reports.get(source) {
+ if suppressed.agent_label != agent_label {
+ return FullLifecycleHookReportRoute::Ignore;
+ }
+ if suppressed.reason == FullLifecycleHookSuppressionReason::HookClear {
+ let reanchor_sequence = matches!(
+ (&suppressed.session_ref, session_ref),
+ (Some(previous), Some(incoming)) if previous != incoming
+ );
+ return if reanchor_sequence {
+ FullLifecycleHookReportRoute::Accept {
+ reanchor_sequence: true,
+ }
+ } else {
+ FullLifecycleHookReportRoute::Ignore
+ };
+ }
+ }
- fn full_lifecycle_hook_report_has_fresh_session_after_suppression(
- &self,
- source: &str,
- agent_label: &str,
- session_ref: &Option<crate::agent_resume::AgentSessionRef>,
- ) -> bool {
- if !crate::detect::full_lifecycle_hook_authority(source, agent_label) {
- return false;
+ if process_present
+ && session_anchored
+ && !self
+ .suppressed_full_lifecycle_hook_reports
+ .contains_key(source)
+ {
+ return FullLifecycleHookReportRoute::Accept {
+ reanchor_sequence: self
+ .full_lifecycle_hook_report_has_fresh_session_after_stale_session(
+ source,
+ agent_label,
+ session_ref,
+ ),
+ };
}
- self.suppressed_full_lifecycle_hook_reports
+
+ let Some(session_ref) = session_ref.clone() else {
+ return FullLifecycleHookReportRoute::Ignore;
+ };
+ let Some(seq) = seq else {
+ return FullLifecycleHookReportRoute::Ignore;
+ };
+ if self
+ .hook_report_sequences
.get(source)
- .is_some_and(|suppressed| {
- suppressed.agent_label == agent_label
- && suppressed.reason != FullLifecycleHookSuppressionReason::ProcessExit
- && matches!(
- (&suppressed.session_ref, session_ref),
- (Some(suppressed_ref), Some(incoming_ref))
- if incoming_ref != suppressed_ref
- )
- })
+ .is_some_and(|previous| seq <= *previous)
+ {
+ return FullLifecycleHookReportRoute::Ignore;
+ }
+
+ let previous_session_ref = self
+ .persisted_agent_session
+ .as_ref()
+ .filter(|session| session.source == source && session.agent == agent_label)
+ .map(|session| session.session_ref.clone());
+ let suppressed = self
+ .suppressed_full_lifecycle_hook_reports
+ .entry(source.to_string())
+ .or_insert_with(|| SuppressedFullLifecycleHookReport {
+ agent_label: agent_label.to_string(),
+ session_ref: previous_session_ref,
+ observed_at: reported_at,
+ reason: FullLifecycleHookSuppressionReason::ProcessExit,
+ replacement_session_ref: None,
+ pending_replacement_report: None,
+ });
+ let replace_pending = suppressed
+ .pending_replacement_report
+ .as_ref()
+ .is_none_or(|pending| seq > pending.seq);
+ if replace_pending {
+ suppressed.pending_replacement_report = Some(PendingFullLifecycleHookReport {
+ authority: HookAuthority {
+ source: source.to_string(),
+ agent_label: agent_label.to_string(),
+ state,
+ message: message.map(str::to_string),
+ reported_at,
+ session_ref: Some(session_ref),
+ },
+ seq,
+ });
+ }
+ FullLifecycleHookReportRoute::Ignore
}
fn full_lifecycle_hook_report_matches_stale_session(
@@ -801,22 +1041,59 @@ impl TerminalState {
}
let detected_label = crate::detect::agent_label(detected_agent);
let mut stale_sessions = Vec::new();
+ let mut validated_replacement_sessions = Vec::new();
self.suppressed_full_lifecycle_hook_reports
.retain(|source, suppressed| {
let should_clear = crate::detect::parse_agent_label(&suppressed.agent_label)
== Some(detected_agent);
- if should_clear {
- if let Some(session_ref) = suppressed.session_ref.clone() {
- stale_sessions.push((
+ if !should_clear {
+ return true;
+ }
+ if suppressed.reason == FullLifecycleHookSuppressionReason::ProcessExit {
+ if let Some(session_ref) = suppressed.replacement_session_ref.take() {
+ if let Some(exited_session_ref) = suppressed
+ .session_ref
+ .as_ref()
+ .filter(|exited_session_ref| *exited_session_ref != &session_ref)
+ .cloned()
+ {
+ stale_sessions.push((
+ source.clone(),
+ StaleFullLifecycleHookSession {
+ agent_label: suppressed.agent_label.clone(),
+ session_ref: exited_session_ref,
+ },
+ ));
+ }
+ let session_start_seq = self.hook_report_sequences.get(source).copied();
+ let pending =
+ suppressed
+ .pending_replacement_report
+ .take()
+ .filter(|pending| {
+ pending.authority.session_ref.as_ref() == Some(&session_ref)
+ && session_start_seq.is_none_or(|seq| pending.seq > seq)
+ });
+ validated_replacement_sessions.push((
source.clone(),
- StaleFullLifecycleHookSession {
- agent_label: suppressed.agent_label.clone(),
- session_ref,
- },
+ suppressed.agent_label.clone(),
+ session_ref,
+ pending,
));
+ return false;
}
+ return true;
+ }
+ if let Some(session_ref) = suppressed.session_ref.clone() {
+ stale_sessions.push((
+ source.clone(),
+ StaleFullLifecycleHookSession {
+ agent_label: suppressed.agent_label.clone(),
+ session_ref,
+ },
+ ));
}
- !should_clear
+ false
});
for (source, stale_session) in stale_sessions {
self.remember_stale_full_lifecycle_hook_session(
@@ -826,8 +1103,24 @@ impl TerminalState {
);
}
self.hook_report_sequences.retain(|source, _| {
- !crate::detect::full_lifecycle_hook_authority(source, detected_label)
+ validated_replacement_sessions
+ .iter()
+ .any(|(validated_source, _, _, _)| validated_source == source)
+ || !crate::detect::full_lifecycle_hook_authority(source, detected_label)
});
+ for (source, agent_label, session_ref, pending) in validated_replacement_sessions {
+ self.forget_stale_full_lifecycle_hook_session(&source, &agent_label, &session_ref);
+ self.reconcile_agent_name_owner(&agent_label, Some(&session_ref));
+ self.persisted_agent_session = Some(crate::agent_resume::PersistedAgentSession {
+ source: source.clone(),
+ agent: agent_label,
+ session_ref,
+ });
+ if let Some(pending) = pending {
+ self.hook_report_sequences.insert(source, pending.seq);
+ self.hook_authority = Some(pending.authority);
+ }
+ }
}
fn remember_stale_full_lifecycle_hook_session(
@@ -986,7 +1279,8 @@ impl TerminalState {
"herdr:codex",
"codex",
Some("startup" | "clear" | "resume" | "compact")
- ) | ("herdr:opencode", "opencode", Some("new"))
+ ) | ("herdr:mastracode", "mastracode", Some("startup"))
+ | ("herdr:opencode", "opencode", Some("new"))
| ("herdr:pi", "pi", Some("new" | "resume" | "fork"))
| (
"herdr:omp",
@@ -1029,6 +1323,86 @@ impl TerminalState {
session_start_source: Option<String>,
) -> Option<TerminalStateMutation> {
let session_ref = session_ref?;
+ let known_agent = crate::detect::parse_agent_label(&agent_label);
+ let process_present = known_agent.is_some()
+ && self.detected_agent == known_agent
+ && self.recent_agent_process_exit.is_none();
+ let full_lifecycle_source =
+ crate::detect::full_lifecycle_hook_authority(&source, &agent_label);
+ let generation_gated = self
+ .suppressed_full_lifecycle_hook_reports
+ .get(&source)
+ .is_some_and(|suppressed| {
+ suppressed.agent_label == agent_label
+ && suppressed.reason != FullLifecycleHookSuppressionReason::HookClear
+ });
+ let session_anchored = self.hook_authority.as_ref().is_some_and(|authority| {
+ authority.source == source
+ && authority.agent_label == agent_label
+ && authority.session_ref.is_some()
+ }) || self.persisted_agent_session_matches(&source, &agent_label);
+ if full_lifecycle_source && (!process_present || generation_gated || !session_anchored) {
+ if !Self::session_start_source_is_recognized(session_start_source.as_deref()) {
+ return None;
+ }
+ let seq = seq?;
+ if self
+ .hook_report_sequences
+ .get(&source)
+ .is_some_and(|previous| seq <= *previous)
+ {
+ return None;
+ }
+
+ let previous_agent_label = self.effective_agent_label().map(str::to_string);
+ let previous_known_agent = self.effective_known_agent();
+ let previous_state = self.state;
+ let now = Instant::now();
+ let previous_presentation =
+ self.effective_presentation_for_state_at(previous_state, now);
+ let previous_session = self.current_session_identity_for_persistence();
+ let suppressed = self
+ .suppressed_full_lifecycle_hook_reports
+ .entry(source.clone())
+ .or_insert_with(|| SuppressedFullLifecycleHookReport {
+ agent_label: agent_label.clone(),
+ session_ref: None,
+ observed_at: now,
+ reason: FullLifecycleHookSuppressionReason::ProcessExit,
+ replacement_session_ref: None,
+ pending_replacement_report: None,
+ });
+ if suppressed.replacement_session_ref.as_ref() != Some(&session_ref) {
+ if suppressed
+ .pending_replacement_report
+ .as_ref()
+ .is_some_and(|pending| {
+ pending.authority.session_ref.as_ref() != Some(&session_ref)
+ })
+ {
+ suppressed.pending_replacement_report = None;
+ }
+ suppressed.replacement_session_ref = Some(session_ref);
+ }
+ self.hook_report_sequences.insert(source.clone(), seq);
+
+ if process_present {
+ self.clear_full_lifecycle_hook_suppression_for_detected_agent(None, known_agent);
+ let current_session = self.current_session_identity_for_persistence();
+ return Some(TerminalStateMutation {
+ effective_state_change: self.recompute_effective_state(
+ previous_agent_label,
+ previous_known_agent,
+ previous_state,
+ previous_presentation,
+ now,
+ ),
+ session_ref_changed: previous_session != current_session,
+ agent_released: false,
+ });
+ }
+ return None;
+ }
if !self.accept_hook_report(&source, seq) {
return None;
}
@@ -1193,6 +1567,13 @@ impl TerminalState {
.as_ref()
.map(|authority| authority.source.clone())
});
+ let should_clear = self
+ .hook_authority
+ .as_ref()
+ .is_some_and(|authority| source.is_none_or(|source| authority.source == source));
+ if !should_clear {
+ return None;
+ }
if let Some(source) = sequence_source.as_deref() {
if !self.accept_hook_report(source, seq) {
return None;
@@ -1205,13 +1586,6 @@ impl TerminalState {
let previous_state = self.state;
let previous_presentation = self.effective_presentation_for_state_at(previous_state, now);
let previous_session = self.current_session_identity_for_persistence();
- let should_clear = self
- .hook_authority
- .as_ref()
- .is_some_and(|authority| source.is_none_or(|source| authority.source == source));
- if !should_clear {
- return None;
- }
self.suppress_current_full_lifecycle_hook_authority(
FullLifecycleHookSuppressionReason::HookClear,
);
@@ -1230,27 +1604,12 @@ impl TerminalState {
})
}
- #[cfg(test)]
- pub fn release_agent(
- &mut self,
- source: &str,
- agent_label: &str,
- seq: Option<u64>,
- ) -> Option<EffectiveStateChange> {
- self.release_agent_with_mutation(source, agent_label, seq)
- .and_then(|mutation| mutation.effective_state_change)
- }
-
pub fn release_agent_with_mutation(
&mut self,
source: &str,
agent_label: &str,
seq: Option<u64>,
) -> Option<TerminalStateMutation> {
- if !self.accept_hook_report(source, seq) {
- return None;
- }
-
if self.hook_authority.as_ref().is_some_and(|authority| {
authority.agent_label != agent_label || authority.source != source
}) {
@@ -1262,10 +1621,17 @@ impl TerminalState {
if !matches_current_agent && !matches_persisted_session {
return None;
}
+ if !self.accept_hook_report(source, seq) {
+ return None;
+ }
let preserve_foreign_persisted_session = self
.persisted_agent_session
.as_ref()
.is_some_and(|session| session.source != source || session.agent != agent_label);
+ let process_owns_agent =
+ crate::detect::parse_agent_label(agent_label).is_some_and(|agent| {
+ self.detected_agent == Some(agent) && self.recent_agent_process_exit.is_none()
+ });
let now = Instant::now();
let previous_agent_label = self.effective_agent_label().map(str::to_string);
@@ -1278,12 +1644,14 @@ impl TerminalState {
agent_label,
FullLifecycleHookSuppressionReason::HookClear,
);
- self.detected_agent = None;
- self.fallback_state = AgentState::Unknown;
- self.fallback_visible_blocker = false;
- self.fallback_observed_at = None;
+ if !process_owns_agent {
+ self.detected_agent = None;
+ self.fallback_state = AgentState::Unknown;
+ self.fallback_visible_blocker = false;
+ self.fallback_observed_at = None;
+ self.clear_agent_name();
+ }
self.hook_authority = None;
- self.clear_agent_name();
if !preserve_foreign_persisted_session {
self.persisted_agent_session = None;
}
@@ -1297,22 +1665,33 @@ impl TerminalState {
now,
),
session_ref_changed: previous_session != current_session,
- agent_released: true,
+ agent_released: !process_owns_agent,
})
}
+ fn hook_authority_is_effective(&self, authority: &HookAuthority) -> bool {
+ !crate::detect::full_lifecycle_hook_authority(&authority.source, &authority.agent_label)
+ || crate::detect::parse_agent_label(&authority.agent_label).is_none_or(|agent| {
+ self.detected_agent == Some(agent) && self.recent_agent_process_exit.is_none()
+ })
+ }
+
pub fn effective_agent_label(&self) -> Option<&str> {
self.hook_authority
.as_ref()
+ .filter(|authority| self.hook_authority_is_effective(authority))
.map(|authority| authority.agent_label.as_str())
- .or_else(|| self.detected_agent.map(crate::detect::agent_label))
+ .or_else(|| {
+ self.recent_agent_process_exit
+ .is_none()
+ .then(|| self.detected_agent.map(crate::detect::agent_label))
+ .flatten()
+ })
}
pub fn effective_known_agent(&self) -> Option<Agent> {
- if let Some(authority) = &self.hook_authority {
- return crate::detect::parse_agent_label(&authority.agent_label);
- }
- self.detected_agent
+ self.effective_agent_label()
+ .and_then(crate::detect::parse_agent_label)
}
pub(crate) fn unchanged_effective_state_change_at(&self, now: Instant) -> EffectiveStateChange {
@@ -1351,7 +1730,11 @@ impl TerminalState {
fn live_full_lifecycle_hook_authority(&self) -> bool {
self.hook_authority.as_ref().is_some_and(|authority| {
- crate::detect::full_lifecycle_hook_authority(&authority.source, &authority.agent_label)
+ self.hook_authority_is_effective(authority)
+ && crate::detect::full_lifecycle_hook_authority(
+ &authority.source,
+ &authority.agent_label,
+ )
})
}
@@ -1533,13 +1916,14 @@ impl TerminalState {
self.hook_authority = None;
self.persisted_agent_session = None;
self.agent_metadata.clear();
+ self.metadata_report_agents.clear();
self.suppressed_full_lifecycle_hook_reports.clear();
self.stale_full_lifecycle_hook_sessions.clear();
self.state = AgentState::Unknown;
self.last_agent_state_change_seq = None;
self.launch_argv = None;
self.respawn_shell_on_exit = false;
- self.recent_agent_process_exit_at = None;
+ self.recent_agent_process_exit = None;
self.pending_agent_resume_plan = None;
self.clear_agent_name();
}
@@ -1612,6 +1996,7 @@ impl TerminalState {
} else {
self.hook_authority
.as_ref()
+ .filter(|authority| self.hook_authority_is_effective(authority))
.map(|authority| authority.state)
.unwrap_or(self.fallback_state)
};
@@ -1663,6 +2048,21 @@ mod tests {
.to_string()
}
+ fn anchor_full_lifecycle_session(
+ terminal: &mut TerminalState,
+ agent: Agent,
+ source: &str,
+ agent_label: &str,
+ session_ref: crate::agent_resume::AgentSessionRef,
+ ) {
+ terminal.set_detected_state(Some(agent), terminal.fallback_state);
+ terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession {
+ source: source.into(),
+ agent: agent_label.into(),
+ session_ref,
+ });
+ }
+
#[test]
fn managed_agent_activates_only_after_matching_settled_detection() {
let mut terminal = test_terminal();
@@ -1740,6 +2140,13 @@ mod tests {
fn hook_authority_overrides_fallback_for_same_agent() {
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::Pi,
+ "herdr:pi",
+ "pi",
+ crate::agent_resume::AgentSessionRef::path(test_session_path("root.jsonl")).unwrap(),
+ );
terminal.set_hook_authority(
"herdr:pi".into(),
"pi".into(),
@@ -1776,6 +2183,13 @@ mod tests {
fn omp_hook_authority_overrides_detected_fallback() {
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::Omp), AgentState::Idle);
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::Omp,
+ "herdr:omp",
+ "omp",
+ crate::agent_resume::AgentSessionRef::id("omp-root").unwrap(),
+ );
terminal.set_hook_authority(
"herdr:omp".into(),
"omp".into(),
@@ -1838,6 +2252,45 @@ mod tests {
}
#[test]
+ fn startup_session_claim_activates_full_lifecycle_integrations() {
+ for (agent, source, label) in [
+ (Agent::Kimi, "herdr:kimi", "kimi"),
+ (Agent::Kilo, "herdr:kilo", "kilo"),
+ (Agent::Hermes, "herdr:hermes", "hermes"),
+ ] {
+ let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(agent), AgentState::Idle);
+ let session_ref = crate::agent_resume::AgentSessionRef::id(format!("{label}-root"));
+
+ let session = terminal.set_agent_session_ref_for_session_start(
+ source.into(),
+ label.into(),
+ session_ref.clone(),
+ Some(10),
+ Some("startup".into()),
+ );
+ let working = terminal.set_hook_authority_with_session_ref(
+ source.into(),
+ label.into(),
+ AgentState::Working,
+ None,
+ session_ref,
+ Some(11),
+ );
+
+ assert!(
+ session.is_some(),
+ "{label} should accept its startup session"
+ );
+ assert!(
+ working.is_some(),
+ "{label} should accept state after startup"
+ );
+ assert_eq!(terminal.state, AgentState::Working);
+ }
+ }
+
+ #[test]
fn pi_session_replacement_reports_reanchor_full_lifecycle_authority() {
for reason in ["new", "resume", "fork"] {
let mut terminal = test_terminal();
@@ -1995,6 +2448,13 @@ mod tests {
let old_session = test_session_path("pi-current.jsonl");
let new_session = test_session_path("pi-unexpected.jsonl");
terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::Pi,
+ "herdr:pi",
+ "pi",
+ crate::agent_resume::AgentSessionRef::path(old_session.clone()).unwrap(),
+ );
terminal.set_hook_authority_with_session_ref(
"herdr:pi".into(),
"pi".into(),
@@ -2095,91 +2555,6 @@ mod tests {
}
#[test]
- fn process_exit_clears_matching_full_lifecycle_hook_authority() {
- let now = Instant::now();
- let mut terminal = test_terminal();
- terminal.set_detected_state(Some(Agent::Pi), AgentState::Working);
- terminal.set_hook_authority_at(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- None,
- Some(10),
- now,
- );
-
- let change = terminal.set_detected_state_with_screen_signals_at(
- Some(Agent::Pi),
- AgentState::Idle,
- false,
- true,
- false,
- true,
- now + Duration::from_millis(1),
- );
-
- assert!(terminal.hook_authority.is_none());
- assert_eq!(terminal.state, AgentState::Idle);
- assert_eq!(
- change.effective_state_change.unwrap().previous_state,
- AgentState::Working
- );
-
- let stale = terminal.set_hook_authority_at(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- None,
- Some(9),
- now + Duration::from_millis(2),
- );
-
- assert!(stale.is_none());
- assert_eq!(terminal.state, AgentState::Idle);
- }
-
- #[test]
- fn late_full_lifecycle_hook_after_process_exit_does_not_reacquire_authority() {
- let now = Instant::now();
- let mut terminal = test_terminal();
- terminal.set_detected_state(Some(Agent::Pi), AgentState::Working);
- terminal.set_hook_authority_at(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- None,
- Some(20),
- now,
- );
-
- terminal.set_detected_state_with_screen_signals_at(
- Some(Agent::Pi),
- AgentState::Idle,
- false,
- true,
- false,
- true,
- now + Duration::from_millis(1),
- );
- let late = terminal.set_hook_authority_at(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- None,
- Some(21),
- now + Duration::from_millis(2),
- );
-
- assert!(late.is_none());
- assert!(terminal.hook_authority.is_none());
- assert_eq!(terminal.state, AgentState::Idle);
- }
-
- #[test]
fn late_full_lifecycle_hook_with_same_session_after_process_exit_does_not_reacquire_authority()
{
let now = Instant::now();
@@ -2219,249 +2594,252 @@ mod tests {
}
#[test]
- fn late_full_lifecycle_hook_after_release_does_not_reacquire_authority() {
+ fn live_full_lifecycle_hook_rejects_different_session_ref_for_same_source() {
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
- terminal.set_hook_authority(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- Some(20),
- );
-
- terminal.release_agent("herdr:pi", "pi", Some(21));
- let late = terminal.set_hook_authority(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- Some(22),
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::Pi,
+ "herdr:pi",
+ "pi",
+ crate::agent_resume::AgentSessionRef::path(test_session_path("one.jsonl")).unwrap(),
);
-
- assert!(late.is_none());
- assert!(terminal.hook_authority.is_none());
- assert_eq!(terminal.state, AgentState::Unknown);
- }
-
- #[test]
- fn late_full_lifecycle_hook_with_same_session_after_release_does_not_reacquire_authority() {
- let mut terminal = test_terminal();
- let session_path = test_session_path("pi.jsonl");
- terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
terminal.set_hook_authority_with_session_ref(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
- crate::agent_resume::AgentSessionRef::path(session_path.clone()),
+ crate::agent_resume::AgentSessionRef::path(test_session_path("one.jsonl")),
Some(20),
);
- terminal.release_agent("herdr:pi", "pi", Some(21));
- let late = terminal.set_hook_authority_with_session_ref(
+ let mutation = terminal.set_hook_authority_with_session_ref(
"herdr:pi".into(),
"pi".into(),
- AgentState::Working,
+ AgentState::Idle,
None,
- crate::agent_resume::AgentSessionRef::path(session_path),
- Some(22),
+ crate::agent_resume::AgentSessionRef::path(test_session_path("two.jsonl")),
+ Some(21),
);
- assert!(late.is_none());
- assert!(terminal.hook_authority.is_none());
- assert_eq!(terminal.state, AgentState::Unknown);
+ assert!(mutation.is_none());
+ assert_eq!(terminal.state, AgentState::Working);
+ assert_eq!(
+ terminal
+ .hook_authority
+ .as_ref()
+ .and_then(|authority| authority.session_ref.as_ref())
+ .map(|session_ref| session_ref.value.as_str()),
+ Some(test_session_path("one.jsonl").as_str())
+ );
}
#[test]
- fn changed_session_ref_allows_full_lifecycle_hook_after_suppression() {
+ fn fresh_detected_process_keeps_old_session_suppressed_after_process_exit() {
let mut terminal = test_terminal();
+ let old_session = test_session_path("old-process-exit.jsonl");
+ let new_session = test_session_path("new-process-exit.jsonl");
terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
terminal.set_hook_authority_with_session_ref(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
- crate::agent_resume::AgentSessionRef::path(test_session_path("old.jsonl")),
- Some(20),
+ crate::agent_resume::AgentSessionRef::path(old_session.clone()),
+ Some(1000),
+ );
+ let process_exit_seen_at = Instant::now() + Duration::from_secs(1);
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Pi),
+ AgentState::Idle,
+ false,
+ true,
+ false,
+ true,
+ process_exit_seen_at,
);
- terminal.release_agent("herdr:pi", "pi", Some(21));
- let fresh = terminal.set_hook_authority_with_session_ref(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
+ let fresh_process_seen_at = process_exit_seen_at + Duration::from_millis(1);
+ terminal.set_detected_state_with_screen_signals_at(
None,
- crate::agent_resume::AgentSessionRef::path(test_session_path("new.jsonl")),
- Some(22),
+ AgentState::Unknown,
+ false,
+ false,
+ false,
+ false,
+ fresh_process_seen_at,
+ );
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Pi),
+ AgentState::Unknown,
+ false,
+ false,
+ false,
+ false,
+ fresh_process_seen_at + Duration::from_millis(1),
);
- assert!(fresh.is_some());
- assert!(terminal.hook_authority.is_some());
- assert_eq!(terminal.state, AgentState::Working);
- }
-
- #[test]
- fn changed_session_ref_reanchors_hook_sequence_after_release() {
- let mut terminal = test_terminal();
- terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
- terminal.set_hook_authority_with_session_ref(
+ let late_old = terminal.set_hook_authority_with_session_ref(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
- crate::agent_resume::AgentSessionRef::path(test_session_path("old.jsonl")),
- Some(1000),
+ crate::agent_resume::AgentSessionRef::path(old_session),
+ Some(500),
);
- terminal.release_agent("herdr:pi", "pi", Some(3000));
-
- let fresh = terminal.set_hook_authority_with_session_ref(
+ let fresh_new = terminal.set_hook_authority_with_session_ref(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
- crate::agent_resume::AgentSessionRef::path(test_session_path("new.jsonl")),
- Some(1500),
+ crate::agent_resume::AgentSessionRef::path(new_session.clone()),
+ Some(501),
);
- assert!(fresh.is_some());
+ assert!(late_old.is_none());
+ assert!(fresh_new.is_none());
+ terminal
+ .set_agent_session_ref_for_session_start(
+ "herdr:pi".into(),
+ "pi".into(),
+ crate::agent_resume::AgentSessionRef::path(new_session),
+ Some(400),
+ Some("startup".into()),
+ )
+ .expect("fresh session should activate the buffered report");
assert!(terminal.hook_authority.is_some());
assert_eq!(terminal.state, AgentState::Working);
}
#[test]
- fn stale_session_suppression_survives_multiple_release_generations() {
+ fn rapid_restart_replays_reports_that_arrive_before_process_evidence() {
let mut terminal = test_terminal();
- let session_a = test_session_path("release-generation-a.jsonl");
- let session_b = test_session_path("release-generation-b.jsonl");
- let session_c = test_session_path("release-generation-c.jsonl");
+ let session_path = test_session_path("reports-before-process-evidence.jsonl");
+ let now = Instant::now();
terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
- terminal.set_hook_authority_with_session_ref(
+ terminal.set_hook_authority_at(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
- crate::agent_resume::AgentSessionRef::path(session_a.clone()),
+ crate::agent_resume::AgentSessionRef::path(session_path.clone()),
Some(1000),
+ now,
+ );
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Pi),
+ AgentState::Idle,
+ false,
+ true,
+ false,
+ true,
+ now + Duration::from_millis(1),
);
- terminal.release_agent("herdr:pi", "pi", Some(2000));
- let generation_b = terminal.set_hook_authority_with_session_ref(
+ let lower_sequence = terminal.set_hook_authority_at(
"herdr:pi".into(),
"pi".into(),
- AgentState::Working,
+ AgentState::Idle,
None,
- crate::agent_resume::AgentSessionRef::path(session_b),
- Some(1500),
+ crate::agent_resume::AgentSessionRef::path(session_path.clone()),
+ Some(1001),
+ now + Duration::from_millis(2),
);
- assert!(generation_b.is_some());
- terminal.release_agent("herdr:pi", "pi", Some(3000));
-
- let late_generation_a = terminal.set_hook_authority_with_session_ref(
+ let missing_sequence = terminal.set_hook_authority_at(
"herdr:pi".into(),
"pi".into(),
- AgentState::Working,
+ AgentState::Idle,
+ None,
+ crate::agent_resume::AgentSessionRef::path(session_path.clone()),
None,
- crate::agent_resume::AgentSessionRef::path(session_a),
- Some(2500),
+ now + Duration::from_millis(3),
);
- let generation_c = terminal.set_hook_authority_with_session_ref(
+ let buffered_working = terminal.set_hook_authority_at(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
- crate::agent_resume::AgentSessionRef::path(session_c),
- Some(2500),
+ crate::agent_resume::AgentSessionRef::path(session_path.clone()),
+ Some(2001),
+ now + Duration::from_millis(4),
);
-
- assert!(late_generation_a.is_none());
- assert!(generation_c.is_some());
- assert!(terminal.hook_authority.is_some());
- assert_eq!(terminal.state, AgentState::Working);
- }
-
- #[test]
- fn live_full_lifecycle_hook_rejects_different_session_ref_for_same_source() {
- let mut terminal = test_terminal();
- terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
- terminal.set_hook_authority_with_session_ref(
+ let startup = terminal.set_agent_session_ref_for_session_start(
"herdr:pi".into(),
"pi".into(),
- AgentState::Working,
- None,
- crate::agent_resume::AgentSessionRef::path(test_session_path("one.jsonl")),
- Some(20),
+ crate::agent_resume::AgentSessionRef::path(session_path),
+ Some(2000),
+ Some("startup".into()),
);
+ assert!(startup.is_none());
+ assert!(lower_sequence.is_none());
+ assert!(missing_sequence.is_none());
+ assert!(buffered_working.is_none());
+ assert!(!terminal.full_lifecycle_hook_authority_active());
- let mutation = terminal.set_hook_authority_with_session_ref(
- "herdr:pi".into(),
- "pi".into(),
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Pi),
AgentState::Idle,
- None,
- crate::agent_resume::AgentSessionRef::path(test_session_path("two.jsonl")),
- Some(21),
+ false,
+ true,
+ false,
+ false,
+ now + Duration::from_millis(5),
);
- assert!(mutation.is_none());
+ assert!(terminal.full_lifecycle_hook_authority_active());
assert_eq!(terminal.state, AgentState::Working);
- assert_eq!(
- terminal
- .hook_authority
- .as_ref()
- .and_then(|authority| authority.session_ref.as_ref())
- .map(|session_ref| session_ref.value.as_str()),
- Some(test_session_path("one.jsonl").as_str())
- );
}
#[test]
- fn fresh_detected_process_allows_full_lifecycle_hook_after_suppression() {
+ fn process_exit_discards_unclaimed_buffered_state_from_that_generation() {
let mut terminal = test_terminal();
- terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
- terminal.set_hook_authority(
+ let old_session = test_session_path("buffered-exit-old.jsonl");
+ let shared_session = test_session_path("buffered-exit-shared.jsonl");
+ let now = Instant::now();
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::Pi,
+ "herdr:pi",
+ "pi",
+ crate::agent_resume::AgentSessionRef::path(old_session.clone()).unwrap(),
+ );
+ terminal.set_hook_authority_at(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
- Some(20),
+ crate::agent_resume::AgentSessionRef::path(old_session),
+ Some(1000),
+ now,
);
- terminal.release_agent("herdr:pi", "pi", Some(21));
- let now = Instant::now();
-
terminal.set_detected_state_with_screen_signals_at(
Some(Agent::Pi),
- AgentState::Unknown,
+ AgentState::Idle,
false,
+ true,
false,
+ true,
+ now + Duration::from_millis(1),
+ );
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Pi),
+ AgentState::Idle,
false,
+ true,
false,
- now,
+ false,
+ now + Duration::from_millis(2),
);
- let fresh = terminal.set_hook_authority(
+ terminal.set_hook_authority_at(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
- Some(22),
+ crate::agent_resume::AgentSessionRef::path(shared_session.clone()),
+ Some(500),
+ now + Duration::from_millis(3),
);
- assert!(fresh.is_some());
- assert!(terminal.hook_authority.is_some());
- assert_eq!(terminal.state, AgentState::Working);
- }
-
- #[test]
- fn fresh_detected_process_reanchors_hook_sequence_after_process_exit() {
- let mut terminal = test_terminal();
- terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
- terminal.set_hook_authority(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- Some(1000),
- );
- let process_exit_seen_at = Instant::now() + Duration::from_millis(1);
terminal.set_detected_state_with_screen_signals_at(
Some(Agent::Pi),
AgentState::Idle,
@@ -2469,56 +2847,46 @@ mod tests {
true,
false,
true,
- process_exit_seen_at,
- );
-
- let fresh_process_seen_at = process_exit_seen_at + Duration::from_millis(1);
- terminal.set_detected_state_with_screen_signals_at(
- None,
- AgentState::Unknown,
- false,
- false,
- false,
- false,
- fresh_process_seen_at,
+ now + Duration::from_millis(4),
);
terminal.set_detected_state_with_screen_signals_at(
Some(Agent::Pi),
- AgentState::Unknown,
- false,
+ AgentState::Idle,
false,
+ true,
false,
false,
- fresh_process_seen_at + Duration::from_millis(1),
- );
- let fresh = terminal.set_hook_authority(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- Some(500),
+ now + Duration::from_millis(5),
);
+ terminal
+ .set_agent_session_ref_for_session_start(
+ "herdr:pi".into(),
+ "pi".into(),
+ crate::agent_resume::AgentSessionRef::path(shared_session),
+ Some(100),
+ Some("startup".into()),
+ )
+ .expect("new generation session claim");
- assert!(fresh.is_some());
- assert!(terminal.hook_authority.is_some());
- assert_eq!(terminal.state, AgentState::Working);
+ assert!(terminal.hook_authority.is_none());
+ assert_eq!(terminal.state, AgentState::Idle);
}
#[test]
- fn fresh_detected_process_keeps_old_session_suppressed_after_process_exit() {
+ fn queued_fresh_process_evidence_uses_process_exit_observation_time() {
let mut terminal = test_terminal();
- let old_session = test_session_path("old-process-exit.jsonl");
- let new_session = test_session_path("new-process-exit.jsonl");
+ let session_path = test_session_path("queued-after-process-exit.jsonl");
+ let process_exit_at = Instant::now() - Duration::from_secs(1);
terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
- terminal.set_hook_authority_with_session_ref(
+ terminal.set_hook_authority_at(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
- crate::agent_resume::AgentSessionRef::path(old_session.clone()),
+ crate::agent_resume::AgentSessionRef::path(session_path.clone()),
Some(1000),
+ process_exit_at - Duration::from_millis(1),
);
- let process_exit_seen_at = Instant::now() + Duration::from_secs(1);
terminal.set_detected_state_with_screen_signals_at(
Some(Agent::Pi),
AgentState::Idle,
@@ -2526,10 +2894,9 @@ mod tests {
true,
false,
true,
- process_exit_seen_at,
+ process_exit_at,
);
- let fresh_process_seen_at = process_exit_seen_at + Duration::from_millis(1);
terminal.set_detected_state_with_screen_signals_at(
None,
AgentState::Unknown,
@@ -2537,39 +2904,26 @@ mod tests {
false,
false,
false,
- fresh_process_seen_at,
+ process_exit_at + Duration::from_millis(1),
);
terminal.set_detected_state_with_screen_signals_at(
Some(Agent::Pi),
- AgentState::Unknown,
- false,
+ AgentState::Idle,
false,
+ true,
false,
false,
- fresh_process_seen_at + Duration::from_millis(1),
- );
-
- let late_old = terminal.set_hook_authority_with_session_ref(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- crate::agent_resume::AgentSessionRef::path(old_session),
- Some(500),
+ process_exit_at + Duration::from_millis(2),
);
- let fresh_new = terminal.set_hook_authority_with_session_ref(
+ let startup = terminal.set_agent_session_ref_for_session_start(
"herdr:pi".into(),
"pi".into(),
- AgentState::Working,
- None,
- crate::agent_resume::AgentSessionRef::path(new_session),
- Some(500),
+ crate::agent_resume::AgentSessionRef::path(session_path),
+ Some(2000),
+ Some("startup".into()),
);
- assert!(late_old.is_none());
- assert!(fresh_new.is_some());
- assert!(terminal.hook_authority.is_some());
- assert_eq!(terminal.state, AgentState::Working);
+ assert!(startup.is_some());
}
#[test]
@@ -2629,14 +2983,12 @@ mod tests {
false,
now + Duration::from_millis(4),
);
- let fresh_new = terminal.set_hook_authority_at(
+ let fresh_new = terminal.set_agent_session_ref_for_session_start(
"herdr:pi".into(),
"pi".into(),
- AgentState::Working,
- None,
crate::agent_resume::AgentSessionRef::path(new_session),
- Some(500),
- now + Duration::from_millis(5),
+ Some(400),
+ Some("startup".into()),
);
assert!(fresh_new.is_some());
@@ -2709,277 +3061,64 @@ mod tests {
Some(500),
now + Duration::from_millis(5),
);
+ assert!(fresh_without_session.is_none());
+ assert!(terminal.hook_authority.is_none());
- assert!(fresh_without_session.is_some());
- assert!(terminal.hook_authority.is_some());
- assert_eq!(terminal.state, AgentState::Working);
- }
-
- #[test]
- fn stale_session_suppression_survives_multiple_process_generations() {
- let mut terminal = test_terminal();
- let session_a = test_session_path("generation-a.jsonl");
- let session_b = test_session_path("generation-b.jsonl");
- let session_c = test_session_path("generation-c.jsonl");
- let now = Instant::now();
- terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
- terminal.set_hook_authority_at(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- crate::agent_resume::AgentSessionRef::path(session_a.clone()),
- Some(1000),
- now,
- );
-
- terminal.set_detected_state_with_screen_signals_at(
- Some(Agent::Pi),
- AgentState::Idle,
- false,
- true,
- false,
- true,
- now + Duration::from_millis(1),
- );
- terminal.set_detected_state_with_screen_signals_at(
- None,
- AgentState::Unknown,
- false,
- false,
- false,
- false,
- now + Duration::from_millis(2),
- );
- terminal.set_detected_state_with_screen_signals_at(
- Some(Agent::Pi),
- AgentState::Unknown,
- false,
- false,
- false,
- false,
- now + Duration::from_millis(3),
- );
- let generation_b = terminal.set_hook_authority_at(
+ terminal
+ .set_agent_session_ref_for_session_start(
+ "herdr:pi".into(),
+ "pi".into(),
+ crate::agent_resume::AgentSessionRef::path(test_session_path(
+ "fresh-after-nosession-process-exit.jsonl",
+ )),
+ Some(600),
+ Some("startup".into()),
+ )
+ .expect("fresh root session should claim the process generation");
+ let child_update = terminal.set_hook_authority_at(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
- crate::agent_resume::AgentSessionRef::path(session_b),
- Some(500),
- now + Duration::from_millis(4),
- );
- assert!(generation_b.is_some());
-
- terminal.set_detected_state_with_screen_signals_at(
- Some(Agent::Pi),
- AgentState::Idle,
- false,
- true,
- false,
- true,
- now + Duration::from_millis(5),
- );
- terminal.set_detected_state_with_screen_signals_at(
None,
- AgentState::Unknown,
- false,
- false,
- false,
- false,
+ Some(601),
now + Duration::from_millis(6),
);
- terminal.set_detected_state_with_screen_signals_at(
- Some(Agent::Pi),
- AgentState::Unknown,
- false,
- false,
- false,
- false,
- now + Duration::from_millis(7),
- );
-
- let late_generation_a = terminal.set_hook_authority_at(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- crate::agent_resume::AgentSessionRef::path(session_a),
- Some(250),
- now + Duration::from_millis(8),
- );
- let generation_c = terminal.set_hook_authority_at(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- crate::agent_resume::AgentSessionRef::path(session_c),
- Some(250),
- now + Duration::from_millis(9),
- );
- assert!(late_generation_a.is_none());
- assert!(generation_c.is_some());
- assert!(terminal.hook_authority.is_some());
+ assert!(child_update.is_some());
assert_eq!(terminal.state, AgentState::Working);
}
#[test]
- fn release_suppression_ignores_same_agent_idle_publish() {
- let now = Instant::now();
- let mut terminal = test_terminal();
- terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
- terminal.set_hook_authority(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- Some(20),
- );
- terminal.release_agent("herdr:pi", "pi", Some(21));
-
- let change = terminal.set_detected_state_with_screen_signals_at(
- Some(Agent::Pi),
- AgentState::Idle,
- false,
- true,
- false,
- false,
- now,
- );
- let late = terminal.set_hook_authority(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- Some(22),
- );
-
- assert!(change.effective_state_change.is_none());
- assert!(late.is_none());
- assert_eq!(terminal.detected_agent, None);
- assert_eq!(terminal.state, AgentState::Unknown);
- }
-
- #[test]
- fn fresh_session_ref_allows_full_lifecycle_hook_after_suppression() {
+ fn mastracode_session_start_replaces_current_root_session() {
let mut terminal = test_terminal();
- terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
- terminal.set_hook_authority(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- Some(20),
- );
- terminal.release_agent("herdr:pi", "pi", Some(21));
-
- let fresh = terminal.set_hook_authority_with_session_ref(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- crate::agent_resume::AgentSessionRef::id("fresh-session"),
- Some(22),
- );
-
- assert!(fresh.is_some());
- assert!(terminal.hook_authority.is_some());
- assert_eq!(terminal.state, AgentState::Working);
- }
-
- // Regression for #614: a same-pane restart must reacquire lifecycle authority.
- #[test]
- fn omp_reacquires_full_lifecycle_hook_after_release_with_fresh_session_ref() {
- assert_full_lifecycle_hook_reacquires_after_release_with_fresh_session_ref(
- "herdr:omp",
- "omp",
- );
- }
-
- #[test]
- fn mastracode_reacquires_full_lifecycle_hook_after_release_with_fresh_session_ref() {
- assert_full_lifecycle_hook_reacquires_after_release_with_fresh_session_ref(
- "herdr:mastracode",
- "mastracode",
- );
- }
-
- #[test]
- fn mastracode_lifecycle_report_replaces_restored_thread_ref() {
- let mut terminal = test_terminal();
- terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession {
- source: "herdr:mastracode".into(),
- agent: "mastracode".into(),
- session_ref: crate::agent_resume::AgentSessionRef::id("mastracode-old").unwrap(),
- });
-
- let mutation = terminal
- .set_hook_authority_with_session_ref(
+ terminal.set_detected_state(Some(Agent::Mastracode), AgentState::Idle);
+ terminal
+ .set_agent_session_ref_for_session_start(
"herdr:mastracode".into(),
"mastracode".into(),
- AgentState::Working,
- None,
- crate::agent_resume::AgentSessionRef::id("mastracode-new"),
+ crate::agent_resume::AgentSessionRef::id("mastracode-old"),
Some(20),
+ Some("startup".into()),
)
- .expect("fresh MastraCode thread should replace restored thread id");
-
- assert!(mutation.session_ref_changed);
- assert_eq!(
- terminal.current_session_identity_for_persistence(),
- Some((
- "herdr:mastracode".into(),
- "mastracode".into(),
- crate::agent_resume::AgentSessionRefKind::Id,
- "mastracode-new".into()
- ))
- );
- }
+ .expect("initial root session");
- fn assert_full_lifecycle_hook_reacquires_after_release_with_fresh_session_ref(
- source: &str,
- agent_label: &str,
- ) {
- let mut terminal = test_terminal();
- let old_session = format!("{agent_label}-old");
- let new_session = format!("{agent_label}-new");
-
- terminal.set_hook_authority_with_session_ref(
- source.into(),
- agent_label.into(),
- AgentState::Working,
- None,
- crate::agent_resume::AgentSessionRef::id(&old_session),
- Some(20),
- );
- terminal.release_agent(source, agent_label, Some(21));
-
- // A late report from the released run keeps its old session ref and stays
- // suppressed, so a just-exited agent cannot resurrect the pane.
- let stale = terminal.set_hook_authority_with_session_ref(
- source.into(),
- agent_label.into(),
- AgentState::Working,
- None,
- crate::agent_resume::AgentSessionRef::id(&old_session),
- Some(22),
+ let replacement = terminal.set_agent_session_ref_for_session_start(
+ "herdr:mastracode".into(),
+ "mastracode".into(),
+ crate::agent_resume::AgentSessionRef::id("mastracode-new"),
+ Some(21),
+ Some("startup".into()),
);
- assert!(stale.is_none());
- assert!(terminal.hook_authority.is_none());
- // A fresh run carries a new session ref and reacquires authority.
- let fresh = terminal.set_hook_authority_with_session_ref(
- source.into(),
- agent_label.into(),
- AgentState::Working,
- None,
- crate::agent_resume::AgentSessionRef::id(&new_session),
- Some(23),
+ assert!(replacement.is_some_and(|mutation| mutation.session_ref_changed));
+ assert_eq!(
+ terminal
+ .persisted_agent_session
+ .as_ref()
+ .map(|session| session.session_ref.value.as_str()),
+ Some("mastracode-new")
);
- assert!(fresh.is_some());
- assert!(terminal.hook_authority.is_some());
- assert_eq!(terminal.state, AgentState::Working);
}
#[test]
@@ -3035,6 +3174,15 @@ mod tests {
false,
now + Duration::from_millis(3),
);
+ terminal
+ .set_agent_session_ref_for_session_start(
+ "herdr:omp".into(),
+ "omp".into(),
+ crate::agent_resume::AgentSessionRef::id("omp-new"),
+ Some(400),
+ Some("startup".into()),
+ )
+ .expect("fresh process and session should claim the pane");
let fresh = terminal.set_hook_authority_with_session_ref(
"herdr:omp".into(),
"omp".into(),
@@ -3078,6 +3226,13 @@ mod tests {
fn visible_blocker_does_not_override_full_lifecycle_hook_authority() {
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::Pi,
+ "herdr:pi",
+ "pi",
+ crate::agent_resume::AgentSessionRef::path(test_session_path("root.jsonl")).unwrap(),
+ );
terminal.set_hook_authority(
"herdr:pi".into(),
"pi".into(),
@@ -3206,6 +3361,13 @@ mod tests {
let now = Instant::now();
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Working);
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::OpenCode,
+ "herdr:opencode",
+ "opencode",
+ crate::agent_resume::AgentSessionRef::id("opencode-root").unwrap(),
+ );
terminal.set_hook_authority_at(
"herdr:opencode".into(),
"opencode".into(),
@@ -3264,6 +3426,13 @@ mod tests {
let now = Instant::now();
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::Hermes), AgentState::Idle);
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::Hermes,
+ "herdr:hermes",
+ "hermes",
+ crate::agent_resume::AgentSessionRef::id("hermes-root").unwrap(),
+ );
terminal.set_hook_authority_at(
"herdr:hermes".into(),
"hermes".into(),
@@ -3294,6 +3463,13 @@ mod tests {
let now = Instant::now();
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::Kilo), AgentState::Idle);
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::Kilo,
+ "herdr:kilo",
+ "kilo",
+ crate::agent_resume::AgentSessionRef::id("kilo-root").unwrap(),
+ );
terminal.set_hook_authority_at(
"herdr:kilo".into(),
"kilo".into(),
@@ -3502,6 +3678,13 @@ mod tests {
let now = Instant::now();
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::Pi,
+ "herdr:pi",
+ "pi",
+ crate::agent_resume::AgentSessionRef::path(test_session_path("root.jsonl")).unwrap(),
+ );
terminal.set_hook_authority_at(
"herdr:pi".into(),
"pi".into(),
@@ -3592,7 +3775,7 @@ mod tests {
assert!(terminal.hook_authority.is_none());
assert_eq!(terminal.detected_agent, Some(Agent::Codex));
- assert_eq!(terminal.effective_agent_label(), Some("codex"));
+ assert_eq!(terminal.effective_agent_label(), None);
assert_eq!(terminal.state, AgentState::Idle);
}
@@ -3633,7 +3816,119 @@ mod tests {
}
#[test]
- fn stale_process_exit_does_not_clear_newer_same_agent_hook_authority() {
+ fn stale_process_exit_preserves_newer_custom_authority() {
+ let mut terminal = test_terminal();
+ let observed = Instant::now();
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Pi),
+ AgentState::Idle,
+ false,
+ false,
+ false,
+ false,
+ observed,
+ );
+ terminal.set_hook_authority_at(
+ "custom:pi".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ None,
+ Some(100),
+ observed + Duration::from_secs(1),
+ );
+
+ let mutation = terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Pi),
+ AgentState::Idle,
+ false,
+ false,
+ false,
+ true,
+ observed,
+ );
+
+ assert!(!mutation.agent_released);
+ assert_eq!(terminal.state, AgentState::Working);
+ assert_eq!(
+ terminal
+ .hook_authority
+ .as_ref()
+ .map(|hook| hook.source.as_str()),
+ Some("custom:pi")
+ );
+ }
+
+ #[test]
+ fn custom_authority_reanchors_sequence_after_process_restart() {
+ let mut terminal = test_terminal();
+ let observed = Instant::now();
+ terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ terminal.set_hook_authority_at(
+ "custom:pi".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ None,
+ Some(100),
+ observed,
+ );
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Pi),
+ AgentState::Idle,
+ false,
+ false,
+ false,
+ true,
+ observed + Duration::from_millis(1),
+ );
+ terminal.set_detected_state_with_screen_signals_at(
+ None,
+ AgentState::Unknown,
+ false,
+ false,
+ false,
+ false,
+ observed + Duration::from_millis(2),
+ );
+
+ assert!(terminal
+ .release_agent_with_mutation("custom:pi", "pi", Some(200))
+ .is_none());
+ assert!(terminal
+ .clear_hook_authority_with_mutation(Some("custom:pi"), Some(201))
+ .is_none());
+ assert!(terminal
+ .set_hook_authority(
+ "custom:pi".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ Some(1),
+ )
+ .is_none());
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Pi),
+ AgentState::Idle,
+ false,
+ false,
+ false,
+ false,
+ observed + Duration::from_millis(3),
+ );
+ assert!(terminal
+ .set_hook_authority(
+ "custom:pi".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ Some(1),
+ )
+ .is_some());
+ }
+
+ #[test]
+ fn process_exit_clears_newer_same_agent_hook_authority() {
let mut terminal = test_terminal();
let observed = Instant::now();
terminal.set_detected_state_with_screen_signals_at(
@@ -3674,10 +3969,9 @@ mod tests {
observed,
);
- let authority = terminal.hook_authority.as_ref().expect("hook authority");
- assert_eq!(authority.reported_at, observed + Duration::from_secs(1));
- assert_eq!(terminal.state, AgentState::Working);
- assert_eq!(terminal.effective_agent_label(), Some("codex"));
+ assert!(terminal.hook_authority.is_none());
+ assert_eq!(terminal.state, AgentState::Idle);
+ assert_eq!(terminal.effective_agent_label(), None);
}
#[test]
@@ -3701,28 +3995,16 @@ mod tests {
}
#[test]
- fn release_agent_clears_identity_immediately() {
+ fn stale_hook_report_sequence_is_ignored_for_same_source() {
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
- terminal.set_hook_authority(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- None,
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::Pi,
+ "herdr:pi",
+ "pi",
+ crate::agent_resume::AgentSessionRef::path(test_session_path("root.jsonl")).unwrap(),
);
-
- terminal.release_agent("herdr:pi", "pi", None);
-
- assert!(terminal.hook_authority.is_none());
- assert_eq!(terminal.detected_agent, None);
- assert_eq!(terminal.fallback_state, AgentState::Unknown);
- assert_eq!(terminal.state, AgentState::Unknown);
- }
-
- #[test]
- fn stale_hook_report_sequence_is_ignored_for_same_source() {
- let mut terminal = test_terminal();
terminal.set_hook_authority(
"herdr:pi".into(),
"pi".into(),
@@ -3751,6 +4033,13 @@ mod tests {
fn accepted_hook_report_stores_session_ref() {
let mut terminal = test_terminal();
let session_path = test_session_path("pi.jsonl");
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::Pi,
+ "herdr:pi",
+ "pi",
+ crate::agent_resume::AgentSessionRef::path(session_path.clone()).unwrap(),
+ );
let mutation = terminal
.set_hook_authority_with_session_ref(
"herdr:pi".into(),
@@ -3762,7 +4051,7 @@ mod tests {
)
.expect("accepted report");
- assert!(mutation.session_ref_changed);
+ assert!(!mutation.session_ref_changed);
assert_eq!(
terminal
.hook_authority
@@ -3781,6 +4070,13 @@ mod tests {
let mut terminal = test_terminal();
let session_path = test_session_path("pi.jsonl");
let new_session_path = test_session_path("new.jsonl");
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::Pi,
+ "herdr:pi",
+ "pi",
+ crate::agent_resume::AgentSessionRef::path(session_path.clone()).unwrap(),
+ );
terminal.set_hook_authority_with_session_ref(
"herdr:pi".into(),
"pi".into(),
@@ -3814,6 +4110,13 @@ mod tests {
fn accepted_hook_report_without_session_ref_clears_previous_ref() {
let mut terminal = test_terminal();
let session_path = test_session_path("pi.jsonl");
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::Pi,
+ "herdr:pi",
+ "pi",
+ crate::agent_resume::AgentSessionRef::path(session_path.clone()).unwrap(),
+ );
terminal.set_hook_authority_with_session_ref(
"herdr:pi".into(),
"pi".into(),
@@ -3845,30 +4148,6 @@ mod tests {
}
#[test]
- fn accepted_hook_report_marks_changed_when_same_owner_session_identity_changes() {
- let mut terminal = test_terminal();
- terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession {
- source: "herdr:pi".into(),
- agent: "pi".into(),
- session_ref: crate::agent_resume::AgentSessionRef::path(test_session_path("old.jsonl"))
- .unwrap(),
- });
-
- let mutation = terminal
- .set_hook_authority_with_session_ref(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- crate::agent_resume::AgentSessionRef::path(test_session_path("new.jsonl")),
- Some(20),
- )
- .expect("accepted report");
-
- assert!(mutation.session_ref_changed);
- }
-
- #[test]
fn different_same_agent_session_ref_is_ignored_until_current_session_clears() {
let mut terminal = test_terminal();
terminal
@@ -4008,12 +4287,14 @@ mod tests {
#[test]
fn opencode_new_session_ref_replaces_existing_session_ref() {
let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle);
terminal
- .set_agent_session_ref(
+ .set_agent_session_ref_for_session_start(
"herdr:opencode".into(),
"opencode".into(),
crate::agent_resume::AgentSessionRef::id("opencode-old"),
Some(20),
+ Some("new".into()),
)
.expect("initial session should be accepted");
@@ -4042,11 +4323,12 @@ mod tests {
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
terminal
- .set_agent_session_ref(
+ .set_agent_session_ref_for_session_start(
"herdr:pi".into(),
"pi".into(),
crate::agent_resume::AgentSessionRef::id("pi-old"),
Some(20),
+ Some("new".into()),
)
.expect("initial session should be accepted");
terminal.set_agent_name("reviewer".into());
@@ -4104,12 +4386,14 @@ mod tests {
#[test]
fn opencode_session_ref_without_start_source_does_not_replace_existing() {
let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle);
terminal
- .set_agent_session_ref(
+ .set_agent_session_ref_for_session_start(
"herdr:opencode".into(),
"opencode".into(),
crate::agent_resume::AgentSessionRef::id("opencode-old"),
Some(20),
+ Some("new".into()),
)
.expect("initial session should be accepted");
@@ -4303,6 +4587,13 @@ mod tests {
fn foreground_agent_session_replaces_stale_different_owner_hook_authority() {
let mut terminal = test_terminal();
let now = std::time::Instant::now();
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::OpenCode,
+ "herdr:opencode",
+ "opencode",
+ crate::agent_resume::AgentSessionRef::id("opencode-session").unwrap(),
+ );
terminal
.set_hook_authority_at(
"herdr:opencode".into(),
@@ -4356,13 +4647,22 @@ mod tests {
assert!(late_old_session.is_none());
terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle);
+ terminal
+ .set_agent_session_ref_for_session_start(
+ "herdr:opencode".into(),
+ "opencode".into(),
+ crate::agent_resume::AgentSessionRef::id("opencode-new-session"),
+ Some(23),
+ Some("new".into()),
+ )
+ .expect("fresh root session");
let fresh_session = terminal.set_hook_authority_with_session_ref(
"herdr:opencode".into(),
"opencode".into(),
AgentState::Working,
None,
crate::agent_resume::AgentSessionRef::id("opencode-new-session"),
- Some(23),
+ Some(24),
);
assert!(fresh_session.is_some());
}
@@ -4425,8 +4725,16 @@ mod tests {
}
#[test]
- fn hook_authority_preserves_current_session_ref_when_incoming_ref_differs() {
+ fn hook_authority_rejects_state_from_a_different_session() {
let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle);
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::OpenCode,
+ "herdr:opencode",
+ "opencode",
+ crate::agent_resume::AgentSessionRef::id("opencode-session").unwrap(),
+ );
terminal
.set_hook_authority_with_session_ref(
"herdr:opencode".into(),
@@ -4438,19 +4746,17 @@ mod tests {
)
.expect("initial session should be accepted");
- let mutation = terminal
- .set_hook_authority_with_session_ref(
- "herdr:opencode".into(),
- "opencode".into(),
- AgentState::Blocked,
- Some("needs approval".into()),
- crate::agent_resume::AgentSessionRef::id("nested-session"),
- Some(21),
- )
- .expect("state update should still be accepted");
+ let mutation = terminal.set_hook_authority_with_session_ref(
+ "herdr:opencode".into(),
+ "opencode".into(),
+ AgentState::Blocked,
+ Some("needs approval".into()),
+ crate::agent_resume::AgentSessionRef::id("nested-session"),
+ Some(21),
+ );
- assert!(!mutation.session_ref_changed);
- assert_eq!(terminal.state, AgentState::Blocked);
+ assert!(mutation.is_none());
+ assert_eq!(terminal.state, AgentState::Working);
assert_eq!(
terminal
.hook_authority
@@ -4498,6 +4804,13 @@ mod tests {
fn clearing_hook_authority_clears_session_ref() {
let mut terminal = test_terminal();
let session_path = test_session_path("pi.jsonl");
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::Pi,
+ "herdr:pi",
+ "pi",
+ crate::agent_resume::AgentSessionRef::path(session_path.clone()).unwrap(),
+ );
terminal.set_hook_authority_with_session_ref(
"herdr:pi".into(),
"pi".into(),
@@ -4516,28 +4829,7 @@ mod tests {
}
#[test]
- fn release_agent_clears_session_ref() {
- let mut terminal = test_terminal();
- let session_path = test_session_path("pi.jsonl");
- terminal.set_hook_authority_with_session_ref(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- crate::agent_resume::AgentSessionRef::path(session_path),
- Some(20),
- );
-
- let mutation = terminal
- .release_agent_with_mutation("herdr:pi", "pi", Some(21))
- .expect("accepted release");
-
- assert!(mutation.session_ref_changed);
- assert!(terminal.hook_authority.is_none());
- }
-
- #[test]
- fn agent_alias_survives_detection_uncertainty_but_not_replacement_or_release() {
+ fn agent_alias_survives_detection_uncertainty_and_reported_release_but_not_replacement() {
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::Pi), AgentState::Working);
terminal.set_agent_name("reviewer".into());
@@ -4549,10 +4841,37 @@ mod tests {
assert!(terminal.agent_name.is_none());
terminal.set_agent_name("replacement".into());
- terminal
+ let mutation = terminal
.release_agent_with_mutation("herdr:codex", "codex", None)
.expect("detected agent release should be accepted");
- assert!(terminal.agent_name.is_none());
+ assert!(!mutation.agent_released);
+ assert_eq!(terminal.agent_name.as_deref(), Some("replacement"));
+ assert_eq!(terminal.detected_agent, Some(Agent::Codex));
+ }
+
+ #[test]
+ fn custom_release_preserves_process_owned_agent_state() {
+ let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ terminal
+ .set_hook_authority(
+ "custom:pi".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ Some(10),
+ )
+ .expect("custom state should be accepted");
+
+ let mutation = terminal
+ .release_agent_with_mutation("custom:pi", "pi", Some(11))
+ .expect("custom release should be accepted");
+
+ assert!(!mutation.agent_released);
+ assert!(terminal.hook_authority.is_none());
+ assert_eq!(terminal.detected_agent, Some(Agent::Pi));
+ assert_eq!(terminal.effective_agent_label(), Some("pi"));
+ assert_eq!(terminal.state, AgentState::Idle);
}
#[test]
@@ -4608,6 +4927,14 @@ mod tests {
#[test]
fn accepted_same_kind_hook_owner_replacement_clears_the_alias() {
let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::Pi,
+ "herdr:pi",
+ "pi",
+ crate::agent_resume::AgentSessionRef::path(test_session_path("first.jsonl")).unwrap(),
+ );
terminal
.set_hook_authority_at(
"herdr:pi".into(),
@@ -4694,11 +5021,12 @@ mod tests {
#[test]
fn process_exit_clears_matching_persisted_session_ref() {
let mut terminal = test_terminal();
+ let session_ref =
+ crate::agent_resume::AgentSessionRef::path(test_session_path("pi.jsonl")).unwrap();
terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession {
source: "herdr:pi".into(),
agent: "pi".into(),
- session_ref: crate::agent_resume::AgentSessionRef::path(test_session_path("pi.jsonl"))
- .unwrap(),
+ session_ref: session_ref.clone(),
});
terminal.set_detected_state(Some(Agent::Pi), AgentState::Working);
@@ -4714,6 +5042,15 @@ mod tests {
assert!(mutation.session_ref_changed);
assert!(terminal.persisted_agent_session.is_none());
+
+ let delayed = terminal.set_agent_session_ref(
+ "herdr:pi".into(),
+ "pi".into(),
+ Some(session_ref),
+ Some(21),
+ );
+ assert!(delayed.is_none());
+ assert!(terminal.persisted_agent_session.is_none());
}
#[test]
@@ -4831,6 +5168,13 @@ mod tests {
fn detected_agent_disappearance_does_not_clear_full_lifecycle_hook_session_ref() {
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::Hermes), AgentState::Idle);
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::Hermes,
+ "herdr:hermes",
+ "hermes",
+ crate::agent_resume::AgentSessionRef::id("hermes-session").unwrap(),
+ );
terminal.set_hook_authority_with_session_ref(
"herdr:hermes".into(),
"hermes".into(),
@@ -4884,6 +5228,14 @@ mod tests {
#[test]
fn unsequenced_hook_report_is_ignored_after_source_uses_sequence() {
let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::Pi,
+ "herdr:pi",
+ "pi",
+ crate::agent_resume::AgentSessionRef::path(test_session_path("root.jsonl")).unwrap(),
+ );
terminal.set_hook_authority(
"herdr:pi".into(),
"pi".into(),
@@ -4905,26 +5257,16 @@ mod tests {
}
#[test]
- fn stale_release_sequence_is_ignored_for_same_source() {
- let mut terminal = test_terminal();
- terminal.set_hook_authority(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- Some(20),
- );
-
- let change = terminal.release_agent("herdr:pi", "pi", Some(19));
-
- assert!(change.is_none());
- assert_eq!(terminal.state, AgentState::Working);
- assert!(terminal.hook_authority.is_some());
- }
-
- #[test]
fn stale_clear_all_sequence_is_checked_against_current_authority_source() {
let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::Pi,
+ "herdr:pi",
+ "pi",
+ crate::agent_resume::AgentSessionRef::path(test_session_path("root.jsonl")).unwrap(),
+ );
terminal.set_hook_authority(
"herdr:pi".into(),
"pi".into(),
@@ -4943,6 +5285,7 @@ mod tests {
#[test]
fn same_sequence_from_different_sources_is_independent() {
let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
terminal.set_hook_authority(
"herdr:pi".into(),
"pi".into(),