diff options
Diffstat (limited to 'src/integration')
| -rw-r--r-- | src/integration/assets/claude/herdr-agent-state.sh | 6 | ||||
| -rw-r--r-- | src/integration/assets/codex/herdr-agent-state.sh | 23 | ||||
| -rw-r--r-- | src/integration/assets/hermes/__init__.py | 26 | ||||
| -rw-r--r-- | src/integration/assets/opencode/herdr-agent-state.js | 65 | ||||
| -rw-r--r-- | src/integration/assets/pi/herdr-agent-state.ts | 44 | ||||
| -rw-r--r-- | src/integration/mod.rs | 55 |
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")); + } } |
