Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions apps/desktop/src/main/lib/agent-setup/agent-wrappers-pi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { writeFileIfChanged } from "./agent-wrappers-common";

export const PI_EXTENSION_FILE = "superset-hooks.ts";

const PI_EXTENSION_SIGNATURE = "// Superset pi extension";
const PI_EXTENSION_VERSION = "v1";
export const PI_EXTENSION_MARKER = `${PI_EXTENSION_SIGNATURE} ${PI_EXTENSION_VERSION}`;

const PI_EXTENSION_TEMPLATE_PATH = path.join(
__dirname,
"templates",
"pi-extension.template.ts",
);

/**
* Returns the global pi extensions directory used by pi's auto-discovery.
*
* Decision (see PRD): we install into the user's global `~/.pi/agent/extensions/`
* rather than an env-scoped Superset-private path. Pi reads
* `PI_CODING_AGENT_DIR` exclusively when set, so an env-scoped install would
* shadow user-installed extensions. Cursor-agent is the precedent for
* "global install, no env override."
*/
export function getPiExtensionPath(): string {
return path.join(
os.homedir(),
".pi",
"agent",
"extensions",
PI_EXTENSION_FILE,
);
}

/**
* Renders the pi extension content with the marker substituted.
*
* The template is environment-independent: it computes the notify.sh path at
* runtime from `SUPERSET_HOME_DIR` (which is set in every Superset terminal
* for both dev and prod installs).
*/
export function getPiExtensionContent(): string {
const template = fs.readFileSync(PI_EXTENSION_TEMPLATE_PATH, "utf-8");
return template.replace("{{MARKER}}", PI_EXTENSION_MARKER);
}

/**
* Writes the Superset-managed pi extension into the global pi extensions
* directory. Idempotent via `writeFileIfChanged`.
*
* Pi auto-discovers extensions in this directory at session start, so no
* registration step is required. The install is unconditional on whether
* pi itself is installed: if the user later installs pi via npm, hooks
* start working with no further setup.
*/
export function createPiExtension(): void {
const extensionPath = getPiExtensionPath();
const content = getPiExtensionContent();
fs.mkdirSync(path.dirname(extensionPath), { recursive: true });
const changed = writeFileIfChanged(extensionPath, content, 0o644);
console.log(`[agent-setup] ${changed ? "Updated" : "Verified"} pi extension`);
}
46 changes: 46 additions & 0 deletions apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ const {
createDroidSettingsJson,
createDroidWrapper,
createMastraWrapper,
createPiExtension,
getClaudeGlobalSettingsJsonContent,
getClaudeManagedHookCommand,
getCodexGlobalHooksJsonContent,
Expand All @@ -74,6 +75,9 @@ const {
getDroidSettingsJsonContent,
getGeminiSettingsJsonContent,
getMastraHooksJsonContent,
getPiExtensionContent,
getPiExtensionPath,
PI_EXTENSION_MARKER,
} = await import("./agent-wrappers");
const { reconcileManagedEntries } = await import("./agent-wrappers-common");

Expand Down Expand Up @@ -1283,3 +1287,45 @@ describe("agent-wrappers codex hooks.json", () => {
).toBeNull();
});
});

describe("agent-wrappers pi", () => {
beforeEach(() => {
mockedHomeDir = path.join(TEST_ROOT, "home");
mkdirSync(TEST_BIN_DIR, { recursive: true });
mkdirSync(TEST_HOOKS_DIR, { recursive: true });
});

afterEach(() => {
rmSync(TEST_ROOT, { recursive: true, force: true });
});

it("renders pi extension content with the marker substituted", () => {
const content = getPiExtensionContent();
expect(content).toContain(PI_EXTENSION_MARKER);
expect(content).not.toContain("{{MARKER}}");
});

it("renders pi extension content as a valid extension default-export shape", () => {
const content = getPiExtensionContent();
expect(content).toContain("export default function");
});

it("installs the pi extension into the global ~/.pi/agent/extensions directory", () => {
const extensionPath = getPiExtensionPath();
expect(extensionPath).toBe(
path.join(
mockedHomeDir,
".pi",
"agent",
"extensions",
"superset-hooks.ts",
),
);

createPiExtension();

const installed = readFileSync(extensionPath, "utf-8");
expect(installed).toContain(PI_EXTENSION_MARKER);
expect(installed).toContain("export default function");
});
});
7 changes: 7 additions & 0 deletions apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,10 @@ export {
getMastraGlobalHooksJsonPath,
getMastraHooksJsonContent,
} from "./agent-wrappers-mastra";
export {
createPiExtension,
getPiExtensionContent,
getPiExtensionPath,
PI_EXTENSION_FILE,
PI_EXTENSION_MARKER,
} from "./agent-wrappers-pi";
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const DESKTOP_AGENT_SETUP_ACTIONS = [
"droid-settings-json",
"opencode-plugin",
"opencode-wrapper",
"pi-extension",
"cursor-hook-script",
"cursor-agent-wrapper",
"cursor-hooks-json",
Expand Down Expand Up @@ -66,6 +67,10 @@ export const DESKTOP_AGENT_SETUP_TARGETS = [
setupActions: ["opencode-plugin", "opencode-wrapper"],
managedBinary: true,
},
{
id: "pi",
setupActions: ["pi-extension"],
},
{
id: "cursor-agent",
setupActions: [
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/lib/agent-setup/desktop-agent-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
createMastraWrapper,
createOpenCodePlugin,
createOpenCodeWrapper,
createPiExtension,
} from "./agent-wrappers";
import {
DESKTOP_AGENT_SETUP_BOOTSTRAP_ACTIONS,
Expand All @@ -40,6 +41,7 @@ const DESKTOP_AGENT_SETUP_RUNNERS: Record<DesktopAgentSetupAction, () => void> =
"droid-settings-json": createDroidSettingsJson,
"opencode-plugin": createOpenCodePlugin,
"opencode-wrapper": createOpenCodeWrapper,
"pi-extension": createPiExtension,
"cursor-hook-script": createCursorHookScript,
"cursor-agent-wrapper": createCursorAgentWrapper,
"cursor-hooks-json": createCursorHooksJson,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
{{MARKER}}
/**
* Superset Notification Extension for pi
*
* Emits Claude-Code-compatible lifecycle hooks to Superset's notify.sh so
* the host UI gets a "working" indicator (and completion chime) for pi
* sessions, the same way it does for Claude Code, Codex, etc.
*
* Mapping:
* pi `before_agent_start` → Claude `UserPromptSubmit` → Superset `Start`
* pi `tool_execution_end` → Claude `PostToolUse` → progress signal
* pi `agent_end` → Claude `Stop` → completion / chime
* pi `session_shutdown` → Claude `Stop` → cleanup on quit/reload
*
* Activates only when running inside a Superset terminal (detected via
* SUPERSET_TERMINAL_ID / SUPERSET_TAB_ID / SUPERSET_PANE_ID). Outside
* Superset it's a complete no-op. If notify.sh is missing it's also a
* no-op (Superset uninstalled / never installed).
*
* Hook dispatch is fire-and-forget: failures to spawn or curl never
* affect the agent loop. notify.sh has its own connect/max timeouts.
*/

import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";

export default function (pi: ExtensionAPI) {
// Only activate inside a Superset terminal. Both v2 (host-service) and
// v1 (electron localhost) shells set at least one of these.
const insideSuperset = Boolean(
process.env.SUPERSET_TERMINAL_ID ||
process.env.SUPERSET_TAB_ID ||
process.env.SUPERSET_PANE_ID,
);
if (!insideSuperset) return;

const supersetHome =
process.env.SUPERSET_HOME_DIR || join(homedir(), ".superset");
const notifyScript = join(supersetHome, "hooks", "notify.sh");
if (!existsSync(notifyScript)) return;

const fire = (eventName: string) => {
try {
const child = spawn(notifyScript, [], {
stdio: ["pipe", "ignore", "ignore"],
detached: true,
env: process.env,
});
child.on("error", () => {
/* swallow — never let hook failures affect pi */
});
child.stdin?.on("error", () => {
/* swallow — happens if notify.sh exits before we finish writing */
});
child.stdin?.end(JSON.stringify({ hook_event_name: eventName }));
child.unref();
} catch {
// spawn() can throw synchronously (EACCES, ENOENT). Stay silent.
}
};

// Gate every hook on ctx.hasUI: when this is explicitly false (print
// mode `-p`, JSON mode), pi is running as a subagent or non-interactive
// helper and should NOT drive Superset's working indicator. Interactive
// and RPC sessions (the user-facing ones) have hasUI=true.
//
// We deliberately check `=== false` rather than `!ctx.hasUI` so that pi
// versions older than 0.38.0 (where `hasUI` did not yet exist) still
// fire hooks. On those older versions subagent flicker is possible, but
// that's a niche regression; on >=0.38.0 the gate works precisely.
const skip = (ctx: { hasUI?: boolean }) => ctx.hasUI === false;

pi.on("before_agent_start", (_event, ctx) => {
if (skip(ctx)) return;
fire("UserPromptSubmit");
});

pi.on("tool_execution_end", (_event, ctx) => {
if (skip(ctx)) return;
fire("PostToolUse");
});

pi.on("agent_end", (_event, ctx) => {
if (skip(ctx)) return;
fire("Stop");
});

// Ensure we mark the agent as "stopped" if pi is killed mid-run, so the
// Superset working indicator doesn't get stuck on. Fires on Ctrl+C,
// SIGTERM, SIGHUP, /quit, /reload, /new, /resume, /fork.
pi.on("session_shutdown", (_event, ctx) => {
if (skip(ctx)) return;
fire("Stop");
});
}
1 change: 1 addition & 0 deletions biome.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"!**/drizzle",
"!**/*.template.js",
"!**/*.template.sh",
"!**/*.template.ts",
"!**/plans",
"!apps/mobile/uniwind-types.d.ts",
"!packages/sdk/src"
Expand Down