diff --git a/configs/nixos/modules/agents/pi/extensions/compact-status.ts b/.pi/extensions/compact-status.ts similarity index 100% rename from configs/nixos/modules/agents/pi/extensions/compact-status.ts rename to .pi/extensions/compact-status.ts diff --git a/.pi/extensions/herdr-agent-state.ts b/.pi/extensions/herdr-agent-state.ts new file mode 100644 index 0000000..caa50ed --- /dev/null +++ b/.pi/extensions/herdr-agent-state.ts @@ -0,0 +1,287 @@ +// installed by herdr +// 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=6 +// @ts-nocheck + +import net from "node:net"; + +const HERDR_ENV = process.env.HERDR_ENV; +const socketPath = process.env.HERDR_SOCKET_PATH; +const socketEndpoint = + process.platform === "win32" && socketPath ? `\\\\.\\pipe\\${socketPath}` : socketPath; +const paneId = process.env.HERDR_PANE_ID; +const source = "herdr:pi"; + +function enabled() { + return HERDR_ENV === "1" && !!socketPath && !!paneId; +} + +function sendRequestAttempt(request: unknown, timeoutMs: number): Promise { + if (!enabled()) { + return Promise.resolve(true); + } + + return new Promise((resolve) => { + let done = false; + let timeout: ReturnType | undefined; + const finish = (delivered: boolean) => { + if (done) return; + done = true; + if (timeout) { + clearTimeout(timeout); + } + socket.destroy(); + resolve(delivered); + }; + + const socket = net.createConnection(socketEndpoint!); + socket.on("error", () => finish(false)); + socket.on("connect", () => socket.write(`${JSON.stringify(request)}\n`)); + socket.on("data", () => finish(true)); + socket.on("end", () => finish(false)); + timeout = setTimeout(() => finish(false), timeoutMs); + timeout.unref?.(); + }); +} + +async function sendRequest(request: unknown): Promise { + if (await sendRequestAttempt(request, 500)) { + return; + } + await sendRequestAttempt(request, 1500); +} + +type AgentState = "working" | "blocked" | "idle"; + +type QueuedState = { + state: AgentState; + message?: string; + seq: number; +}; + +let reportSeq = Date.now() * 1000; +let currentAgentSessionId: string | undefined; +let currentAgentSessionPath: string | undefined; + +function nextReportSeq(): number { + reportSeq += 1; + return reportSeq; +} + +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): Record { + if (currentAgentSessionPath) { + return { ...params, agent_session_path: currentAgentSessionPath }; + } + if (currentAgentSessionId) { + return { ...params, agent_session_id: currentAgentSessionId }; + } + return params; +} + +function currentSessionRef(): Record | undefined { + if (currentAgentSessionPath) { + return { agent_session_path: currentAgentSessionPath }; + } + if (currentAgentSessionId) { + return { agent_session_id: currentAgentSessionId }; + } + return undefined; +} + +function reportSession(sessionStartSource?: string): Promise { + 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(), + session_start_source: sessionStartSource, + ...sessionRef, + }, + }); +} + +function sendState(state: AgentState, message?: string, seq = nextReportSeq()): Promise { + return sendRequest({ + id: `${source}:${Date.now()}:${Math.random().toString(36).slice(2)}`, + method: "pane.report_agent", + params: withSessionRef({ + pane_id: paneId, + source, + agent: "pi", + state, + message, + seq, + }), + }); +} + +function releaseAgent(): Promise { + 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(), + }, + }); +} + +function shouldReleaseOnSessionShutdown(event: any): boolean { + // Pi tears down and rebinds extension runtimes for internal lifecycle actions + // such as /reload, /new, /resume, and /fork. Those do not mean the pane's + // agent process has exited, and releasing hook authority there can suppress + // legitimate reports from the replacement runtime. Only a user/process quit + // should release Herdr's full-lifecycle authority. + const reason = event?.reason; + return reason === "quit"; +} + +let sendInFlight = false; +let queuedState: QueuedState | undefined; + +function queueState(state: AgentState, message?: string): void { + queuedState = { state, message, seq: nextReportSeq() }; + if (!sendInFlight) { + void drainStateQueue(); + } +} + +async function drainStateQueue(): Promise { + if (sendInFlight) { + return; + } + + sendInFlight = true; + try { + while (queuedState) { + const next = queuedState; + queuedState = undefined; + await sendState(next.state, next.message, next.seq); + } + } finally { + sendInFlight = false; + if (queuedState) { + void drainStateQueue(); + } + } +} + +export default function (pi) { + if (!enabled()) { + return; + } + + let agentActive = false; + let blockedCount = 0; + let blockedMessage: string | undefined; + let lastState: AgentState | undefined; + let lastMessage: string | undefined; + let rootSession = false; + + 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(force = false) { + const next = desiredState(); + if (!force && next.state === lastState && next.message === lastMessage) { + return; + } + lastState = next.state; + lastMessage = next.message; + queueState(next.state, next.message); + } + + pi.events.on("herdr:blocked", (data) => { + if (!rootSession) { + return; + } + if (!data?.active) { + blockedCount = Math.max(0, blockedCount - 1); + if (blockedCount === 0) { + blockedMessage = undefined; + } + publishState(); + return; + } + + blockedCount += 1; + blockedMessage = data.label; + publishState(); + }); + + pi.on("session_start", async (event, ctx) => { + if (ctx?.hasUI !== true) { + return; + } + rootSession = true; + updateSessionRef(ctx); + await reportSession(event?.reason); + // A reload can replace this extension mid-run without emitting another agent_start. + agentActive = ctx?.isIdle?.() === false; + publishState(true); + }); + + pi.on("agent_start", (_event, ctx) => { + if (!rootSession) { + return; + } + updateSessionRef(ctx); + void reportSession(); + agentActive = true; + publishState(); + }); + + pi.on("agent_settled", (_event, ctx) => { + if (!rootSession || ctx?.isIdle?.() !== true) { + return; + } + + agentActive = false; + publishState(); + }); + + pi.on("session_shutdown", async (event) => { + if (!rootSession) { + return; + } + if (shouldReleaseOnSessionShutdown(event)) { + await releaseAgent(); + } + }); +} diff --git a/configs/nixos/modules/agents/pi/extensions/.gitkeep b/.pi/prompts/.gitkeep similarity index 100% rename from configs/nixos/modules/agents/pi/extensions/.gitkeep rename to .pi/prompts/.gitkeep diff --git a/AGENTS.md b/AGENTS.md index 31a2487..b1d7b2d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,3 +41,6 @@ Keep executable public interface declarations after their implementation symbols, or validate before committing a top-down rewrite. - `rules_rust` `rust_test` targets cannot set both `crate` and `srcs`. Use one input style per test target. +- In this monorepo, "dotfiles" refers to the dotfiles/NixOS configuration under `src/dotfiles/` and `configs/nixos/`. +- The flake does not currently define `formatter.x86_64-linux`. + Do not use `nix fmt` as a validation command until a formatter is added. diff --git a/configs/nixos/modules/agents/pi/pi.nix b/configs/nixos/modules/agents/pi/pi.nix index 2a0d2c6..c4313d2 100644 --- a/configs/nixos/modules/agents/pi/pi.nix +++ b/configs/nixos/modules/agents/pi/pi.nix @@ -5,7 +5,7 @@ ... }: # Pi, a terminal coding agent, for the primary user, configured through -# home-manager, which ships the package and manages ~/.pi/agent. +# home-manager, which ships the package and manages global settings. # The login credential is left unmanaged, so it survives rebuilds. let cfg = config.modules.agents.pi; @@ -17,26 +17,6 @@ let ./patches/pi-tool-lookup-validation.patch ]; }); - herdrPiIntegration = pkgs.stdenvNoCC.mkDerivation { - name = "herdr-pi-integration"; - nativeBuildInputs = [ pkgs.herdr ]; - phases = [ "installPhase" ]; - installPhase = '' - mkdir -p $TMPDIR/home/.pi/agent/extensions - HOME=$TMPDIR/home herdr integration install pi - mkdir -p $out - cp $TMPDIR/home/.pi/agent/extensions/herdr-agent-state.ts $out/herdr-agent-state.ts - ''; - }; - piExtensions = pkgs.stdenvNoCC.mkDerivation { - name = "pi-extensions"; - phases = [ "installPhase" ]; - installPhase = '' - mkdir -p $out - cp -R ${./extensions}/. $out/ - cp ${herdrPiIntegration}/herdr-agent-state.ts $out/herdr-agent-state.ts - ''; - }; in { options.modules.agents.pi = { @@ -60,22 +40,9 @@ in }; }; - home.file = - { - # The first declarative rollout replaces the interactive settings file. - # Login state stays in auth.json, which this module does not manage. - "${piDir}/settings.json".force = true; - - "${piDir}/extensions" = { - source = piExtensions; - recursive = true; - }; - - "${piDir}/prompts" = { - source = ./prompts; - recursive = true; - }; - }; + # The first declarative rollout replaces the interactive settings file. + # Login state stays in auth.json, which this module does not manage. + home.file."${piDir}/settings.json".force = true; }; }; } diff --git a/configs/nixos/modules/agents/pi/prompts/.gitkeep b/configs/nixos/modules/agents/pi/prompts/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/configs/nixos/modules/shell/herdr.nix b/configs/nixos/modules/shell/herdr.nix index b3d1709..893cef4 100644 --- a/configs/nixos/modules/shell/herdr.nix +++ b/configs/nixos/modules/shell/herdr.nix @@ -18,6 +18,7 @@ in home-manager.users.${user} = { home.packages = [ pkgs.herdr ]; + # Run `PI_CODING_AGENT_DIR=$PWD/.pi herdr integration install pi` from the monorepo root to refresh the vendored project-local Pi extension. xdg.configFile."herdr/config.toml".text = '' [keys] prefix = "ctrl+space"