diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-04-01 19:30:58 +0300 |
|---|---|---|
| committer | Can Celik <ogulcancelik@gmail.com> | 2026-04-01 19:32:26 +0300 |
| commit | ca4270b31fe93c180f7321cb4006cb5e7307d889 (patch) | |
| tree | 72628e311a1b8e5ebf78c74cbc61d990253dc4c4 /src/integration | |
| parent | fb0c9717cfa961a47d63272e622e9e35a001082a (diff) | |
feat: add authoritative pi hook integration
Diffstat (limited to 'src/integration')
| -rw-r--r-- | src/integration/assets/pi/herdr-agent-state.ts | 125 | ||||
| -rw-r--r-- | src/integration/mod.rs | 86 |
2 files changed, 211 insertions, 0 deletions
diff --git a/src/integration/assets/pi/herdr-agent-state.ts b/src/integration/assets/pi/herdr-agent-state.ts new file mode 100644 index 00000000..f51f05d1 --- /dev/null +++ b/src/integration/assets/pi/herdr-agent-state.ts @@ -0,0 +1,125 @@ +// installed by herdr +// safe to edit. this integration only activates inside herdr-managed panes. +// @ts-nocheck + +import { createConnection } from "node:net"; + +const HERDR_ENV = process.env.HERDR_ENV; +const socketPath = process.env.HERDR_SOCKET_PATH; +const paneId = process.env.HERDR_PANE_ID; +const source = "herdr:pi"; + +function enabled() { + return HERDR_ENV === "1" && !!socketPath && !!paneId; +} + +function sendRequest(request: unknown): Promise<void> { + if (!enabled()) { + return Promise.resolve(); + } + + return new Promise((resolve) => { + let done = false; + const finish = () => { + if (done) return; + done = true; + socket.destroy(); + resolve(); + }; + + const socket = createConnection(socketPath!); + socket.on("error", finish); + socket.on("connect", () => socket.write(`${JSON.stringify(request)}\n`)); + socket.on("data", finish); + socket.on("end", finish); + const timeout = setTimeout(finish, 500); + timeout.unref?.(); + }); +} + +function sendState(state: "working" | "blocked" | "idle", message?: string): Promise<void> { + return sendRequest({ + id: `${source}:${Date.now()}:${Math.random().toString(36).slice(2)}`, + method: "pane.report_agent", + params: { + pane_id: paneId, + source, + agent: "pi", + state, + message, + }, + }); +} + +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", + }, + }); +} + +export default function (pi) { + if (!enabled()) { + return; + } + + let agentActive = false; + let blockedCount = 0; + let blockedMessage: string | undefined; + let lastState: "working" | "blocked" | "idle" | undefined; + let lastMessage: string | undefined; + + function desiredState() { + if (blockedCount > 0) { + return { state: "blocked" as const, message: blockedMessage }; + } + if (agentActive) { + return { state: "working" as const, message: undefined }; + } + return { state: "idle" as const, message: undefined }; + } + + function publishState() { + const next = desiredState(); + if (next.state === lastState && next.message === lastMessage) { + return; + } + lastState = next.state; + lastMessage = next.message; + void sendState(next.state, next.message); + } + + pi.events.on("herdr:blocked", (data) => { + if (!data?.active) { + blockedCount = Math.max(0, blockedCount - 1); + if (blockedCount === 0) { + blockedMessage = undefined; + } + publishState(); + return; + } + + blockedCount += 1; + blockedMessage = data.label; + publishState(); + }); + + pi.on("agent_start", () => { + agentActive = true; + publishState(); + }); + + pi.on("agent_end", () => { + agentActive = false; + publishState(); + }); + + pi.on("session_shutdown", async () => { + await releaseAgent(); + }); +} diff --git a/src/integration/mod.rs b/src/integration/mod.rs new file mode 100644 index 00000000..65c5c6ad --- /dev/null +++ b/src/integration/mod.rs @@ -0,0 +1,86 @@ +use std::fs; +use std::io; +use std::path::PathBuf; + +use portable_pty::CommandBuilder; + +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"); + +pub(crate) fn apply_pane_env(cmd: &mut CommandBuilder, pane_id: PaneId) { + cmd.env(crate::api::SOCKET_PATH_ENV_VAR, crate::api::socket_path()); + cmd.env(HERDR_PANE_ID_ENV_VAR, format!("p_{}", pane_id.raw())); +} + +pub(crate) fn install_pi() -> io::Result<PathBuf> { + let dir = pi_extension_dir()?; + if !dir.is_dir() { + return Err(io::Error::other(format!( + "pi extension directory not found at {}. install pi and create the extensions directory first", + dir.display() + ))); + } + + let path = dir.join(PI_EXTENSION_INSTALL_NAME); + fs::write(&path, PI_EXTENSION_ASSET)?; + Ok(path) +} + +fn pi_extension_dir() -> io::Result<PathBuf> { + let home = std::env::var("HOME") + .map(PathBuf::from) + .map_err(|_| io::Error::other("HOME is not set; cannot locate ~/.pi/agent/extensions"))?; + Ok(home.join(".pi/agent/extensions")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn unique_base() -> PathBuf { + std::env::temp_dir().join(format!( + "herdr-integration-install-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )) + } + + #[test] + fn install_pi_writes_embedded_asset_to_pi_extensions_dir() { + let base = unique_base(); + let home = base.join("home"); + let ext_dir = home.join(".pi/agent/extensions"); + fs::create_dir_all(&ext_dir).unwrap(); + std::env::set_var("HOME", &home); + + let path = install_pi().unwrap(); + let content = fs::read_to_string(&path).unwrap(); + + assert_eq!(path, ext_dir.join(PI_EXTENSION_INSTALL_NAME)); + assert_eq!(content, PI_EXTENSION_ASSET); + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn install_pi_errors_when_extension_dir_missing() { + let base = unique_base(); + let home = base.join("home"); + fs::create_dir_all(&home).unwrap(); + std::env::set_var("HOME", &home); + + let err = install_pi().unwrap_err().to_string(); + + assert!(err.contains("pi extension directory not found")); + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); + } +} |
