aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-21 02:38:14 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-21 02:42:39 +0300
commit92a10fc044d42b216317696a4e8e6f70d7cc7a7d (patch)
treea45fb881c85e66ff8641fa84bb2a1bf0ead584ed /src
parent73b137a4ae75cb1465e8d4ddfc293ff7366d8133 (diff)
fix: protect root agent restore sessions
refs #712
Diffstat (limited to 'src')
-rw-r--r--src/agent_resume.rs21
-rw-r--r--src/app/api/panes.rs2
-rw-r--r--src/integration/assets/claude/herdr-agent-state.ps13
-rw-r--r--src/integration/assets/claude/herdr-agent-state.sh4
-rw-r--r--src/integration/assets/codex/herdr-agent-state.ps122
-rw-r--r--src/integration/assets/codex/herdr-agent-state.sh26
-rw-r--r--src/integration/assets/hermes/__init__.py12
-rw-r--r--src/integration/assets/kilo/herdr-agent-state.js7
-rw-r--r--src/integration/assets/kimi/herdr-agent-state.ps18
-rw-r--r--src/integration/assets/kimi/herdr-agent-state.sh17
-rw-r--r--src/integration/assets/omp/herdr-agent-state.ts55
-rw-r--r--src/integration/assets/opencode/herdr-agent-state.js7
-rw-r--r--src/integration/assets/pi/herdr-agent-state.ts74
-rw-r--r--src/integration/mod.rs124
-rw-r--r--src/terminal/state.rs349
15 files changed, 495 insertions, 236 deletions
diff --git a/src/agent_resume.rs b/src/agent_resume.rs
index 5012e991..dbd1d9f2 100644
--- a/src/agent_resume.rs
+++ b/src/agent_resume.rs
@@ -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]
diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs
index ac8ddf4b..08a9b677 100644
--- a/src/app/api/panes.rs
+++ b/src/app/api/panes.rs
@@ -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,
),
});
diff --git a/src/integration/assets/claude/herdr-agent-state.ps1 b/src/integration/assets/claude/herdr-agent-state.ps1
index dc78efd2..dcc9d4df 100644
--- a/src/integration/assets/claude/herdr-agent-state.ps1
+++ b/src/integration/assets/claude/herdr-agent-state.ps1
@@ -2,7 +2,7 @@
# managed by herdr; reinstalling or updating the integration overwrites this file.
# add custom hooks beside this file instead of editing it.
# HERDR_INTEGRATION_ID=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
diff --git a/src/integration/assets/claude/herdr-agent-state.sh b/src/integration/assets/claude/herdr-agent-state.sh
index ce072189..cb5e840d 100644
--- a/src/integration/assets/claude/herdr-agent-state.sh
+++ b/src/integration/assets/claude/herdr-agent-state.sh
@@ -3,7 +3,7 @@
# managed by herdr; reinstalling or updating the integration overwrites this file.
# add custom hooks beside this file instead of editing it.
# HERDR_INTEGRATION_ID=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
diff --git a/src/integration/assets/codex/herdr-agent-state.ps1 b/src/integration/assets/codex/herdr-agent-state.ps1
index e56948f5..d837b78a 100644
--- a/src/integration/assets/codex/herdr-agent-state.ps1
+++ b/src/integration/assets/codex/herdr-agent-state.ps1
@@ -2,7 +2,7 @@
# managed by herdr; reinstalling or updating the integration overwrites this file.
# add custom hooks beside this file instead of editing it.
# HERDR_INTEGRATION_ID=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 {
}
diff --git a/src/integration/assets/codex/herdr-agent-state.sh b/src/integration/assets/codex/herdr-agent-state.sh
index 385c9a98..b3b08a78 100644
--- a/src/integration/assets/codex/herdr-agent-state.sh
+++ b/src/integration/assets/codex/herdr-agent-state.sh
@@ -3,7 +3,7 @@
# managed by herdr; reinstalling or updating the integration overwrites this file.
# add custom hooks beside this file instead of editing it.
# HERDR_INTEGRATION_ID=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)
diff --git a/src/integration/assets/hermes/__init__.py b/src/integration/assets/hermes/__init__.py
index 6bb0fe8a..e6f6971d 100644
--- a/src/integration/assets/hermes/__init__.py
+++ b/src/integration/assets/hermes/__init__.py
@@ -1,7 +1,7 @@
"""Hermes plugin installed by Herdr to report agent lifecycle state."""
# HERDR_INTEGRATION_ID=hermes
-# HERDR_INTEGRATION_VERSION=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)
diff --git a/src/integration/assets/kilo/herdr-agent-state.js b/src/integration/assets/kilo/herdr-agent-state.js
index aa1a0c1b..fc12919c 100644
--- a/src/integration/assets/kilo/herdr-agent-state.js
+++ b/src/integration/assets/kilo/herdr-agent-state.js
@@ -2,7 +2,7 @@
// managed by herdr; reinstalling or updating the integration overwrites this file.
// add custom hooks/plugins beside this file instead of editing it.
// HERDR_INTEGRATION_ID=kilo
-// HERDR_INTEGRATION_VERSION=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;
diff --git a/src/integration/assets/kimi/herdr-agent-state.ps1 b/src/integration/assets/kimi/herdr-agent-state.ps1
index d9924acc..ff6bcefe 100644
--- a/src/integration/assets/kimi/herdr-agent-state.ps1
+++ b/src/integration/assets/kimi/herdr-agent-state.ps1
@@ -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 {
diff --git a/src/integration/assets/kimi/herdr-agent-state.sh b/src/integration/assets/kimi/herdr-agent-state.sh
index 2c8d8567..b5b6cc4b 100644
--- a/src/integration/assets/kimi/herdr-agent-state.sh
+++ b/src/integration/assets/kimi/herdr-agent-state.sh
@@ -3,7 +3,7 @@
# managed by herdr; reinstalling or updating the integration overwrites this file.
# add custom hooks beside this file instead of editing it.
# HERDR_INTEGRATION_ID=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 = {
diff --git a/src/integration/assets/omp/herdr-agent-state.ts b/src/integration/assets/omp/herdr-agent-state.ts
index a6428cdd..a933e867 100644
--- a/src/integration/assets/omp/herdr-agent-state.ts
+++ b/src/integration/assets/omp/herdr-agent-state.ts
@@ -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();
- });
}
diff --git a/src/integration/assets/opencode/herdr-agent-state.js b/src/integration/assets/opencode/herdr-agent-state.js
index 1e9fff51..6836227b 100644
--- a/src/integration/assets/opencode/herdr-agent-state.js
+++ b/src/integration/assets/opencode/herdr-agent-state.js
@@ -2,7 +2,7 @@
// managed by herdr; reinstalling or updating the integration overwrites this file.
// add custom hooks/plugins beside this file instead of editing it.
// HERDR_INTEGRATION_ID=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;
diff --git a/src/integration/assets/pi/herdr-agent-state.ts b/src/integration/assets/pi/herdr-agent-state.ts
index 0e620f1f..9af12cfd 100644
--- a/src/integration/assets/pi/herdr-agent-state.ts
+++ b/src/integration/assets/pi/herdr-agent-state.ts
@@ -2,7 +2,7 @@
// managed by herdr; reinstalling or updating the integration overwrites this file.
// add custom hooks/plugins beside this file instead of editing it.
// HERDR_INTEGRATION_ID=pi
-// HERDR_INTEGRATION_VERSION=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();
- });
}
diff --git a/src/integration/mod.rs b/src/integration/mod.rs
index 59eaebd9..0a00240b 100644
--- a/src/integration/mod.rs
+++ b/src/integration/mod.rs
@@ -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");
-
- 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);
+ 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!(session_start_handler < non_ui_guard);
+ assert!(non_ui_guard < session_report);
}
#[test]
diff --git a/src/terminal/state.rs b/src/terminal/state.rs
index 01b6bdfe..b5a05168 100644
--- a/src/terminal/state.rs
+++ b/src/terminal/state.rs
@@ -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");
@@ -3223,6 +3234,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();
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")
);
}
@@ -3383,6 +3492,86 @@ mod tests {
}
#[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();
terminal.respawn_shell_on_exit = true;
@@ -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]