aboutsummaryrefslogtreecommitdiff
path: root/src/integration
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-04-01 19:30:58 +0300
committerCan Celik <ogulcancelik@gmail.com>2026-04-01 19:32:26 +0300
commitca4270b31fe93c180f7321cb4006cb5e7307d889 (patch)
tree72628e311a1b8e5ebf78c74cbc61d990253dc4c4 /src/integration
parentfb0c9717cfa961a47d63272e622e9e35a001082a (diff)
feat: add authoritative pi hook integration
Diffstat (limited to 'src/integration')
-rw-r--r--src/integration/assets/pi/herdr-agent-state.ts125
-rw-r--r--src/integration/mod.rs86
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);
+ }
+}