fix: protect root agent restore sessions

refs #712
This commit is contained in:
Ogulcan Celik 2026-06-21 02:38:14 +03:00
parent 73b137a4ae
commit 92a10fc044
16 changed files with 495 additions and 236 deletions

View File

@ -69,7 +69,7 @@ pub fn session_ref_from_report(
agent_session_id.and_then(AgentSessionRef::id)
}
pub fn normalize_claude_session_start_source(value: Option<String>) -> Option<String> {
pub fn normalize_session_start_source(value: Option<String>) -> Option<String> {
match value.as_deref().map(str::trim) {
Some(source @ ("startup" | "resume" | "clear" | "compact")) => Some(source.to_string()),
_ => None,
@ -501,32 +501,29 @@ mod tests {
}
#[test]
fn normalize_claude_session_start_source_allows_known_claude_values() {
fn normalize_session_start_source_allows_known_values() {
assert_eq!(
normalize_claude_session_start_source(Some("startup".into())),
normalize_session_start_source(Some("startup".into())),
Some("startup".into())
);
assert_eq!(
normalize_claude_session_start_source(Some("resume".into())),
normalize_session_start_source(Some("resume".into())),
Some("resume".into())
);
assert_eq!(
normalize_claude_session_start_source(Some("clear".into())),
normalize_session_start_source(Some("clear".into())),
Some("clear".into())
);
assert_eq!(
normalize_claude_session_start_source(Some("compact".into())),
normalize_session_start_source(Some("compact".into())),
Some("compact".into())
);
assert_eq!(
normalize_claude_session_start_source(Some(" resume ".into())),
normalize_session_start_source(Some(" resume ".into())),
Some("resume".into())
);
assert_eq!(
normalize_claude_session_start_source(Some("other".into())),
None
);
assert_eq!(normalize_claude_session_start_source(None), None);
assert_eq!(normalize_session_start_source(Some("other".into())), None);
assert_eq!(normalize_session_start_source(None), None);
}
#[test]

View File

@ -1239,7 +1239,7 @@ impl App {
source: params.source,
agent_label,
seq: params.seq,
session_start_source: crate::agent_resume::normalize_claude_session_start_source(
session_start_source: crate::agent_resume::normalize_session_start_source(
params.session_start_source,
),
});

View File

@ -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=claude
# HERDR_INTEGRATION_VERSION=6
# HERDR_INTEGRATION_VERSION=7
param([string]$Action = "")
@ -17,6 +17,7 @@ try {
exit 0
}
if (-not [string]::IsNullOrWhiteSpace($payload.agent_id)) { exit 0 }
if ($payload.hook_event_name -eq "SubagentStop") { exit 0 }
$sessionId = $payload.session_id

View File

@ -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=claude
# HERDR_INTEGRATION_VERSION=6
# HERDR_INTEGRATION_VERSION=7
set -eu
@ -50,6 +50,8 @@ if hook_input_file:
hook_event_name = str(hook_input.get("hook_event_name") or "")
is_subagent = bool(hook_input.get("agent_id"))
if is_subagent:
raise SystemExit(0)
if hook_event_name == "SubagentStop":
# SubagentStop is a completion event. Older Herdr integrations mapped it
# to durable working, but Claude recap/away-summary can emit it after the

View File

@ -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=codex
# HERDR_INTEGRATION_VERSION=5
# HERDR_INTEGRATION_VERSION=6
param([string]$Action = "")
@ -17,11 +17,29 @@ try {
exit 0
}
if ($payload.hook_event_name -and $payload.hook_event_name -ne "SessionStart") { exit 0 }
$sessionId = $payload.session_id
if ([string]::IsNullOrWhiteSpace($sessionId)) { exit 0 }
$seq = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
try {
& herdr pane report-agent-session $env:HERDR_PANE_ID --source herdr:codex --agent codex --seq $seq --agent-session-id $sessionId 2>$null | Out-Null
$args = @(
"pane",
"report-agent-session",
$env:HERDR_PANE_ID,
"--source",
"herdr:codex",
"--agent",
"codex",
"--seq",
"$seq",
"--agent-session-id",
"$sessionId"
)
if ($payload.hook_event_name -eq "SessionStart" -and $payload.source -is [string] -and -not [string]::IsNullOrWhiteSpace($payload.source)) {
$args += @("--session-start-source", "$($payload.source)")
}
& herdr @args 2>$null | Out-Null
} catch {
}

View File

@ -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=codex
# HERDR_INTEGRATION_VERSION=5
# HERDR_INTEGRATION_VERSION=6
set -eu
@ -48,21 +48,31 @@ if hook_input_file:
except Exception:
hook_input = {}
hook_event_name = str(hook_input.get("hook_event_name") or "")
if hook_event_name and hook_event_name != "SessionStart":
raise SystemExit(0)
request_id = f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}"
report_seq = time.time_ns()
session_id = hook_input.get("session_id")
agent_session_id = session_id if isinstance(session_id, str) and session_id else None
session_start_source = hook_input.get("source") if hook_event_name == "SessionStart" else None
if not isinstance(session_start_source, str) or not session_start_source:
session_start_source = None
if agent_session_id:
params = {
"pane_id": pane_id,
"source": source,
"agent": "codex",
"seq": report_seq,
"agent_session_id": agent_session_id,
}
if session_start_source:
params["session_start_source"] = session_start_source
request = {
"id": request_id,
"method": "pane.report_agent_session",
"params": {
"pane_id": pane_id,
"source": source,
"agent": "codex",
"seq": report_seq,
"agent_session_id": agent_session_id,
},
"params": params,
}
else:
raise SystemExit(0)

View File

@ -1,7 +1,7 @@
"""Hermes plugin installed by Herdr to report agent lifecycle state."""
# HERDR_INTEGRATION_ID=hermes
# HERDR_INTEGRATION_VERSION=2
# HERDR_INTEGRATION_VERSION=3
from __future__ import annotations
@ -71,10 +71,6 @@ def _report(state: str, **kwargs) -> None:
_send("pane.report_agent", params)
def _release() -> None:
_send("pane.release_agent", {})
def _working(**kwargs) -> None:
_report("working", **kwargs)
@ -87,11 +83,6 @@ def _idle(**kwargs) -> None:
_report("idle", **kwargs)
def _finalize(**kwargs) -> None:
del kwargs
_release()
def register(ctx):
ctx.register_hook("on_session_start", _idle)
ctx.register_hook("pre_llm_call", _working)
@ -102,4 +93,3 @@ def register(ctx):
ctx.register_hook("post_approval_response", _working)
ctx.register_hook("post_llm_call", _idle)
ctx.register_hook("on_session_end", _idle)
ctx.register_hook("on_session_finalize", _finalize)

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=kilo
// HERDR_INTEGRATION_VERSION=1
// HERDR_INTEGRATION_VERSION=2
import net from "node:net";
@ -96,10 +96,6 @@ function reportState(state, sessionID) {
return request("pane.report_agent", params);
}
function releaseAgent() {
return request("pane.release_agent", {});
}
export const HerdrAgentStatePlugin = async () => {
if (
process.env.HERDR_ENV !== "1" ||
@ -149,7 +145,6 @@ export const HerdrAgentStatePlugin = async () => {
await reportState("idle", sessionID);
break;
case "session.deleted":
await releaseAgent();
break;
default:
break;

View File

@ -2,11 +2,11 @@
# 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=3
# HERDR_INTEGRATION_VERSION=4
param([string]$Action = "")
if (@("session", "working", "blocked", "idle", "release") -notcontains $Action) { exit 0 }
if (@("session", "working", "blocked", "idle") -notcontains $Action) { exit 0 }
if ($env:HERDR_ENV -ne "1") { exit 0 }
if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 }
@ -21,9 +21,7 @@ $seq = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
$sessionId = if ($null -ne $payload -and -not [string]::IsNullOrWhiteSpace($payload.session_id)) { $payload.session_id } else { $null }
try {
if ($Action -eq "release") {
& herdr pane release-agent $env:HERDR_PANE_ID --source herdr:kimi --agent kimi --seq $seq 2>$null | Out-Null
} elseif ($Action -eq "session") {
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
} else {

View File

@ -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=kimi
# HERDR_INTEGRATION_VERSION=3
# HERDR_INTEGRATION_VERSION=4
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
session|working|blocked|idle|release) ;;
session|working|blocked|idle) ;;
*) exit 0 ;;
esac
@ -54,18 +54,7 @@ agent_session_id = session_id if isinstance(session_id, str) and session_id else
request_id = f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}"
report_seq = time.time_ns()
if action == "release":
request = {
"id": request_id,
"method": "pane.release_agent",
"params": {
"pane_id": pane_id,
"source": source,
"agent": agent,
"seq": report_seq,
},
}
elif action == "session":
if action == "session":
if not agent_session_id:
raise SystemExit(0)
request = {

View File

@ -100,6 +100,35 @@ function parseDurationEnv(name: string, fallback: number): number {
return parsed;
}
function currentSessionRef(): Record<string, unknown> | undefined {
if (currentAgentSessionPath) {
return { agent_session_path: currentAgentSessionPath };
}
if (currentAgentSessionId) {
return { agent_session_id: currentAgentSessionId };
}
return undefined;
}
function reportSession(): Promise<void> {
const sessionRef = currentSessionRef();
if (!sessionRef) {
return Promise.resolve();
}
return sendRequest({
id: `${source}:session:${Date.now()}:${Math.random().toString(36).slice(2)}`,
method: "pane.report_agent_session",
params: {
pane_id: paneId,
source,
agent: "omp",
seq: nextReportSeq(),
...sessionRef,
},
});
}
function sendState(state: AgentState, message?: string, seq = nextReportSeq()): Promise<void> {
return sendRequest({
id: `${source}:${Date.now()}:${Math.random().toString(36).slice(2)}`,
@ -169,19 +198,6 @@ function retryableErrorMessage(event: any): string | undefined {
return errorMessage || "retryable provider error";
}
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(),
},
});
}
export default function (pi) {
if (!enabled()) {
return;
@ -287,11 +303,12 @@ export default function (pi) {
});
pi.on("session_start", (_event, ctx) => {
rootSession = ctx?.hasUI === true;
if (!rootSession) {
if (ctx?.hasUI !== true) {
return;
}
rootSession = true;
updateSessionRef(ctx);
void reportSession();
publishState(true);
});
@ -326,12 +343,4 @@ export default function (pi) {
scheduleIdle();
});
pi.on("session_shutdown", async () => {
if (!rootSession) {
return;
}
clearPendingTimers();
await releaseAgent();
});
}

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=opencode
// HERDR_INTEGRATION_VERSION=5
// HERDR_INTEGRATION_VERSION=6
import net from "node:net";
@ -96,10 +96,6 @@ function reportState(state, sessionID) {
return request("pane.report_agent", params);
}
function releaseAgent() {
return request("pane.release_agent", {});
}
export const HerdrAgentStatePlugin = async () => {
if (
process.env.HERDR_ENV !== "1" ||
@ -149,7 +145,6 @@ export const HerdrAgentStatePlugin = async () => {
await reportState("idle", sessionID);
break;
case "session.deleted":
await releaseAgent();
break;
default:
break;

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=2
// HERDR_INTEGRATION_VERSION=3
// @ts-nocheck
import { createConnection } from "node:net";
@ -100,6 +100,35 @@ function withSessionRef(params: Record<string, unknown>): Record<string, unknown
return params;
}
function currentSessionRef(): Record<string, unknown> | undefined {
if (currentAgentSessionPath) {
return { agent_session_path: currentAgentSessionPath };
}
if (currentAgentSessionId) {
return { agent_session_id: currentAgentSessionId };
}
return undefined;
}
function reportSession(): Promise<void> {
const sessionRef = currentSessionRef();
if (!sessionRef) {
return Promise.resolve();
}
return sendRequest({
id: `${source}:session:${Date.now()}:${Math.random().toString(36).slice(2)}`,
method: "pane.report_agent_session",
params: {
pane_id: paneId,
source,
agent: "pi",
seq: nextReportSeq(),
...sessionRef,
},
});
}
function sendState(state: AgentState, message?: string, seq = nextReportSeq()): Promise<void> {
return sendRequest({
id: `${source}:${Date.now()}:${Math.random().toString(36).slice(2)}`,
@ -169,19 +198,6 @@ function retryableErrorMessage(event: any): string | undefined {
return errorMessage || "retryable provider error";
}
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(),
},
});
}
export default function (pi) {
if (!enabled()) {
return;
@ -197,6 +213,7 @@ export default function (pi) {
let lastMessage: string | undefined;
let idleTimer: ReturnType<typeof setTimeout> | undefined;
let retryTimer: ReturnType<typeof setTimeout> | undefined;
let rootSession = false;
function clearTimer(timer: ReturnType<typeof setTimeout> | undefined) {
if (timer) {
@ -250,11 +267,6 @@ export default function (pi) {
idleTimer.unref?.();
}
pi.on("session_start", (_event, ctx) => {
updateSessionRef(ctx);
publishState(true);
});
function holdForRetry(message: string) {
clearPendingTimers();
retryHoldActive = true;
@ -272,6 +284,9 @@ export default function (pi) {
}
pi.events.on("herdr:blocked", (data) => {
if (!rootSession) {
return;
}
if (!data?.active) {
blockedCount = Math.max(0, blockedCount - 1);
if (blockedCount === 0) {
@ -287,7 +302,20 @@ export default function (pi) {
publishState();
});
pi.on("session_start", (_event, ctx) => {
if (ctx?.hasUI !== true) {
return;
}
rootSession = true;
updateSessionRef(ctx);
void reportSession();
publishState(true);
});
pi.on("agent_start", () => {
if (!rootSession) {
return;
}
clearPendingTimers();
clearFailureState();
agentActive = true;
@ -295,6 +323,9 @@ export default function (pi) {
});
pi.on("agent_end", (event) => {
if (!rootSession) {
return;
}
if (!agentActive) {
// Pi can emit duplicate/late end events while auto-retry is already
// holding the pane in Working. Do not let an unqualified duplicate end
@ -312,9 +343,4 @@ export default function (pi) {
scheduleIdle();
});
pi.on("session_shutdown", async () => {
clearPendingTimers();
await releaseAgent();
});
}

View File

@ -12,7 +12,7 @@ pub(crate) const HERDR_TAB_ID_ENV_VAR: &str = "HERDR_TAB_ID";
pub(crate) const HERDR_WORKSPACE_ID_ENV_VAR: &str = "HERDR_WORKSPACE_ID";
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 = 2;
const PI_INTEGRATION_VERSION: u32 = 3;
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 = 3;
@ -27,7 +27,7 @@ const CLAUDE_HOOK_ASSET: &str = if cfg!(windows) {
} else {
include_str!("assets/claude/herdr-agent-state.sh")
};
const CLAUDE_INTEGRATION_VERSION: u32 = 6;
const CLAUDE_INTEGRATION_VERSION: u32 = 7;
const CLAUDE_CONFIG_DIR_ENV_VAR: &str = "CLAUDE_CONFIG_DIR";
const CODEX_HOOK_INSTALL_NAME: &str = if cfg!(windows) {
"herdr-agent-state.ps1"
@ -39,7 +39,7 @@ const CODEX_HOOK_ASSET: &str = if cfg!(windows) {
} else {
include_str!("assets/codex/herdr-agent-state.sh")
};
const CODEX_INTEGRATION_VERSION: u32 = 5;
const CODEX_INTEGRATION_VERSION: u32 = 6;
const CODEX_HOME_ENV_VAR: &str = "CODEX_HOME";
const KIMI_HOOK_INSTALL_NAME: &str = if cfg!(windows) {
"herdr-agent-state.ps1"
@ -51,12 +51,12 @@ const KIMI_HOOK_ASSET: &str = if cfg!(windows) {
} else {
include_str!("assets/kimi/herdr-agent-state.sh")
};
const KIMI_INTEGRATION_VERSION: u32 = 3;
const KIMI_INTEGRATION_VERSION: u32 = 4;
const KIMI_CODE_HOME_ENV_VAR: &str = "KIMI_CODE_HOME";
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";
const KIMI_HOOK_EVENTS: [(&str, &str); 10] = [
const KIMI_HOOK_EVENTS: [(&str, &str); 9] = [
("SessionStart", "session"),
("UserPromptSubmit", "working"),
("PreToolUse", "working"),
@ -66,7 +66,6 @@ const KIMI_HOOK_EVENTS: [(&str, &str); 10] = [
("PermissionResult", "working"),
("Stop", "idle"),
("Interrupt", "idle"),
("SessionEnd", "release"),
];
const COPILOT_HOOK_INSTALL_NAME: &str = if cfg!(windows) {
"herdr-agent-state.ps1"
@ -136,16 +135,16 @@ const DROID_REMOVED_LIFECYCLE_HOOK_EVENTS: [(&str, &str); 9] = [
];
const OPENCODE_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state.js";
const OPENCODE_PLUGIN_ASSET: &str = include_str!("assets/opencode/herdr-agent-state.js");
const OPENCODE_INTEGRATION_VERSION: u32 = 5;
const OPENCODE_INTEGRATION_VERSION: u32 = 6;
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 = 1;
const KILO_INTEGRATION_VERSION: u32 = 2;
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 = 2;
const HERMES_INTEGRATION_VERSION: u32 = 3;
const QODERCLI_HOOK_INSTALL_NAME: &str = if cfg!(windows) {
"herdr-agent-state.ps1"
} else {
@ -4434,7 +4433,7 @@ mod tests {
assert_eq!(claude.path, hook_path);
assert_eq!(claude.installed_version, Some(1));
assert_eq!(claude.expected_version, 6);
assert_eq!(claude.expected_version, 7);
assert_eq!(claude.state, IntegrationStatusKind::Outdated);
std::env::remove_var("HOME");
@ -4464,7 +4463,7 @@ mod tests {
assert_eq!(claude.path, hook_path);
assert_eq!(claude.installed_version, Some(2));
assert_eq!(claude.expected_version, 6);
assert_eq!(claude.expected_version, 7);
assert_eq!(claude.state, IntegrationStatusKind::Outdated);
std::env::remove_var("HOME");
@ -4597,7 +4596,7 @@ mod tests {
assert_eq!(codex.path, hook_path);
assert_eq!(codex.installed_version, Some(2));
assert_eq!(codex.expected_version, 5);
assert_eq!(codex.expected_version, 6);
assert_eq!(codex.state, IntegrationStatusKind::Outdated);
std::env::remove_var("HOME");
@ -5966,28 +5965,66 @@ mod tests {
#[test]
fn bundled_integration_assets_report_session_refs() {
assert!(PI_EXTENSION_ASSET.contains("agent_session_path: currentAgentSessionPath"));
assert!(PI_EXTENSION_ASSET.contains("agent_session_id: currentAgentSessionId"));
assert!(PI_EXTENSION_ASSET.contains("publishState(true)"));
assert!(OMP_EXTENSION_ASSET.contains("agent_session_path: currentAgentSessionPath"));
assert!(OMP_EXTENSION_ASSET.contains("agent_session_id: currentAgentSessionId"));
assert!(OMP_EXTENSION_ASSET.contains("publishState(true)"));
assert!(CLAUDE_HOOK_ASSET.contains("agent_session_id"));
assert!(CLAUDE_HOOK_ASSET.contains("agent_session_path"));
assert!(CLAUDE_HOOK_ASSET.contains("session_start_source"));
assert!(CLAUDE_HOOK_ASSET.contains("pane.report_agent_session"));
assert!(PI_EXTENSION_ASSET.contains("agent_session_path"));
assert!(PI_EXTENSION_ASSET.contains("agent_session_id"));
assert!(PI_EXTENSION_ASSET.contains("ctx?.hasUI !== true"));
assert!(PI_EXTENSION_ASSET.contains("pane.report_agent_session"));
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_end\""));
assert!(!PI_EXTENSION_ASSET.contains("pane.release_agent"));
assert!(!PI_EXTENSION_ASSET.contains("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"));
assert!(OMP_EXTENSION_ASSET.contains("pane.report_agent_session"));
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("session_shutdown"));
assert!(
CLAUDE_HOOK_ASSET.contains("agent_session_id")
|| CLAUDE_HOOK_ASSET.contains("--agent-session-id")
);
assert!(
CLAUDE_HOOK_ASSET.contains("agent_session_path")
|| CLAUDE_HOOK_ASSET.contains("--agent-session-path")
);
assert!(CLAUDE_HOOK_ASSET.contains("agent_id"));
assert!(
CLAUDE_HOOK_ASSET.contains("session_start_source")
|| CLAUDE_HOOK_ASSET.contains("--session-start-source")
);
assert!(
CLAUDE_HOOK_ASSET.contains("pane.report_agent_session")
|| CLAUDE_HOOK_ASSET.contains("report-agent-session")
);
assert!(!CLAUDE_HOOK_ASSET.contains("\"state\": action"));
assert!(!CLAUDE_HOOK_ASSET.contains("pane.release_agent"));
assert!(CODEX_HOOK_ASSET.contains("HERDR_HOOK_INPUT_FILE"));
assert!(CODEX_HOOK_ASSET.contains("agent_session_id"));
assert!(CODEX_HOOK_ASSET.contains("pane.report_agent_session"));
assert!(
CODEX_HOOK_ASSET.contains("HERDR_HOOK_INPUT_FILE")
|| CODEX_HOOK_ASSET.contains("In.ReadToEnd")
);
assert!(
CODEX_HOOK_ASSET.contains("agent_session_id")
|| CODEX_HOOK_ASSET.contains("--agent-session-id")
);
assert!(
CODEX_HOOK_ASSET.contains("session_start_source")
|| CODEX_HOOK_ASSET.contains("--session-start-source")
);
assert!(
CODEX_HOOK_ASSET.contains("pane.report_agent_session")
|| CODEX_HOOK_ASSET.contains("report-agent-session")
);
assert!(!CODEX_HOOK_ASSET.contains("\"state\": action"));
assert!(!CODEX_HOOK_ASSET.contains("pane.release_agent"));
assert!(KIMI_HOOK_ASSET.contains("source = \"herdr:kimi\""));
assert!(KIMI_HOOK_ASSET.contains("agent_session_id"));
assert!(KIMI_HOOK_ASSET.contains("pane.report_agent_session"));
assert!(KIMI_HOOK_ASSET.contains("\"state\": action"));
assert!(KIMI_HOOK_ASSET.contains("pane.release_agent"));
assert!(!KIMI_HOOK_ASSET.contains("pane.release_agent"));
assert!(COPILOT_HOOK_ASSET.contains("agent_session_id"));
assert!(COPILOT_HOOK_ASSET.contains("pane.report_agent_session"));
assert!(!COPILOT_HOOK_ASSET.contains("\"state\":"));
@ -6006,16 +6043,18 @@ mod tests {
assert!(OPENCODE_PLUGIN_ASSET.contains("params.agent_session_id = sessionID"));
assert!(OPENCODE_PLUGIN_ASSET.contains("pane.report_agent_session"));
assert!(OPENCODE_PLUGIN_ASSET.contains("reportState"));
assert!(OPENCODE_PLUGIN_ASSET.contains("pane.release_agent"));
assert!(!OPENCODE_PLUGIN_ASSET.contains("pane.release_agent"));
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("reportState"));
assert!(KILO_PLUGIN_ASSET.contains("pane.release_agent"));
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\","));
assert!(HERMES_PLUGIN_INIT_ASSET.contains("pane.release_agent"));
assert!(HERMES_PLUGIN_INIT_ASSET.contains("on_session_end"));
assert!(!HERMES_PLUGIN_INIT_ASSET.contains("on_session_finalize"));
assert!(!HERMES_PLUGIN_INIT_ASSET.contains("pane.release_agent"));
assert!(QODERCLI_HOOK_ASSET.contains("HERDR_HOOK_INPUT_FILE"));
assert!(QODERCLI_HOOK_ASSET.contains("agent_session_id"));
assert!(QODERCLI_HOOK_ASSET.contains("pane.report_agent_session"));
@ -6035,26 +6074,19 @@ mod tests {
}
#[test]
fn omp_root_session_guard_is_instance_scoped() {
let export_start = OMP_EXTENSION_ASSET
.find("export default function (pi)")
.expect("omp extension exports a function");
let root_session_decl = OMP_EXTENSION_ASSET
.find("let rootSession = false")
.expect("omp extension declares root session guard");
fn omp_session_hook_ignores_non_ui_sessions() {
let session_start_handler = OMP_EXTENSION_ASSET
.find("pi.on(\"session_start\"")
.expect("omp extension registers session_start handler");
let non_ui_guard = OMP_EXTENSION_ASSET
.find("ctx?.hasUI !== true")
.expect("omp extension checks UI context");
let session_report = OMP_EXTENSION_ASSET
.find("void reportSession()")
.expect("omp extension reports root session");
assert_eq!(
OMP_EXTENSION_ASSET
.matches("let rootSession = false")
.count(),
1
);
assert!(OMP_EXTENSION_ASSET.contains("rootSession = ctx?.hasUI === true"));
assert!(export_start < root_session_decl);
assert!(root_session_decl < session_start_handler);
assert!(session_start_handler < non_ui_guard);
assert!(non_ui_guard < session_report);
}
#[test]

View File

@ -264,6 +264,14 @@ impl TerminalState {
}
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)
|| (previous_detected_agent.is_some()
@ -273,19 +281,20 @@ impl TerminalState {
== previous_detected_agent
})))
{
let durable_session = self.hook_authority.as_ref().and_then(|authority| {
authority.session_ref.as_ref().map(|session_ref| {
crate::agent_resume::PersistedAgentSession {
source: authority.source.clone(),
agent: authority.agent_label.clone(),
session_ref: session_ref.clone(),
}
})
});
self.suppress_current_full_lifecycle_hook_authority(
FullLifecycleHookSuppressionReason::HookClear,
);
self.hook_authority = None;
}
let detected_agent_changed_or_disappeared =
previous_detected_agent.is_some() && agent != previous_detected_agent;
let persisted_agent_was_previously_detected =
self.persisted_agent_session_belongs_to_detected_agent(previous_detected_agent);
if self.persisted_agent_session_conflicts_with_detected_agent(agent)
|| detected_agent_changed_or_disappeared && persisted_agent_was_previously_detected
{
self.persisted_agent_session = None;
self.persisted_agent_session = durable_session;
}
TerminalStateMutation {
effective_state_change: self.recompute_effective_state(
@ -388,11 +397,13 @@ impl TerminalState {
&agent_label,
&session_ref,
);
if self.known_agent_label_conflicts_with_detected_agent(&agent_label) {
if self.known_agent_label_conflicts_with_detected_agent(&agent_label)
|| self.current_session_owner_conflicts(&source, &agent_label)
{
return None;
}
let session_ref = session_ref.map(|session_ref| {
self.conflicting_current_session_ref(&source, &agent_label, &session_ref, None)
self.conflicting_same_owner_session_ref(&source, &agent_label, &session_ref, None)
.unwrap_or(session_ref)
});
if self.live_full_lifecycle_hook_authority_conflicts_with_session(
@ -481,32 +492,6 @@ impl TerminalState {
&& !self.hook_authority_conflicts_with_detected_agent(detected_agent)
}
fn persisted_agent_session_conflicts_with_detected_agent(
&self,
detected_agent: Option<Agent>,
) -> bool {
let Some(detected_agent) = detected_agent else {
return false;
};
self.persisted_agent_session
.as_ref()
.and_then(|session| crate::detect::parse_agent_label(&session.agent))
.is_some_and(|agent| agent != detected_agent)
}
fn persisted_agent_session_belongs_to_detected_agent(
&self,
detected_agent: Option<Agent>,
) -> bool {
let Some(detected_agent) = detected_agent else {
return false;
};
self.persisted_agent_session
.as_ref()
.and_then(|session| crate::detect::parse_agent_label(&session.agent))
.is_some_and(|agent| agent == detected_agent)
}
fn persisted_agent_session_matches(&self, source: &str, agent: &str) -> bool {
self.persisted_agent_session
.as_ref()
@ -802,7 +787,15 @@ impl TerminalState {
})
}
fn conflicting_current_session_ref(
fn current_session_owner_conflicts(&self, source: &str, agent_label: &str) -> bool {
self.current_session_identity_for_persistence().is_some_and(
|(current_source, current_agent, _, _)| {
current_source != source || current_agent != agent_label
},
)
}
fn conflicting_same_owner_session_ref(
&self,
source: &str,
agent_label: &str,
@ -815,7 +808,7 @@ impl TerminalState {
&& current_agent == agent_label
&& current_kind == crate::agent_resume::AgentSessionRefKind::Id
&& session_ref.kind == crate::agent_resume::AgentSessionRefKind::Id
&& (current_kind != session_ref.kind || current_value != session_ref.value)
&& current_value != session_ref.value
&& !Self::session_start_source_allows_session_replacement(
source,
agent_label,
@ -834,9 +827,18 @@ impl TerminalState {
agent_label: &str,
session_start_source: Option<&str>,
) -> bool {
source == "herdr:claude"
&& agent_label == "claude"
&& matches!(session_start_source, Some("clear" | "resume" | "compact"))
matches!(
(source, agent_label, session_start_source),
(
"herdr:claude",
"claude",
Some("clear" | "resume" | "compact")
) | (
"herdr:codex",
"codex",
Some("startup" | "clear" | "resume" | "compact")
)
)
}
pub fn set_persisted_agent_session(
@ -871,14 +873,15 @@ impl TerminalState {
if self.known_agent_label_conflicts_with_detected_agent(&agent_label) {
return None;
}
if self
.conflicting_current_session_ref(
&source,
&agent_label,
&session_ref,
session_start_source.as_deref(),
)
.is_some()
if self.current_session_owner_conflicts(&source, &agent_label)
|| self
.conflicting_same_owner_session_ref(
&source,
&agent_label,
&session_ref,
session_start_source.as_deref(),
)
.is_some()
{
return None;
}
@ -1009,6 +1012,10 @@ impl TerminalState {
if !matches_current_agent && !matches_persisted_session {
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 now = Instant::now();
let previous_agent_label = self.effective_agent_label().map(str::to_string);
@ -1026,7 +1033,10 @@ impl TerminalState {
self.fallback_visible_blocker = false;
self.fallback_observed_at = None;
self.hook_authority = None;
self.persisted_agent_session = None;
if !preserve_foreign_persisted_session {
self.persisted_agent_session = None;
}
let current_session = self.current_session_identity_for_persistence();
Some(TerminalStateMutation {
effective_state_change: self.recompute_effective_state(
previous_agent_label,
@ -1035,7 +1045,7 @@ impl TerminalState {
previous_presentation,
now,
),
session_ref_changed: previous_session.is_some(),
session_ref_changed: previous_session != current_session,
})
}
@ -3097,22 +3107,23 @@ mod tests {
}
#[test]
fn accepted_hook_report_marks_changed_when_session_identity_changes() {
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:opencode".into(),
agent: "opencode".into(),
session_ref: crate::agent_resume::AgentSessionRef::id("same-session").unwrap(),
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:hermes".into(),
"hermes".into(),
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
None,
crate::agent_resume::AgentSessionRef::id("same-session"),
crate::agent_resume::AgentSessionRef::path(test_session_path("new.jsonl")),
Some(20),
)
.expect("accepted report");
@ -3222,6 +3233,106 @@ mod tests {
}
}
#[test]
fn codex_lifecycle_session_ref_replaces_existing_session_ref() {
for session_start_source in ["startup", "clear", "resume", "compact"] {
let mut terminal = test_terminal();
terminal
.set_agent_session_ref(
"herdr:codex".into(),
"codex".into(),
crate::agent_resume::AgentSessionRef::id("codex-session"),
Some(20),
)
.expect("initial session should be accepted");
let next_session = format!("codex-{session_start_source}-session");
let mutation = terminal
.set_agent_session_ref_for_session_start(
"herdr:codex".into(),
"codex".into(),
crate::agent_resume::AgentSessionRef::id(&next_session),
Some(21),
Some(session_start_source.into()),
)
.unwrap_or_else(|| panic!("{session_start_source} should replace the session"));
assert!(mutation.session_ref_changed);
assert_eq!(
terminal
.persisted_agent_session
.as_ref()
.map(|session| session.session_ref.value.as_str()),
Some(next_session.as_str())
);
}
}
#[test]
fn different_owner_session_ref_does_not_replace_existing_session_ref() {
let mut terminal = test_terminal();
terminal
.set_agent_session_ref(
"herdr:droid".into(),
"droid".into(),
crate::agent_resume::AgentSessionRef::id("droid-session"),
Some(20),
)
.expect("initial session should be accepted");
let mutation = terminal.set_agent_session_ref_for_session_start(
"herdr:claude".into(),
"claude".into(),
crate::agent_resume::AgentSessionRef::id("claude-session"),
Some(21),
Some("resume".into()),
);
assert!(mutation.is_none());
assert_eq!(
terminal.persisted_agent_session.as_ref().map(|session| (
session.source.as_str(),
session.agent.as_str(),
session.session_ref.value.as_str()
)),
Some(("herdr:droid", "droid", "droid-session"))
);
}
#[test]
fn different_owner_full_lifecycle_hook_does_not_replace_existing_session_ref() {
let mut terminal = test_terminal();
terminal
.set_agent_session_ref(
"herdr:droid".into(),
"droid".into(),
crate::agent_resume::AgentSessionRef::id("droid-session"),
Some(20),
)
.expect("initial session should be accepted");
let mutation = terminal.set_hook_authority_with_session_ref(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
None,
crate::agent_resume::AgentSessionRef::path("/tmp/pi-session.jsonl"),
Some(21),
);
assert!(mutation.is_none());
assert!(terminal.hook_authority.is_none());
assert_eq!(
terminal.persisted_agent_session.as_ref().map(|session| (
session.source.as_str(),
session.agent.as_str(),
session.session_ref.value.as_str()
)),
Some(("herdr:droid", "droid", "droid-session"))
);
}
#[test]
fn repeated_same_agent_session_ref_is_accepted_without_session_change() {
let mut terminal = test_terminal();
@ -3286,7 +3397,7 @@ mod tests {
}
#[test]
fn different_same_agent_session_ref_is_accepted_after_detection_clears_current_session() {
fn detected_agent_clear_does_not_clear_current_session_ref() {
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::Claude), AgentState::Working);
terminal
@ -3299,24 +3410,22 @@ mod tests {
.expect("initial session should be accepted");
let clear = terminal.set_detected_state_with_mutation(None, AgentState::Unknown);
assert!(clear.session_ref_changed);
assert!(!clear.session_ref_changed);
let mutation = terminal
.set_agent_session_ref(
"herdr:claude".into(),
"claude".into(),
crate::agent_resume::AgentSessionRef::id("new-session"),
Some(21),
)
.expect("new session should be accepted after clear");
let mutation = terminal.set_agent_session_ref(
"herdr:claude".into(),
"claude".into(),
crate::agent_resume::AgentSessionRef::id("new-session"),
Some(21),
);
assert!(mutation.session_ref_changed);
assert!(mutation.is_none());
assert_eq!(
terminal
.persisted_agent_session
.as_ref()
.map(|session| session.session_ref.value.as_str()),
Some("new-session")
Some("claude-session")
);
}
@ -3382,6 +3491,86 @@ mod tests {
assert!(terminal.persisted_agent_session.is_none());
}
#[test]
fn release_agent_preserves_foreign_persisted_session_ref() {
let mut terminal = test_terminal();
terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession {
source: "herdr:claude".into(),
agent: "claude".into(),
session_ref: crate::agent_resume::AgentSessionRef::id("claude-session").unwrap(),
});
terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
let mutation = terminal
.release_agent_with_mutation("herdr:pi", "pi", Some(21))
.expect("visible agent release should be accepted");
assert!(!mutation.session_ref_changed);
assert_eq!(
terminal.persisted_agent_session.as_ref().map(|session| (
session.source.as_str(),
session.agent.as_str(),
session.session_ref.value.as_str()
)),
Some(("herdr:claude", "claude", "claude-session"))
);
}
#[test]
fn process_exit_clears_matching_persisted_session_ref() {
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("pi.jsonl"))
.unwrap(),
});
terminal.set_detected_state(Some(Agent::Pi), AgentState::Working);
let mutation = terminal.set_detected_state_with_screen_signals_at(
Some(Agent::Pi),
AgentState::Idle,
false,
false,
false,
true,
std::time::Instant::now(),
);
assert!(mutation.session_ref_changed);
assert!(terminal.persisted_agent_session.is_none());
}
#[test]
fn process_exit_preserves_foreign_persisted_session_ref() {
let mut terminal = test_terminal();
terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession {
source: "herdr:claude".into(),
agent: "claude".into(),
session_ref: crate::agent_resume::AgentSessionRef::id("claude-session").unwrap(),
});
terminal.set_detected_state(Some(Agent::Pi), AgentState::Working);
let mutation = terminal.set_detected_state_with_screen_signals_at(
Some(Agent::Pi),
AgentState::Idle,
false,
false,
false,
true,
std::time::Instant::now(),
);
assert!(!mutation.session_ref_changed);
assert_eq!(
terminal
.persisted_agent_session
.as_ref()
.map(|session| session.session_ref.value.as_str()),
Some("claude-session")
);
}
#[test]
fn respawn_cleanup_resets_restored_agent_status() {
let mut terminal = test_terminal();
@ -3404,7 +3593,7 @@ mod tests {
}
#[test]
fn detected_conflict_clears_session_ref() {
fn detected_conflict_clears_live_hook_but_preserves_session_ref() {
let mut terminal = test_terminal();
terminal.set_hook_authority_with_session_ref(
"herdr:claude".into(),
@ -3419,8 +3608,16 @@ mod tests {
let mutation =
terminal.set_detected_state_with_mutation(Some(Agent::Grok), AgentState::Idle);
assert!(mutation.session_ref_changed);
assert!(!mutation.session_ref_changed);
assert!(terminal.hook_authority.is_none());
assert_eq!(
terminal.persisted_agent_session.as_ref().map(|session| (
session.source.as_str(),
session.agent.as_str(),
session.session_ref.value.as_str()
)),
Some(("herdr:claude", "claude", "claude-session"))
);
}
#[test]
@ -3446,7 +3643,7 @@ mod tests {
}
#[test]
fn detected_agent_disappearance_clears_matching_persisted_session_ref() {
fn detected_agent_disappearance_preserves_matching_persisted_session_ref() {
let mut terminal = test_terminal();
terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession {
source: "herdr:opencode".into(),
@ -3460,8 +3657,8 @@ mod tests {
assert!(terminal.persisted_agent_session.is_some());
let second = terminal.set_detected_state_with_mutation(None, AgentState::Unknown);
assert!(second.session_ref_changed);
assert!(terminal.persisted_agent_session.is_none());
assert!(!second.session_ref_changed);
assert!(terminal.persisted_agent_session.is_some());
}
#[test]

View File

@ -1345,7 +1345,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 (v2)"));
assert!(status_stdout.contains("pi: current (v3)"));
assert!(status_stdout.contains("claude: not installed"));
let integration_uninstall = Command::new(env!("CARGO_BIN_EXE_herdr"))