fix: make known-agent exit process-owned

refs #1792
refs #1648
This commit is contained in:
Ogulcan Celik
2026-07-24 02:50:58 +03:00
parent 169a4fd90c
commit e608a75159
24 changed files with 1914 additions and 1121 deletions
+1
View File
@@ -8,6 +8,7 @@
### Fixed
- Pane applications that query OSC 4 palette colors now inherit the host terminal palette. (#1752)
- Ctrl-clicking a pane URL no longer forwards an unmatched mouse release to alternate-screen applications, preventing duplicate browser tabs. (#1761)
- Known-agent integrations now leave pane ownership to confirmed process exit, so restarting Pi with the same saved session restores lifecycle state even with custom working UI. (#1792)
- OMP integration install, status, and uninstall now respect `PI_CONFIG_DIR` when `PI_CODING_AGENT_DIR` is not set, and installation refuses extension-directory collisions with Pi. (#1696)
- Physical Escape key records on native Windows now bypass raw VT report framing, so pane applications receive Escape immediately and reliably. (#1736)
+1 -1
View File
@@ -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")
+77 -27
View File
@@ -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]
+6 -16
View File
@@ -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, .. }
)));
+141 -1
View File
@@ -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(()) => {
@@ -3818,6 +3831,133 @@ mod tests {
.is_empty());
}
#[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();
+15 -2
View File
@@ -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)
@@ -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) {
@@ -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
@@ -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
@@ -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,
},
}
@@ -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();
}
});
}
+1 -33
View File
@@ -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();
}
});
}
+10 -9
View File
@@ -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=";
+12 -5
View File
@@ -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,
+73 -44
View File
@@ -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]
@@ -2817,27 +2816,6 @@ fn pi_extension_refreshes_session_ref_before_agent_start_state() {
assert!(report_session < publish_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
@@ -3353,6 +3331,57 @@ fn install_mastracode_writes_hook_and_updates_hooks_json() {
let _ = fs::remove_dir_all(base);
}
#[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();
+81 -41
View File
@@ -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
@@ -3369,6 +3401,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!(
@@ -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
);
}
+18 -12
View File
@@ -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()]
+30 -2
View File
@@ -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(),
@@ -9522,6 +9522,34 @@ next_tab = ""
.unwrap()
.attached_terminal_id
.clone();
server
.app
.state
.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
+112 -3
View File
@@ -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,11 +549,68 @@ 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();
+1200 -857
View File
File diff suppressed because it is too large Load Diff
+34 -14
View File
@@ -1699,10 +1699,19 @@ fn pane_report_agent_updates_effective_state() {
}
let session_path = base.join("pi-session.jsonl");
let session = send_request(
&socket_path,
&format!(
r#"{{"id":"req_hook_session","method":"pane.report_agent_session","params":{{"pane_id":"{}","source":"herdr:pi","agent":"pi","agent_session_path":"{}","session_start_source":"startup","seq":1}}}}"#,
pane_id,
session_path.display()
),
);
assert_eq!(session["result"]["type"], "ok");
let hook = send_request(
&socket_path,
&format!(
r#"{{"id":"req_hook_5","method":"pane.report_agent","params":{{"pane_id":"{}","source":"herdr:pi","agent":"pi","state":"working","message":"thinking","agent_session_path":"{}"}}}}"#,
r#"{{"id":"req_hook_5","method":"pane.report_agent","params":{{"pane_id":"{}","source":"herdr:pi","agent":"pi","state":"working","message":"thinking","agent_session_path":"{}","seq":2}}}}"#,
pane_id,
session_path.display()
),
@@ -1875,7 +1884,7 @@ fn pane_report_agent_accepts_unknown_agent_labels() {
#[cfg(not(target_os = "macos"))]
#[test]
fn pane_release_agent_suppresses_reacquire_during_graceful_exit() {
fn official_release_waits_for_confirmed_process_exit() {
let _lock = test_lock();
let base = unique_test_dir();
let config_home = base.join("config");
@@ -1960,11 +1969,22 @@ fn pane_release_agent_suppresses_reacquire_during_graceful_exit() {
thread::sleep(Duration::from_millis(100));
}
let session_path = base.join("release-session.jsonl");
let session = send_request(
&socket_path,
&format!(
r#"{{"id":"req_release_session","method":"pane.report_agent_session","params":{{"pane_id":"{}","source":"herdr:pi","agent":"pi","agent_session_path":"{}","session_start_source":"startup","seq":1}}}}"#,
pane_id,
session_path.display()
),
);
assert_eq!(session["result"]["type"], "ok");
let hook = send_request(
&socket_path,
&format!(
r#"{{"id":"req_release_4","method":"pane.report_agent","params":{{"pane_id":"{}","source":"herdr:pi","agent":"pi","state":"working"}}}}"#,
pane_id
r#"{{"id":"req_release_4","method":"pane.report_agent","params":{{"pane_id":"{}","source":"herdr:pi","agent":"pi","state":"working","agent_session_path":"{}","seq":2}}}}"#,
pane_id,
session_path.display()
),
);
assert_eq!(hook["result"]["type"], "ok");
@@ -1978,8 +1998,8 @@ fn pane_release_agent_suppresses_reacquire_during_graceful_exit() {
);
assert_eq!(released["result"]["type"], "ok");
let suppression_deadline = Instant::now() + Duration::from_millis(300);
while Instant::now() < suppression_deadline {
let release_observation_deadline = Instant::now() + Duration::from_millis(300);
while Instant::now() < release_observation_deadline {
let pane = send_request(
&socket_path,
&format!(
@@ -1987,11 +2007,11 @@ fn pane_release_agent_suppresses_reacquire_during_graceful_exit() {
pane_id
),
);
assert!(
pane["result"]["pane"]["agent"].is_null(),
"pane reacquired pi during graceful release: {pane}"
assert_eq!(
pane["result"]["pane"]["agent"], "pi",
"official release hid the live Pi process: {pane}"
);
assert_eq!(pane["result"]["pane"]["agent_status"], "unknown");
assert_eq!(pane["result"]["pane"]["agent_status"], "working");
thread::sleep(Duration::from_millis(50));
}
@@ -2013,7 +2033,7 @@ fn pane_release_agent_suppresses_reacquire_during_graceful_exit() {
}
assert!(
Instant::now() < cleared_deadline,
"pi agent was not cleared promptly after release: {pane}"
"pi agent was not cleared promptly after process exit: {pane}"
);
thread::sleep(Duration::from_millis(50));
}
@@ -2443,7 +2463,7 @@ fn metadata_status_subscription_filter_and_ttl_expiry_are_observable() {
let report_agent = send_request(
&socket_path,
&format!(
r#"{{"id":"req_meta_sub_2","method":"pane.report_agent","params":{{"pane_id":"{}","source":"herdr:pi","agent":"pi","state":"working"}}}}"#,
r#"{{"id":"req_meta_sub_2","method":"pane.report_agent","params":{{"pane_id":"{}","source":"custom:pi","agent":"pi","state":"working"}}}}"#,
pane_id
),
);
@@ -2463,7 +2483,7 @@ fn metadata_status_subscription_filter_and_ttl_expiry_are_observable() {
let metadata = send_request(
&socket_path,
&format!(
r#"{{"id":"req_meta_sub_3","method":"pane.report_metadata","params":{{"pane_id":"{}","source":"user:pi-display","agent":"pi","applies_to_source":"herdr:pi","title":"filtered out"}}}}"#,
r#"{{"id":"req_meta_sub_3","method":"pane.report_metadata","params":{{"pane_id":"{}","source":"user:pi-display","agent":"pi","applies_to_source":"custom:pi","title":"filtered out"}}}}"#,
pane_id
),
);
@@ -2489,7 +2509,7 @@ fn metadata_status_subscription_filter_and_ttl_expiry_are_observable() {
let metadata = send_request(
&socket_path,
&format!(
r#"{{"id":"req_meta_sub_4","method":"pane.report_metadata","params":{{"pane_id":"{}","source":"user:pi-display","agent":"pi","applies_to_source":"herdr:pi","title":"short lived","ttl_ms":100}}}}"#,
r#"{{"id":"req_meta_sub_4","method":"pane.report_metadata","params":{{"pane_id":"{}","source":"user:pi-display","agent":"pi","applies_to_source":"custom:pi","title":"short lived","ttl_ms":100}}}}"#,
pane_id
),
);
+2 -2
View File
@@ -26,7 +26,7 @@ fn agent_wait_exits_immediately_when_status_already_matches() {
let reported = send_request(
&socket_path,
&format!(
r#"{{"id":"req_cli_immediate_2","method":"pane.report_agent","params":{{"pane_id":"{}","source":"herdr:pi","agent":"pi","state":"idle"}}}}"#,
r#"{{"id":"req_cli_immediate_2","method":"pane.report_agent","params":{{"pane_id":"{}","source":"custom:test","agent":"pi","state":"idle"}}}}"#,
pane_id
),
);
@@ -78,7 +78,7 @@ fn agent_wait_times_out_when_status_does_not_match() {
let reported = send_request(
&socket_path,
&format!(
r#"{{"id":"req_cli_timeout_2","method":"pane.report_agent","params":{{"pane_id":"{}","source":"herdr:pi","agent":"pi","state":"working"}}}}"#,
r#"{{"id":"req_cli_timeout_2","method":"pane.report_agent","params":{{"pane_id":"{}","source":"custom:test","agent":"pi","state":"working"}}}}"#,
pane_id
),
);
+1 -1
View File
@@ -230,7 +230,7 @@ fn integration_commands_run_locally_when_server_is_missing() {
.unwrap();
assert_eq!(integration_status.status.code(), Some(0));
let status_stdout = String::from_utf8_lossy(&integration_status.stdout);
assert!(status_stdout.contains("pi: current (v6)"));
assert!(status_stdout.contains("pi: current (v7)"));
assert!(status_stdout.contains("claude: not installed"));
let integration_uninstall = Command::new(env!("CARGO_BIN_EXE_herdr"))
+78 -11
View File
@@ -1213,6 +1213,8 @@ fn live_handoff_accepts_canonical_pane_id_from_child_env() {
#[test]
fn live_handoff_keeps_unmanaged_agent_name_bound_to_saved_session() {
use std::os::unix::fs::PermissionsExt;
let _lock = test_lock();
let base = unique_test_dir();
let config_home = base.join("config");
@@ -1220,6 +1222,18 @@ fn live_handoff_keeps_unmanaged_agent_name_bound_to_saved_session() {
let api_socket = runtime_dir.join("herdr.sock");
let old_session = base.join("old-session.jsonl");
let new_session = base.join("new-session.jsonl");
let started_marker = base.join("agent-started");
let fake_pi = base.join("pi");
fs::create_dir_all(&base).unwrap();
fs::write(
&fake_pi,
format!(
"#!/bin/sh\nexport HERDR_AGENT=pi\necho started > {}\nexec /bin/sleep 30\n",
started_marker.display()
),
)
.unwrap();
fs::set_permissions(&fake_pi, fs::Permissions::from_mode(0o755)).unwrap();
let spawned = spawn_server(&config_home, &runtime_dir, &api_socket);
wait_for_socket(&api_socket, Duration::from_secs(10));
@@ -1236,6 +1250,30 @@ fn live_handoff_keeps_unmanaged_agent_name_bound_to_saved_session() {
.as_str()
.unwrap()
.to_string();
assert_ok(request(
&api_socket,
serde_json::json!({
"id": "test:pane:start-agent",
"method": "pane.send_input",
"params": {"pane_id": pane_id, "text": fake_pi, "keys": ["Enter"]}
}),
));
support::wait_for_file(&started_marker, Duration::from_secs(5));
assert_ok(request(
&api_socket,
serde_json::json!({
"id": "test:agent:session",
"method": "pane.report_agent_session",
"params": {
"pane_id": pane_id,
"source": "herdr:pi",
"agent": "pi",
"seq": 1,
"agent_session_path": old_session,
"session_start_source": "startup"
}
}),
));
assert_ok(request(
&api_socket,
serde_json::json!({
@@ -1246,11 +1284,30 @@ fn live_handoff_keeps_unmanaged_agent_name_bound_to_saved_session() {
"source": "herdr:pi",
"agent": "pi",
"state": "idle",
"seq": 1,
"seq": 2,
"agent_session_path": old_session
}
}),
));
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let response = request(
&api_socket,
serde_json::json!({
"id": "test:agent:wait-for-process",
"method": "agent.get",
"params": {"target": pane_id}
}),
);
if response.get("result").is_some() {
break;
}
assert!(
Instant::now() < deadline,
"agent process was not detected: {response}"
);
thread::sleep(Duration::from_millis(25));
}
assert_ok(request(
&api_socket,
serde_json::json!({
@@ -1276,21 +1333,31 @@ fn live_handoff_keeps_unmanaged_agent_name_bound_to_saved_session() {
"pane_id": pane_id,
"source": "herdr:pi",
"agent": "pi",
"seq": 2,
"seq": 3,
"agent_session_path": new_session,
"session_start_source": "new"
}
}),
));
let old_name = request(
&api_socket,
serde_json::json!({
"id": "test:agent:get-old-name",
"method": "agent.get",
"params": {"target": "reviewer"}
}),
);
assert_eq!(old_name["error"]["code"], "agent_not_found", "{old_name}");
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let old_name = request(
&api_socket,
serde_json::json!({
"id": "test:agent:get-old-name",
"method": "agent.get",
"params": {"target": "reviewer"}
}),
);
if old_name["error"]["code"] == "agent_not_found" {
break;
}
assert!(
Instant::now() < deadline,
"old session alias was not cleared: {old_name}"
);
thread::sleep(Duration::from_millis(25));
}
let _ = request(
&api_socket,