refactor(pi): move runtime resources into project config
This commit is contained in:
287
.pi/extensions/herdr-agent-state.ts
Normal file
287
.pi/extensions/herdr-agent-state.ts
Normal file
@@ -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<boolean> {
|
||||||
|
if (!enabled()) {
|
||||||
|
return Promise.resolve(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let done = false;
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | 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<void> {
|
||||||
|
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<string, unknown>): Record<string, unknown> {
|
||||||
|
if (currentAgentSessionPath) {
|
||||||
|
return { ...params, agent_session_path: currentAgentSessionPath };
|
||||||
|
}
|
||||||
|
if (currentAgentSessionId) {
|
||||||
|
return { ...params, agent_session_id: currentAgentSessionId };
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentSessionRef(): Record<string, unknown> | undefined {
|
||||||
|
if (currentAgentSessionPath) {
|
||||||
|
return { agent_session_path: currentAgentSessionPath };
|
||||||
|
}
|
||||||
|
if (currentAgentSessionId) {
|
||||||
|
return { agent_session_id: currentAgentSessionId };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportSession(sessionStartSource?: string): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<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",
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -41,3 +41,6 @@
|
|||||||
Keep executable public interface declarations after their implementation symbols, or validate before committing a top-down rewrite.
|
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`.
|
- `rules_rust` `rust_test` targets cannot set both `crate` and `srcs`.
|
||||||
Use one input style per test target.
|
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.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
# Pi, a terminal coding agent, for the primary user, configured through
|
# 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.
|
# The login credential is left unmanaged, so it survives rebuilds.
|
||||||
let
|
let
|
||||||
cfg = config.modules.agents.pi;
|
cfg = config.modules.agents.pi;
|
||||||
@@ -17,26 +17,6 @@ let
|
|||||||
./patches/pi-tool-lookup-validation.patch
|
./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
|
in
|
||||||
{
|
{
|
||||||
options.modules.agents.pi = {
|
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.
|
||||||
# The first declarative rollout replaces the interactive settings file.
|
home.file."${piDir}/settings.json".force = true;
|
||||||
# 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;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ in
|
|||||||
home-manager.users.${user} = {
|
home-manager.users.${user} = {
|
||||||
home.packages = [ pkgs.herdr ];
|
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 = ''
|
xdg.configFile."herdr/config.toml".text = ''
|
||||||
[keys]
|
[keys]
|
||||||
prefix = "ctrl+space"
|
prefix = "ctrl+space"
|
||||||
|
|||||||
Reference in New Issue
Block a user