aboutsummaryrefslogtreecommitdiff
path: root/src/integration
diff options
context:
space:
mode:
Diffstat (limited to 'src/integration')
-rw-r--r--src/integration/assets/claude/herdr-agent-state.sh6
-rw-r--r--src/integration/assets/codex/herdr-agent-state.sh23
-rw-r--r--src/integration/assets/hermes/__init__.py26
-rw-r--r--src/integration/assets/opencode/herdr-agent-state.js65
-rw-r--r--src/integration/assets/pi/herdr-agent-state.ts44
-rw-r--r--src/integration/mod.rs55
6 files changed, 167 insertions, 52 deletions
diff --git a/src/integration/assets/claude/herdr-agent-state.sh b/src/integration/assets/claude/herdr-agent-state.sh
index 5042cad8..b76d98e7 100644
--- a/src/integration/assets/claude/herdr-agent-state.sh
+++ b/src/integration/assets/claude/herdr-agent-state.sh
@@ -2,7 +2,7 @@
# installed by herdr
# safe to edit. this hook only activates inside herdr-managed panes.
# HERDR_INTEGRATION_ID=claude
-# HERDR_INTEGRATION_VERSION=3
+# HERDR_INTEGRATION_VERSION=4
set -eu
@@ -60,6 +60,8 @@ if is_subagent and action in ("idle", "release"):
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
if action == "release":
request = {
"id": request_id,
@@ -83,6 +85,8 @@ else:
"seq": report_seq,
},
}
+ if agent_session_id:
+ request["params"]["agent_session_id"] = agent_session_id
try:
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
diff --git a/src/integration/assets/codex/herdr-agent-state.sh b/src/integration/assets/codex/herdr-agent-state.sh
index fbfa24cb..0f0979e3 100644
--- a/src/integration/assets/codex/herdr-agent-state.sh
+++ b/src/integration/assets/codex/herdr-agent-state.sh
@@ -2,12 +2,14 @@
# installed by herdr
# safe to edit. this hook only activates inside herdr-managed panes.
# HERDR_INTEGRATION_ID=codex
-# HERDR_INTEGRATION_VERSION=3
+# HERDR_INTEGRATION_VERSION=4
set -eu
action="${1:-}"
-cat >/dev/null 2>/dev/null || true
+hook_input_file="$(mktemp "${TMPDIR:-/tmp}/herdr-codex-hook.XXXXXX")" || exit 0
+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) ;;
@@ -19,7 +21,7 @@ esac
[ -n "${HERDR_PANE_ID:-}" ] || exit 0
command -v python3 >/dev/null 2>&1 || exit 0
-HERDR_ACTION="$action" python3 - <<'PY'
+HERDR_ACTION="$action" HERDR_HOOK_INPUT_FILE="$hook_input_file" python3 - <<'PY'
import json
import os
import random
@@ -30,12 +32,25 @@ source = "herdr:codex"
action = os.environ.get("HERDR_ACTION", "")
pane_id = os.environ.get("HERDR_PANE_ID")
socket_path = os.environ.get("HERDR_SOCKET_PATH")
+hook_input_file = os.environ.get("HERDR_HOOK_INPUT_FILE")
if not pane_id or not socket_path:
raise SystemExit(0)
+hook_input = {}
+if hook_input_file:
+ try:
+ with open(hook_input_file, encoding="utf-8") as handle:
+ content = handle.read()
+ if content.strip():
+ hook_input = json.loads(content)
+ except Exception:
+ hook_input = {}
+
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
if action == "release":
request = {
"id": request_id,
@@ -59,6 +74,8 @@ else:
"seq": report_seq,
},
}
+ if agent_session_id:
+ request["params"]["agent_session_id"] = agent_session_id
try:
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
diff --git a/src/integration/assets/hermes/__init__.py b/src/integration/assets/hermes/__init__.py
index 03d15a55..6bb0fe8a 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=1
+# HERDR_INTEGRATION_VERSION=2
from __future__ import annotations
@@ -56,8 +56,19 @@ def _send(method: str, params: dict) -> None:
pass
-def _report(state: str) -> None:
- _send("pane.report_agent", {"state": state})
+def _session_id(kwargs: dict) -> str | None:
+ value = kwargs.get("session_id")
+ if isinstance(value, str) and value:
+ return value
+ return None
+
+
+def _report(state: str, **kwargs) -> None:
+ params = {"state": state}
+ session_id = _session_id(kwargs)
+ if session_id:
+ params["agent_session_id"] = session_id
+ _send("pane.report_agent", params)
def _release() -> None:
@@ -65,18 +76,15 @@ def _release() -> None:
def _working(**kwargs) -> None:
- del kwargs
- _report("working")
+ _report("working", **kwargs)
def _blocked(**kwargs) -> None:
- del kwargs
- _report("blocked")
+ _report("blocked", **kwargs)
def _idle(**kwargs) -> None:
- del kwargs
- _report("idle")
+ _report("idle", **kwargs)
def _finalize(**kwargs) -> None:
diff --git a/src/integration/assets/opencode/herdr-agent-state.js b/src/integration/assets/opencode/herdr-agent-state.js
index 0fb96217..778fe564 100644
--- a/src/integration/assets/opencode/herdr-agent-state.js
+++ b/src/integration/assets/opencode/herdr-agent-state.js
@@ -1,7 +1,7 @@
// installed by herdr
// safe to edit. this plugin only activates inside herdr-managed panes.
// HERDR_INTEGRATION_ID=opencode
-// HERDR_INTEGRATION_VERSION=1
+// HERDR_INTEGRATION_VERSION=2
import net from "node:net";
@@ -13,7 +13,13 @@ function nextReportSeq() {
return reportSeq;
}
-function reportState(action) {
+function sessionIDFromProperties(properties) {
+ return typeof properties?.sessionID === "string" && properties.sessionID
+ ? properties.sessionID
+ : undefined;
+}
+
+function reportState(action, sessionID) {
const paneId = process.env.HERDR_PANE_ID;
const socketPath = process.env.HERDR_SOCKET_PATH;
@@ -24,24 +30,26 @@ function reportState(action) {
const requestId = `${SOURCE}:${Date.now()}:${Math.floor(Math.random() * 1_000_000)
.toString()
.padStart(6, "0")}`;
+ const params =
+ action === "release"
+ ? {
+ pane_id: paneId,
+ source: SOURCE,
+ agent: "opencode",
+ seq: nextReportSeq(),
+ }
+ : {
+ pane_id: paneId,
+ source: SOURCE,
+ agent: "opencode",
+ state: action,
+ seq: nextReportSeq(),
+ ...(sessionID ? { agent_session_id: sessionID } : {}),
+ };
const request = {
id: requestId,
method: action === "release" ? "pane.release_agent" : "pane.report_agent",
- params:
- action === "release"
- ? {
- pane_id: paneId,
- source: SOURCE,
- agent: "opencode",
- seq: nextReportSeq(),
- }
- : {
- pane_id: paneId,
- source: SOURCE,
- agent: "opencode",
- state: action,
- seq: nextReportSeq(),
- },
+ params,
};
return new Promise((resolve) => {
@@ -75,26 +83,33 @@ export const HerdrAgentStatePlugin = async () => {
event: async ({ event }) => {
const type = event?.type;
const properties = event?.properties ?? {};
+ const sessionID = sessionIDFromProperties(properties);
switch (type) {
case "permission.asked":
case "question.asked":
- await reportState("blocked");
+ await reportState("blocked", sessionID);
break;
case "permission.replied": {
const reply = properties.reply ?? properties.response;
if (reply === "reject") {
- await reportState("idle");
+ await reportState("idle", sessionID);
} else if (reply === "once" || reply === "always") {
- await reportState("working");
+ await reportState("working", sessionID);
}
break;
}
case "question.replied":
- await reportState("working");
+ await reportState("working", sessionID);
break;
case "question.rejected":
- await reportState("idle");
+ await reportState("idle", sessionID);
+ break;
+ case "session.created":
+ case "session.updated":
+ if (sessionID) {
+ await reportState("idle", sessionID);
+ }
break;
case "session.status": {
const status =
@@ -102,14 +117,14 @@ export const HerdrAgentStatePlugin = async () => {
? properties.status
: properties.status?.type;
if (status === "busy" || status === "retry") {
- await reportState("working");
+ await reportState("working", sessionID);
} else if (status === "idle") {
- await reportState("idle");
+ await reportState("idle", sessionID);
}
break;
}
case "session.idle":
- await reportState("idle");
+ await reportState("idle", sessionID);
break;
default:
break;
diff --git a/src/integration/assets/pi/herdr-agent-state.ts b/src/integration/assets/pi/herdr-agent-state.ts
index 26869f1c..450fd48c 100644
--- a/src/integration/assets/pi/herdr-agent-state.ts
+++ b/src/integration/assets/pi/herdr-agent-state.ts
@@ -1,7 +1,7 @@
// installed by herdr
// safe to edit. this integration only activates inside herdr-managed panes.
// HERDR_INTEGRATION_ID=pi
-// HERDR_INTEGRATION_VERSION=1
+// HERDR_INTEGRATION_VERSION=2
// @ts-nocheck
import { createConnection } from "node:net";
@@ -52,6 +52,8 @@ const retryGraceMs = parseDurationEnv("HERDR_PI_RETRY_GRACE_MS", 2500);
const retryableErrorPattern =
/overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i;
let reportSeq = Date.now() * 1000;
+let currentAgentSessionId: string | undefined;
+let currentAgentSessionPath: string | undefined;
function nextReportSeq(): number {
reportSeq += 1;
@@ -70,18 +72,45 @@ function parseDurationEnv(name: string, fallback: number): number {
return parsed;
}
+function updateSessionRef(ctx: any): void {
+ try {
+ const file = ctx?.sessionManager?.getSessionFile?.();
+ currentAgentSessionPath =
+ typeof file === "string" && file.startsWith("/") ? file : undefined;
+ } catch {
+ currentAgentSessionPath = undefined;
+ }
+
+ try {
+ const id = ctx?.sessionManager?.getSessionId?.();
+ currentAgentSessionId = typeof id === "string" && id.length > 0 ? id : undefined;
+ } catch {
+ currentAgentSessionId = undefined;
+ }
+}
+
+function withSessionRef(params: Record<string, unknown>): Record<string, unknown> {
+ if (currentAgentSessionPath) {
+ return { ...params, agent_session_path: currentAgentSessionPath };
+ }
+ if (currentAgentSessionId) {
+ return { ...params, agent_session_id: currentAgentSessionId };
+ }
+ return params;
+}
+
function sendState(state: AgentState, message?: string, seq = nextReportSeq()): Promise<void> {
return sendRequest({
id: `${source}:${Date.now()}:${Math.random().toString(36).slice(2)}`,
method: "pane.report_agent",
- params: {
+ params: withSessionRef({
pane_id: paneId,
source,
agent: "pi",
state,
message,
seq,
- },
+ }),
});
}
@@ -200,9 +229,9 @@ export default function (pi) {
return { state: "idle" as const, message: undefined };
}
- function publishState() {
+ function publishState(force = false) {
const next = desiredState();
- if (next.state === lastState && next.message === lastMessage) {
+ if (!force && next.state === lastState && next.message === lastMessage) {
return;
}
lastState = next.state;
@@ -220,6 +249,11 @@ export default function (pi) {
idleTimer.unref?.();
}
+ pi.on("session_start", (_event, ctx) => {
+ updateSessionRef(ctx);
+ publishState(true);
+ });
+
function holdForRetry(message: string) {
clearPendingTimers();
retryHoldActive = true;
diff --git a/src/integration/mod.rs b/src/integration/mod.rs
index d6ccaa0f..d9abf648 100644
--- a/src/integration/mod.rs
+++ b/src/integration/mod.rs
@@ -12,28 +12,28 @@ use crate::layout::PaneId;
pub(crate) const HERDR_PANE_ID_ENV_VAR: &str = "HERDR_PANE_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 = 1;
+const PI_INTEGRATION_VERSION: u32 = 2;
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 = 1;
const PI_CODING_AGENT_DIR_ENV_VAR: &str = "PI_CODING_AGENT_DIR";
const CLAUDE_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh";
const CLAUDE_HOOK_ASSET: &str = include_str!("assets/claude/herdr-agent-state.sh");
-const CLAUDE_INTEGRATION_VERSION: u32 = 3;
+const CLAUDE_INTEGRATION_VERSION: u32 = 4;
const CLAUDE_CONFIG_DIR_ENV_VAR: &str = "CLAUDE_CONFIG_DIR";
const CODEX_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh";
const CODEX_HOOK_ASSET: &str = include_str!("assets/codex/herdr-agent-state.sh");
-const CODEX_INTEGRATION_VERSION: u32 = 3;
+const CODEX_INTEGRATION_VERSION: u32 = 4;
const CODEX_HOME_ENV_VAR: &str = "CODEX_HOME";
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 = 1;
+const OPENCODE_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 = 1;
+const HERMES_INTEGRATION_VERSION: u32 = 2;
const INTEGRATION_VERSION_MARKER: &str = "HERDR_INTEGRATION_VERSION=";
#[derive(Debug)]
@@ -685,6 +685,13 @@ pub(crate) fn install_claude() -> io::Result<ClaudeInstallPaths> {
)?;
ensure_command_hook(
hooks,
+ "SessionStart",
+ format!("bash {quoted_hook_path} idle"),
+ 10,
+ Some("*"),
+ )?;
+ ensure_command_hook(
+ hooks,
"UserPromptSubmit",
format!("bash {quoted_hook_path} working"),
10,
@@ -910,6 +917,11 @@ pub(crate) fn uninstall_claude() -> io::Result<ClaudeUninstallResult> {
let quoted_hook_path = shell_single_quote(&hook_path.display().to_string());
updated_settings |= remove_command_hook(
hooks,
+ "SessionStart",
+ &format!("bash {quoted_hook_path} idle"),
+ )?;
+ updated_settings |= remove_command_hook(
+ hooks,
"UserPromptSubmit",
&format!("bash {quoted_hook_path} working"),
)?;
@@ -1901,6 +1913,11 @@ mod tests {
);
assert_eq!(hook_content, CLAUDE_HOOK_ASSET);
assert!(settings["permissions"]["allow"].is_array());
+ assert_eq!(settings["hooks"]["SessionStart"][0]["matcher"], "*");
+ assert!(settings["hooks"]["SessionStart"][0]["hooks"][0]["command"]
+ .as_str()
+ .unwrap()
+ .contains(" idle"));
assert_eq!(settings["hooks"]["UserPromptSubmit"][0]["matcher"], "*");
assert!(
settings["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"]
@@ -1984,6 +2001,10 @@ mod tests {
.len(),
1
);
+ assert_eq!(
+ settings["hooks"]["SessionStart"].as_array().unwrap().len(),
+ 1
+ );
assert!(settings["hooks"].get("PostToolUse").is_none());
assert!(settings["hooks"].get("PostToolUseFailure").is_none());
assert!(settings["hooks"].get("SubagentStop").is_none());
@@ -2069,7 +2090,7 @@ mod tests {
assert_eq!(claude.path, hook_path);
assert_eq!(claude.installed_version, Some(1));
- assert_eq!(claude.expected_version, 3);
+ assert_eq!(claude.expected_version, 4);
assert_eq!(claude.state, IntegrationStatusKind::Outdated);
std::env::remove_var("HOME");
@@ -2099,7 +2120,7 @@ mod tests {
assert_eq!(claude.path, hook_path);
assert_eq!(claude.installed_version, Some(2));
- assert_eq!(claude.expected_version, 3);
+ assert_eq!(claude.expected_version, 4);
assert_eq!(claude.state, IntegrationStatusKind::Outdated);
std::env::remove_var("HOME");
@@ -2119,7 +2140,8 @@ mod tests {
fs::write(
claude_dir.join("settings.json"),
format!(
- r#"{{"hooks":{{"UserPromptSubmit":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep","timeout":10}}]}}],"PermissionRequest":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' blocked","timeout":10}}]}}],"PostToolUse":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}}]}}],"PostToolUseFailure":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}}]}}],"SubagentStop":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}}]}}],"Stop":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' idle","timeout":10}}]}}],"SessionEnd":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' release","timeout":10}}]}}]}}}}"#,
+ r#"{{"hooks":{{"SessionStart":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' idle","timeout":10}}]}}],"UserPromptSubmit":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep","timeout":10}}]}}],"PermissionRequest":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' blocked","timeout":10}}]}}],"PostToolUse":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}}]}}],"PostToolUseFailure":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}}]}}],"SubagentStop":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}}]}}],"Stop":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' idle","timeout":10}}]}}],"SessionEnd":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' release","timeout":10}}]}}]}}}}"#,
+ hook_path.display(),
hook_path.display(),
hook_path.display(),
hook_path.display(),
@@ -2152,6 +2174,7 @@ mod tests {
"echo keep"
);
assert!(settings["hooks"].get("PermissionRequest").is_none());
+ assert!(settings["hooks"].get("SessionStart").is_none());
assert!(settings["hooks"].get("PostToolUse").is_none());
assert!(settings["hooks"].get("PostToolUseFailure").is_none());
assert!(settings["hooks"].get("SubagentStop").is_none());
@@ -2201,7 +2224,7 @@ mod tests {
assert_eq!(codex.path, hook_path);
assert_eq!(codex.installed_version, Some(2));
- assert_eq!(codex.expected_version, 3);
+ assert_eq!(codex.expected_version, 4);
assert_eq!(codex.state, IntegrationStatusKind::Outdated);
std::env::remove_var("HOME");
@@ -2590,4 +2613,18 @@ mod tests {
std::env::remove_var("HOME");
let _ = fs::remove_dir_all(base);
}
+
+ #[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!(CLAUDE_HOOK_ASSET.contains("agent_session_id"));
+ assert!(CODEX_HOOK_ASSET.contains("HERDR_HOOK_INPUT_FILE"));
+ assert!(CODEX_HOOK_ASSET.contains("agent_session_id"));
+ assert!(OPENCODE_PLUGIN_ASSET.contains("properties?.sessionID"));
+ assert!(OPENCODE_PLUGIN_ASSET.contains("agent_session_id: sessionID"));
+ assert!(HERMES_PLUGIN_INIT_ASSET.contains("session_id = _session_id(kwargs)"));
+ assert!(HERMES_PLUGIN_INIT_ASSET.contains("agent_session_id"));
+ }
}