feat(nixos): build vendored Pi #46

Merged
alexion merged 3 commits from import-pi-0.85.1 into main 2026-09-14 10:51:32 -04:00
6 changed files with 54 additions and 511 deletions
Showing only changes of commit 9e7759c9f5 - Show all commits

View File

@@ -1,287 +0,0 @@
// 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();
}
});
}

View File

@@ -11,12 +11,10 @@ let
cfg = config.modules.shell.pi;
user = config.user.name;
piDir = "${config.users.users.${user}.home}/.pi/agent";
patchedPi = pkgs.pi-coding-agent.overrideAttrs (old: {
patches = (old.patches or [ ]) ++ [
./patches/pi-project-resource-ancestors.patch
./patches/pi-tool-lookup-validation.patch
];
});
vendoredPi = pkgs.pi-coding-agent.overrideAttrs {
src = ../../../../../third_party/pi;
patches = [ ];
};
in
{
options.modules.shell.pi = {
@@ -28,7 +26,7 @@ in
home-manager.users.${user} = {
programs.pi-coding-agent = {
enable = true;
package = patchedPi;
package = vendoredPi;
settings = {
defaultProvider = "openai-codex";

View File

@@ -1,140 +0,0 @@
diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts
--- a/packages/coding-agent/src/core/package-manager.ts
+++ b/packages/coding-agent/src/core/package-manager.ts
@@ -459,14 +459,14 @@ function findGitRepoRoot(startDir: string): string | null {
}
}
-function collectAncestorAgentsSkillDirs(startDir: string): string[] {
- const skillDirs: string[] = [];
+function collectAncestorConfigDirs(startDir: string, configDirName: string): string[] {
+ const configDirs: string[] = [];
const resolvedStartDir = resolve(startDir);
const gitRepoRoot = findGitRepoRoot(resolvedStartDir);
let dir = resolvedStartDir;
while (true) {
- skillDirs.push(join(dir, ".agents", "skills"));
+ configDirs.push(join(dir, configDirName));
if (gitRepoRoot && dir === gitRepoRoot) {
break;
}
@@ -477,7 +477,7 @@ function collectAncestorAgentsSkillDirs(startDir: string): string[] {
dir = parent;
}
- return skillDirs;
+ return configDirs;
}
function collectAutoPromptEntries(dir: string): string[] {
@@ -2388,16 +2388,13 @@ export class DefaultPackageManager implements PackageManager {
prompts: join(globalBaseDir, "prompts"),
themes: join(globalBaseDir, "themes"),
};
- const projectDirs = {
- extensions: join(projectBaseDir, "extensions"),
- skills: join(projectBaseDir, "skills"),
- prompts: join(projectBaseDir, "prompts"),
- themes: join(projectBaseDir, "themes"),
- };
const userAgentsSkillsDir = join(getHomeDir(), ".agents", "skills");
const projectTrusted = this.settingsManager.isProjectTrusted();
- const projectAgentsSkillDirs = projectTrusted
- ? collectAncestorAgentsSkillDirs(this.cwd).filter((dir) => resolve(dir) !== resolve(userAgentsSkillsDir))
+ const projectConfigDirs = projectTrusted
+ ? collectAncestorConfigDirs(this.cwd, CONFIG_DIR_NAME).filter((dir) => resolve(dir) !== resolve(globalBaseDir))
+ : [];
+ const projectAgentsDirs = projectTrusted
+ ? collectAncestorConfigDirs(this.cwd, ".agents").filter((dir) => resolve(dir) !== resolve(dirname(userAgentsSkillsDir)))
: [];
const addResources = (
@@ -2414,56 +2411,54 @@ export class DefaultPackageManager implements PackageManager {
}
};
- if (projectTrusted) {
- // Project extensions from .pi/
+ // Project resources from .pi/ in cwd and ancestors.
+ for (const configDir of projectConfigDirs) {
+ const configMetadata: PathMetadata = {
+ ...projectMetadata,
+ baseDir: configDir,
+ };
addResources(
"extensions",
- collectAutoExtensionEntries(projectDirs.extensions),
- projectMetadata,
+ collectAutoExtensionEntries(join(configDir, "extensions")),
+ configMetadata,
projectOverrides.extensions,
- projectBaseDir,
+ configDir,
);
-
- // Project skills from .pi/
addResources(
"skills",
- collectAutoSkillEntries(projectDirs.skills, "pi"),
- projectMetadata,
+ collectAutoSkillEntries(join(configDir, "skills"), "pi"),
+ configMetadata,
projectOverrides.skills,
- projectBaseDir,
+ configDir,
+ );
+ addResources(
+ "prompts",
+ collectAutoPromptEntries(join(configDir, "prompts")),
+ configMetadata,
+ projectOverrides.prompts,
+ configDir,
+ );
+ addResources(
+ "themes",
+ collectAutoThemeEntries(join(configDir, "themes")),
+ configMetadata,
+ projectOverrides.themes,
+ configDir,
);
}
- // Project skills from .agents/ (each with its own baseDir)
- for (const agentsSkillsDir of projectAgentsSkillDirs) {
- const agentsBaseDir = dirname(agentsSkillsDir); // the .agents directory
+ // Project skills from .agents/ in cwd and ancestors.
+ for (const agentsDir of projectAgentsDirs) {
const agentsMetadata: PathMetadata = {
...projectMetadata,
- baseDir: agentsBaseDir,
+ baseDir: agentsDir,
};
addResources(
"skills",
- collectAutoSkillEntries(agentsSkillsDir, "agents"),
+ collectAutoSkillEntries(join(agentsDir, "skills"), "agents"),
agentsMetadata,
projectOverrides.skills,
- agentsBaseDir,
- );
- }
-
- if (projectTrusted) {
- addResources(
- "prompts",
- collectAutoPromptEntries(projectDirs.prompts),
- projectMetadata,
- projectOverrides.prompts,
- projectBaseDir,
- );
- addResources(
- "themes",
- collectAutoThemeEntries(projectDirs.themes),
- projectMetadata,
- projectOverrides.themes,
- projectBaseDir,
+ agentsDir,
);
}

View File

@@ -1,23 +0,0 @@
diff --git a/packages/coding-agent/src/utils/tools-manager.ts b/packages/coding-agent/src/utils/tools-manager.ts
--- a/packages/coding-agent/src/utils/tools-manager.ts 2026-08-01 18:41:36.970496010 -0400
+++ b/packages/coding-agent/src/utils/tools-manager.ts 2026-08-01 18:41:37.028186009 -0400
@@ -74,8 +74,8 @@
function commandExists(cmd: string): boolean {
try {
const result = spawnSync(cmd, ["--version"], { stdio: "pipe" });
- // Check for ENOENT error (command not found)
- return result.error === undefined || result.error === null;
+ // Downloaded generic Linux binaries can exist but fail on NixOS before they print a version.
+ return (result.error === undefined || result.error === null) && result.status === 0;
} catch {
return false;
}
@@ -88,7 +87,7 @@
// Check our tools directory first
const localPath = join(TOOLS_DIR, config.binaryName + (platform() === "win32" ? ".exe" : ""));
- if (existsSync(localPath)) {
+ if (existsSync(localPath) && commandExists(localPath)) {
return localPath;
}

View File

@@ -459,14 +459,14 @@ function findGitRepoRoot(startDir: string): string | null {
}
}
function collectAncestorAgentsSkillDirs(startDir: string): string[] {
const skillDirs: string[] = [];
function collectAncestorConfigDirs(startDir: string, configDirName: string): string[] {
const configDirs: string[] = [];
const resolvedStartDir = resolve(startDir);
const gitRepoRoot = findGitRepoRoot(resolvedStartDir);
let dir = resolvedStartDir;
while (true) {
skillDirs.push(join(dir, ".agents", "skills"));
configDirs.push(join(dir, configDirName));
if (gitRepoRoot && dir === gitRepoRoot) {
break;
}
@@ -477,7 +477,7 @@ function collectAncestorAgentsSkillDirs(startDir: string): string[] {
dir = parent;
}
return skillDirs;
return configDirs;
}
function collectAutoPromptEntries(dir: string): string[] {
@@ -2388,16 +2388,13 @@ export class DefaultPackageManager implements PackageManager {
prompts: join(globalBaseDir, "prompts"),
themes: join(globalBaseDir, "themes"),
};
const projectDirs = {
extensions: join(projectBaseDir, "extensions"),
skills: join(projectBaseDir, "skills"),
prompts: join(projectBaseDir, "prompts"),
themes: join(projectBaseDir, "themes"),
};
const userAgentsSkillsDir = join(getHomeDir(), ".agents", "skills");
const projectTrusted = this.settingsManager.isProjectTrusted();
const projectAgentsSkillDirs = projectTrusted
? collectAncestorAgentsSkillDirs(this.cwd).filter((dir) => resolve(dir) !== resolve(userAgentsSkillsDir))
const projectConfigDirs = projectTrusted
? collectAncestorConfigDirs(this.cwd, CONFIG_DIR_NAME).filter((dir) => resolve(dir) !== resolve(globalBaseDir))
: [];
const projectAgentsDirs = projectTrusted
? collectAncestorConfigDirs(this.cwd, ".agents").filter((dir) => resolve(dir) !== resolve(dirname(userAgentsSkillsDir)))
: [];
const addResources = (
@@ -2414,56 +2411,54 @@ export class DefaultPackageManager implements PackageManager {
}
};
if (projectTrusted) {
// Project extensions from .pi/
addResources(
"extensions",
collectAutoExtensionEntries(projectDirs.extensions),
projectMetadata,
projectOverrides.extensions,
projectBaseDir,
);
// Project skills from .pi/
addResources(
"skills",
collectAutoSkillEntries(projectDirs.skills, "pi"),
projectMetadata,
projectOverrides.skills,
projectBaseDir,
);
}
// Project skills from .agents/ (each with its own baseDir)
for (const agentsSkillsDir of projectAgentsSkillDirs) {
const agentsBaseDir = dirname(agentsSkillsDir); // the .agents directory
const agentsMetadata: PathMetadata = {
// Project resources from .pi/ in cwd and ancestors.
for (const configDir of projectConfigDirs) {
const configMetadata: PathMetadata = {
...projectMetadata,
baseDir: agentsBaseDir,
baseDir: configDir,
};
addResources(
"skills",
collectAutoSkillEntries(agentsSkillsDir, "agents"),
agentsMetadata,
projectOverrides.skills,
agentsBaseDir,
"extensions",
collectAutoExtensionEntries(join(configDir, "extensions")),
configMetadata,
projectOverrides.extensions,
configDir,
);
addResources(
"skills",
collectAutoSkillEntries(join(configDir, "skills"), "pi"),
configMetadata,
projectOverrides.skills,
configDir,
);
}
if (projectTrusted) {
addResources(
"prompts",
collectAutoPromptEntries(projectDirs.prompts),
projectMetadata,
collectAutoPromptEntries(join(configDir, "prompts")),
configMetadata,
projectOverrides.prompts,
projectBaseDir,
configDir,
);
addResources(
"themes",
collectAutoThemeEntries(projectDirs.themes),
projectMetadata,
collectAutoThemeEntries(join(configDir, "themes")),
configMetadata,
projectOverrides.themes,
projectBaseDir,
configDir,
);
}
// Project skills from .agents/ in cwd and ancestors.
for (const agentsDir of projectAgentsDirs) {
const agentsMetadata: PathMetadata = {
...projectMetadata,
baseDir: agentsDir,
};
addResources(
"skills",
collectAutoSkillEntries(join(agentsDir, "skills"), "agents"),
agentsMetadata,
projectOverrides.skills,
agentsDir,
);
}

View File

@@ -72,8 +72,8 @@ const TOOLS: Record<string, ToolConfig> = {
function commandExists(cmd: string): boolean {
try {
const result = spawnSync(cmd, ["--version"], { stdio: "pipe" });
// Check for ENOENT error (command not found)
return result.error === undefined || result.error === null;
// Downloaded generic Linux binaries can exist but fail on NixOS before they print a version.
return (result.error === undefined || result.error === null) && result.status === 0;
} catch {
return false;
}
@@ -86,7 +86,7 @@ export function getToolPath(tool: "fd" | "rg"): string | null {
// Check our tools directory first
const localPath = join(TOOLS_DIR, config.binaryName + (platform() === "win32" ? ".exe" : ""));
if (existsSync(localPath)) {
if (existsSync(localPath) && commandExists(localPath)) {
return localPath;
}