From f864a2d96b2e072d2afebb593f25f97f08ddfccc Mon Sep 17 00:00:00 2001 From: Garrit Franke Date: Tue, 5 May 2026 13:02:44 +0200 Subject: [PATCH 1/2] feat(desktop): wire pi terminal agent to notification hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #2562, which added pi as a built-in agent (preset, command, icon, settings UI, docs). This PR adds the corresponding notification-hook integration in apps/desktop/src/main/lib/agent-setup/, so pi participates in the same Start/Stop/PostToolUse lifecycle that drives Superset's working indicator and completion chime for claude, codex, gemini, cursor, copilot, opencode, mastra, amp, and droid. Mechanism: ship a small TypeScript pi extension to the global pi extensions directory (~/.pi/agent/extensions/superset-hooks.ts). The extension subscribes to pi's lifecycle events and spawns notify.sh with Claude-Code-format hook_event_name JSON payloads, which the existing notify.sh dispatcher already speaks natively — no per-agent translator shim needed. Mapping: pi before_agent_start → UserPromptSubmit → Superset Start pi tool_execution_end → PostToolUse → progress pi agent_end → Stop → completion / chime pi session_shutdown → Stop → cleanup on Ctrl+C, /quit, /reload Subagent flicker is prevented by gating every event on ctx.hasUI === false (strict equality, so older pi versions where hasUI is undefined still fire). Structurally identical to the opencode integration: template file + wrapper module + barrel re-export + capabilities target + runners map entry. Mirrors writeFileIfChanged + signature/version marker convention. No changes required to notify.sh, the v2 host-service tRPC endpoint, the renderer event bus, or the server-side mapEventType. --- .../main/lib/agent-setup/agent-wrappers-pi.ts | 64 ++++++++++++ .../lib/agent-setup/agent-wrappers.test.ts | 46 +++++++++ .../main/lib/agent-setup/agent-wrappers.ts | 7 ++ .../agent-setup/desktop-agent-capabilities.ts | 5 + .../lib/agent-setup/desktop-agent-setup.ts | 2 + .../templates/pi-extension.template.ts | 98 +++++++++++++++++++ biome.jsonc | 1 + 7 files changed, 223 insertions(+) create mode 100644 apps/desktop/src/main/lib/agent-setup/agent-wrappers-pi.ts create mode 100644 apps/desktop/src/main/lib/agent-setup/templates/pi-extension.template.ts diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-pi.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-pi.ts new file mode 100644 index 00000000000..5bb82c26f37 --- /dev/null +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-pi.ts @@ -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`); +} diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts index 7ccbafbfa59..e92e9aa8e9b 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts @@ -66,6 +66,7 @@ const { createDroidSettingsJson, createDroidWrapper, createMastraWrapper, + createPiExtension, getClaudeGlobalSettingsJsonContent, getClaudeManagedHookCommand, getCodexGlobalHooksJsonContent, @@ -74,6 +75,9 @@ const { getDroidSettingsJsonContent, getGeminiSettingsJsonContent, getMastraHooksJsonContent, + getPiExtensionContent, + getPiExtensionPath, + PI_EXTENSION_MARKER, } = await import("./agent-wrappers"); const { reconcileManagedEntries } = await import("./agent-wrappers-common"); @@ -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"); + }); +}); diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts index 4162078113c..2f51854eac7 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts @@ -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"; diff --git a/apps/desktop/src/main/lib/agent-setup/desktop-agent-capabilities.ts b/apps/desktop/src/main/lib/agent-setup/desktop-agent-capabilities.ts index 2f0c60a93de..4a3a2409719 100644 --- a/apps/desktop/src/main/lib/agent-setup/desktop-agent-capabilities.ts +++ b/apps/desktop/src/main/lib/agent-setup/desktop-agent-capabilities.ts @@ -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", @@ -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: [ diff --git a/apps/desktop/src/main/lib/agent-setup/desktop-agent-setup.ts b/apps/desktop/src/main/lib/agent-setup/desktop-agent-setup.ts index e25e4baa66e..effaacd0d8a 100644 --- a/apps/desktop/src/main/lib/agent-setup/desktop-agent-setup.ts +++ b/apps/desktop/src/main/lib/agent-setup/desktop-agent-setup.ts @@ -19,6 +19,7 @@ import { createMastraWrapper, createOpenCodePlugin, createOpenCodeWrapper, + createPiExtension, } from "./agent-wrappers"; import { DESKTOP_AGENT_SETUP_BOOTSTRAP_ACTIONS, @@ -40,6 +41,7 @@ const DESKTOP_AGENT_SETUP_RUNNERS: Record 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, diff --git a/apps/desktop/src/main/lib/agent-setup/templates/pi-extension.template.ts b/apps/desktop/src/main/lib/agent-setup/templates/pi-extension.template.ts new file mode 100644 index 00000000000..6e72cf01861 --- /dev/null +++ b/apps/desktop/src/main/lib/agent-setup/templates/pi-extension.template.ts @@ -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"); + }); +} diff --git a/biome.jsonc b/biome.jsonc index b86edb20b3a..fb2e438dc5b 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -11,6 +11,7 @@ "!**/drizzle", "!**/*.template.js", "!**/*.template.sh", + "!**/*.template.ts", "!**/plans", "!apps/mobile/uniwind-types.d.ts", "!packages/sdk/src" From 9ce69be586b96d603f1ffde2a7d7cd48c059dce6 Mon Sep 17 00:00:00 2001 From: Garrit Franke Date: Tue, 5 May 2026 13:30:31 +0200 Subject: [PATCH 2/2] fix(desktop): drop redundant comment prefix from pi extension template PI_EXTENSION_MARKER already starts with '//' ("// Superset pi extension v1"), so wrapping the placeholder in another comment produced '// // Superset pi extension v1' on the installed file's first line. Aligns with opencode's template, which places {{MARKER}} on a bare line for the same reason. --- .../src/main/lib/agent-setup/templates/pi-extension.template.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/main/lib/agent-setup/templates/pi-extension.template.ts b/apps/desktop/src/main/lib/agent-setup/templates/pi-extension.template.ts index 6e72cf01861..36247e288fd 100644 --- a/apps/desktop/src/main/lib/agent-setup/templates/pi-extension.template.ts +++ b/apps/desktop/src/main/lib/agent-setup/templates/pi-extension.template.ts @@ -1,4 +1,4 @@ -// {{MARKER}} +{{MARKER}} /** * Superset Notification Extension for pi *