From 66b69b47e6c7a9896bd1f1c584831409eacacba5 Mon Sep 17 00:00:00 2001 From: dyoshikawa Date: Sun, 19 Jul 2026 20:14:27 -0700 Subject: [PATCH 1/2] feat(pi): add hooks support via generated TypeScript extension Pi Coding Agent has no static hooks config; bridge canonical hooks by generating a rulesync-owned TypeScript extension in Pi's extension discovery paths (.pi/extensions/rulesync-hooks.ts project-scope, ~/.pi/agent/extensions/rulesync-hooks.ts global-scope), following the OpenCode/Kilo generated-plugin precedent. - map canonical events to Pi extension events (sessionStart -> session_start, sessionEnd -> session_shutdown, preToolUse -> tool_call, postToolUse -> tool_result, preModelInvocation -> context, beforeSubmitPrompt -> input, stop -> agent_end, preCompact -> session_before_compact) - honor hook matchers as regexes tested against event.toolName on tool events; embed commands/matchers via JSON.stringify for safe codegen - the generated extension observes events only (no block/mutate) and executes command hooks via the platform shell - register pi in the hooks processor and target tuple, add unit tests (including loading the generated module via tsx and asserting the registered subscriptions), extend the hooks e2e matrix, and update docs/tables/gitignore Closes #2319 Co-Authored-By: Claude Fable 5 --- .gitignore | 1 + README.md | 2 +- cspell.json | 1 + docs/reference/file-formats.md | 2 +- docs/reference/supported-tools.md | 2 +- skills/rulesync/file-formats.md | 2 +- skills/rulesync/supported-tools.md | 2 +- src/constants/pi-paths.ts | 3 + src/e2e/e2e-hooks.spec.ts | 17 + src/features/hooks/hooks-processor.test.ts | 6 +- src/features/hooks/hooks-processor.ts | 23 ++ src/features/hooks/pi-extension-generator.ts | 132 +++++++ src/features/hooks/pi-hooks.test.ts | 355 +++++++++++++++++++ src/features/hooks/pi-hooks.ts | 108 ++++++ src/types/hooks.ts | 46 +++ src/types/tool-target-tuples.ts | 1 + 16 files changed, 696 insertions(+), 7 deletions(-) create mode 100644 src/features/hooks/pi-extension-generator.ts create mode 100644 src/features/hooks/pi-hooks.test.ts create mode 100644 src/features/hooks/pi-hooks.ts diff --git a/.gitignore b/.gitignore index 76c8168b4..a9750a719 100644 --- a/.gitignore +++ b/.gitignore @@ -363,6 +363,7 @@ rulesync.local.jsonc **/.github/hooks/copilotcli-hooks.json **/.kilo/plugins/rulesync-hooks.js **/.opencode/plugins/rulesync-hooks.js +**/.pi/extensions/rulesync-hooks.ts **/.factory/hooks.json **/.agents/plugins/rulesync/hooks/hooks.json **/.kiro/agents/default.json diff --git a/README.md b/README.md index 133113332..05146e631 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ The tables below show whether each tool supports a given feature (✅ = supporte | Devin Desktop | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | Warp | ✅ | ✅ | ✅ | | | ✅ | | ✅ | | | Replit | ✅ | | | | | ✅ | | | | -| Pi Coding Agent | ✅ | | | ✅ | | ✅ | | | | +| Pi Coding Agent | ✅ | | | ✅ | | ✅ | ✅ | | | | Zed | ✅ | ✅ | ✅ | | | ✅ | | ✅ | | diff --git a/cspell.json b/cspell.json index e0afcaf95..71a4b0d33 100644 --- a/cspell.json +++ b/cspell.json @@ -124,6 +124,7 @@ "dyoshikawa", "eabi", "eamodio", + "earendil", "eastasianwidth", "elicitations", "émøjî", diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index 344fccac8..0250d5e96 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -87,7 +87,7 @@ This is Rulesync, a Node.js CLI tool that automatically generates configuration > **JSONC variant:** hooks can also be authored as `.rulesync/hooks.jsonc` (JSON with comments and trailing commas). When both `hooks.json` and `hooks.jsonc` exist, the `.jsonc` file takes precedence. -Hooks run scripts at lifecycle events (e.g. session start, before tool use). Events use **canonical camelCase** in this file, and Rulesync translates them per tool: Cursor uses them as-is; Claude Code, Factory Droid, Codex CLI, Gemini CLI, and Goose get PascalCase (with a few tool-specific name mappings) in their settings files; OpenCode and Kilo hooks are emitted as JavaScript plugins (`.opencode/plugins/rulesync-hooks.js`, `.kilo/plugins/rulesync-hooks.js`); Copilot and Copilot CLI map event names to their own camelCase (e.g. `beforeSubmitPrompt` → `userPromptSubmitted`, `stop` → `agentStop`, `afterError` → `errorOccurred`) and use `powershell`/`bash` command fields — Copilot CLI additionally covers a wider event set and supports `prompt` and `http` hook types beyond `command`; deepagents-cli uses a dot-notation (e.g. `session.start`, `tool.error`); Kiro emits hooks into `.kiro/agents/default.json` using Kiro's CLI event names (`agentSpawn`, `userPromptSubmit`, `preToolUse`, `postToolUse`, `stop`); Qwen Code emits PascalCase events into the `hooks` key of `.qwen/settings.json` (its supported event set differs from Gemini CLI's). +Hooks run scripts at lifecycle events (e.g. session start, before tool use). Events use **canonical camelCase** in this file, and Rulesync translates them per tool: Cursor uses them as-is; Claude Code, Factory Droid, Codex CLI, Gemini CLI, and Goose get PascalCase (with a few tool-specific name mappings) in their settings files; OpenCode and Kilo hooks are emitted as JavaScript plugins (`.opencode/plugins/rulesync-hooks.js`, `.kilo/plugins/rulesync-hooks.js`); Pi Coding Agent hooks are emitted as a Rulesync-owned TypeScript extension (`.pi/extensions/rulesync-hooks.ts`, or `~/.pi/agent/extensions/rulesync-hooks.ts` in global mode) that subscribes to Pi's snake_case extension events (`sessionStart` → `session_start`, `stop` → `agent_end`, `preToolUse` → `tool_call` with the matcher tested as a regex against the tool name) and observes events only — command hooks run but cannot block or mutate Pi events; Copilot and Copilot CLI map event names to their own camelCase (e.g. `beforeSubmitPrompt` → `userPromptSubmitted`, `stop` → `agentStop`, `afterError` → `errorOccurred`) and use `powershell`/`bash` command fields — Copilot CLI additionally covers a wider event set and supports `prompt` and `http` hook types beyond `command`; deepagents-cli uses a dot-notation (e.g. `session.start`, `tool.error`); Kiro emits hooks into `.kiro/agents/default.json` using Kiro's CLI event names (`agentSpawn`, `userPromptSubmit`, `preToolUse`, `postToolUse`, `stop`); Qwen Code emits PascalCase events into the `hooks` key of `.qwen/settings.json` (its supported event set differs from Gemini CLI's). Example: diff --git a/docs/reference/supported-tools.md b/docs/reference/supported-tools.md index 6542536dc..3a015afcd 100644 --- a/docs/reference/supported-tools.md +++ b/docs/reference/supported-tools.md @@ -39,7 +39,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | Devin Desktop | devin | ✅ 🌏 | ✅ | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | | Warp | warp | ✅ | ✅ | ✅ 🌏 | | | ✅ 🌏 | | 🌏 | | | Replit | replit | ✅ | | | | | ✅ 🌏 | | | | -| Pi Coding Agent | pi | ✅ 🌏 | | | ✅ 🌏 | | ✅ 🌏 | | | | +| Pi Coding Agent | pi | ✅ 🌏 | | | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | | | | Zed | zed | ✅ 🌏 | ✅ | ✅ 🌏 | | | ✅ 🌏 | | ✅ 🌏 | | diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index ecd4b28fa..d8d5e339f 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -87,7 +87,7 @@ This is Rulesync, a Node.js CLI tool that automatically generates configuration > **JSONC variant:** hooks can also be authored as `.rulesync/hooks.jsonc` (JSON with comments and trailing commas). When both `hooks.json` and `hooks.jsonc` exist, the `.jsonc` file takes precedence. -Hooks run scripts at lifecycle events (e.g. session start, before tool use). Events use **canonical camelCase** in this file, and Rulesync translates them per tool: Cursor uses them as-is; Claude Code, Factory Droid, Codex CLI, Gemini CLI, and Goose get PascalCase (with a few tool-specific name mappings) in their settings files; OpenCode and Kilo hooks are emitted as JavaScript plugins (`.opencode/plugins/rulesync-hooks.js`, `.kilo/plugins/rulesync-hooks.js`); Copilot and Copilot CLI map event names to their own camelCase (e.g. `beforeSubmitPrompt` → `userPromptSubmitted`, `stop` → `agentStop`, `afterError` → `errorOccurred`) and use `powershell`/`bash` command fields — Copilot CLI additionally covers a wider event set and supports `prompt` and `http` hook types beyond `command`; deepagents-cli uses a dot-notation (e.g. `session.start`, `tool.error`); Kiro emits hooks into `.kiro/agents/default.json` using Kiro's CLI event names (`agentSpawn`, `userPromptSubmit`, `preToolUse`, `postToolUse`, `stop`); Qwen Code emits PascalCase events into the `hooks` key of `.qwen/settings.json` (its supported event set differs from Gemini CLI's). +Hooks run scripts at lifecycle events (e.g. session start, before tool use). Events use **canonical camelCase** in this file, and Rulesync translates them per tool: Cursor uses them as-is; Claude Code, Factory Droid, Codex CLI, Gemini CLI, and Goose get PascalCase (with a few tool-specific name mappings) in their settings files; OpenCode and Kilo hooks are emitted as JavaScript plugins (`.opencode/plugins/rulesync-hooks.js`, `.kilo/plugins/rulesync-hooks.js`); Pi Coding Agent hooks are emitted as a Rulesync-owned TypeScript extension (`.pi/extensions/rulesync-hooks.ts`, or `~/.pi/agent/extensions/rulesync-hooks.ts` in global mode) that subscribes to Pi's snake_case extension events (`sessionStart` → `session_start`, `stop` → `agent_end`, `preToolUse` → `tool_call` with the matcher tested as a regex against the tool name) and observes events only — command hooks run but cannot block or mutate Pi events; Copilot and Copilot CLI map event names to their own camelCase (e.g. `beforeSubmitPrompt` → `userPromptSubmitted`, `stop` → `agentStop`, `afterError` → `errorOccurred`) and use `powershell`/`bash` command fields — Copilot CLI additionally covers a wider event set and supports `prompt` and `http` hook types beyond `command`; deepagents-cli uses a dot-notation (e.g. `session.start`, `tool.error`); Kiro emits hooks into `.kiro/agents/default.json` using Kiro's CLI event names (`agentSpawn`, `userPromptSubmit`, `preToolUse`, `postToolUse`, `stop`); Qwen Code emits PascalCase events into the `hooks` key of `.qwen/settings.json` (its supported event set differs from Gemini CLI's). Example: diff --git a/skills/rulesync/supported-tools.md b/skills/rulesync/supported-tools.md index 6542536dc..3a015afcd 100644 --- a/skills/rulesync/supported-tools.md +++ b/skills/rulesync/supported-tools.md @@ -39,7 +39,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | Devin Desktop | devin | ✅ 🌏 | ✅ | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | | Warp | warp | ✅ | ✅ | ✅ 🌏 | | | ✅ 🌏 | | 🌏 | | | Replit | replit | ✅ | | | | | ✅ 🌏 | | | | -| Pi Coding Agent | pi | ✅ 🌏 | | | ✅ 🌏 | | ✅ 🌏 | | | | +| Pi Coding Agent | pi | ✅ 🌏 | | | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | | | | Zed | zed | ✅ 🌏 | ✅ | ✅ 🌏 | | | ✅ 🌏 | | ✅ 🌏 | | diff --git a/src/constants/pi-paths.ts b/src/constants/pi-paths.ts index 399f718c3..9918051a0 100644 --- a/src/constants/pi-paths.ts +++ b/src/constants/pi-paths.ts @@ -2,9 +2,12 @@ import { join } from "node:path"; export const PI_DIR = ".pi"; const PI_AGENT_DIR = join(PI_DIR, "agent"); +export const PI_AGENT_EXTENSIONS_DIR_PATH = join(PI_AGENT_DIR, "extensions"); export const PI_AGENT_PROMPTS_DIR_PATH = join(PI_AGENT_DIR, "prompts"); export const PI_AGENT_SKILLS_DIR_PATH = join(PI_AGENT_DIR, "skills"); +export const PI_EXTENSIONS_DIR_PATH = join(PI_DIR, "extensions"); export const PI_PROMPTS_DIR_PATH = join(PI_DIR, "prompts"); export const PI_SKILLS_DIR_PATH = join(PI_DIR, "skills"); export const PI_RULE_FILE_NAME = "AGENTS.md"; export const PI_APPEND_SYSTEM_FILE_NAME = "APPEND_SYSTEM.md"; +export const PI_HOOKS_FILE_NAME = "rulesync-hooks.ts"; diff --git a/src/e2e/e2e-hooks.spec.ts b/src/e2e/e2e-hooks.spec.ts index 17e4e8e03..6ddc1b412 100644 --- a/src/e2e/e2e-hooks.spec.ts +++ b/src/e2e/e2e-hooks.spec.ts @@ -37,6 +37,7 @@ const hooksGenerateTargets = [ { target: "cursor", outputPath: join(".cursor", "hooks.json") }, { target: "opencode", outputPath: join(".opencode", "plugins", "rulesync-hooks.js") }, { target: "kilo", outputPath: join(".kilo", "plugins", "rulesync-hooks.js") }, + { target: "pi", outputPath: join(".pi", "extensions", "rulesync-hooks.ts") }, { target: "codexcli", outputPath: join(".codex", "hooks.json") }, { target: "qwencode", outputPath: join(".qwen", "settings.json") }, { @@ -103,6 +104,14 @@ describe("E2E: hooks", () => { // so assert the canonical command paths survive rather than parsing JSON. expect(generatedContent).toContain(".rulesync/hooks/session-start.sh"); expect(generatedContent).toContain(".rulesync/hooks/audit.sh"); + } else if (target === "pi") { + // Pi emits a TypeScript extension (.pi/extensions/rulesync-hooks.ts) + // that subscribes to snake_case extension events: sessionStart → + // session_start, stop → agent_end. + expect(generatedContent).toContain('pi.on("session_start"'); + expect(generatedContent).toContain('pi.on("agent_end"'); + expect(generatedContent).toContain(".rulesync/hooks/session-start.sh"); + expect(generatedContent).toContain(".rulesync/hooks/audit.sh"); } else { const parsed = JSON.parse(generatedContent); @@ -388,6 +397,7 @@ describe("E2E: hooks", () => { // factorydroid now writes a dedicated .factory/hooks.json (isDeletable=true). { target: "cursor", orphanPath: join(".cursor", "hooks.json") }, { target: "opencode", orphanPath: join(".opencode", "plugins", "rulesync-hooks.js") }, + { target: "pi", orphanPath: join(".pi", "extensions", "rulesync-hooks.ts") }, { target: "codexcli", orphanPath: join(".codex", "hooks.json") }, { target: "copilot", orphanPath: join(".github", "hooks", "copilot-hooks.json") }, { target: "factorydroid", orphanPath: join(".factory", "hooks.json") }, @@ -602,6 +612,7 @@ const hooksGlobalTargets = [ }, { target: "opencode", outputPath: join(".config", "opencode", "plugins", "rulesync-hooks.js") }, { target: "kilo", outputPath: join(".config", "kilo", "plugins", "rulesync-hooks.js") }, + { target: "pi", outputPath: join(".pi", "agent", "extensions", "rulesync-hooks.ts") }, { target: "factorydroid", outputPath: join(".factory", "hooks.json") }, { target: "deepagents", outputPath: join(".deepagents", "hooks.json") }, { target: "junie", outputPath: join(".junie", "config.json") }, @@ -667,6 +678,12 @@ describe("E2E: hooks (global mode)", () => { // Kilo's JS plugin differs from OpenCode's shape; assert command paths. expect(generatedContent).toContain(".rulesync/hooks/session-start.sh"); expect(generatedContent).toContain(".rulesync/hooks/audit.sh"); + } else if (target === "pi") { + // Pi emits a TypeScript extension subscribing to snake_case events. + expect(generatedContent).toContain('pi.on("session_start"'); + expect(generatedContent).toContain('pi.on("agent_end"'); + expect(generatedContent).toContain(".rulesync/hooks/session-start.sh"); + expect(generatedContent).toContain(".rulesync/hooks/audit.sh"); } else if (target === "copilotcli") { // Copilot CLI does not support the `stop` hook event, so audit.sh is // intentionally dropped during generation. diff --git a/src/features/hooks/hooks-processor.test.ts b/src/features/hooks/hooks-processor.test.ts index c7fe5c614..c7a822885 100644 --- a/src/features/hooks/hooks-processor.test.ts +++ b/src/features/hooks/hooks-processor.test.ts @@ -519,7 +519,7 @@ describe("HooksProcessor", () => { }); describe("getToolTargets", () => { - it("should return cursor, claudecode, copilot, copilotcli, opencode, kilo, factorydroid, and kiro for project mode", () => { + it("should return cursor, claudecode, copilot, copilotcli, opencode, kilo, pi, factorydroid, and kiro for project mode", () => { const targets = HooksProcessor.getToolTargets({ global: false }); expect(targets).toEqual([ "antigravity-cli", @@ -531,6 +531,7 @@ describe("HooksProcessor", () => { "copilotcli", "kilo", "opencode", + "pi", "factorydroid", "goose", "kiro", @@ -545,7 +546,7 @@ describe("HooksProcessor", () => { ]); }); - it("should return cursor, claudecode, copilotcli, opencode, kilo, and factorydroid for global mode", () => { + it("should return cursor, claudecode, copilotcli, opencode, kilo, pi, and factorydroid for global mode", () => { const targets = HooksProcessor.getToolTargets({ global: true }); expect(targets).toEqual([ "antigravity-cli", @@ -556,6 +557,7 @@ describe("HooksProcessor", () => { "copilotcli", "kilo", "opencode", + "pi", "factorydroid", "goose", "hermesagent", diff --git a/src/features/hooks/hooks-processor.ts b/src/features/hooks/hooks-processor.ts index 1f862aaec..8f1020fe3 100644 --- a/src/features/hooks/hooks-processor.ts +++ b/src/features/hooks/hooks-processor.ts @@ -21,6 +21,7 @@ import { KIRO_HOOK_EVENTS, KIRO_IDE_HOOK_EVENTS, OPENCODE_HOOK_EVENTS, + PI_HOOK_EVENTS, QWENCODE_HOOK_EVENTS, REASONIX_HOOK_EVENTS, VIBE_HOOK_EVENTS, @@ -52,6 +53,7 @@ import { KiroCliHooks } from "./kiro-cli-hooks.js"; import { KiroHooks } from "./kiro-hooks.js"; import { KiroIdeHooks } from "./kiro-ide-hooks.js"; import { OpencodeHooks } from "./opencode-hooks.js"; +import { PiHooks } from "./pi-hooks.js"; import { QwencodeHooks } from "./qwencode-hooks.js"; import { ReasonixHooks } from "./reasonix-hooks.js"; import { RulesyncHooks } from "./rulesync-hooks.js"; @@ -254,6 +256,27 @@ export const toolHooksFactories = new Map; + +function collectPiHandlers({ + effectiveHooks, + eventMap, +}: { + effectiveHooks: HooksConfig["hooks"]; + eventMap: Record; +}): HandlerGroup { + const handlerGroups: HandlerGroup = {}; + for (const [canonicalEvent, definitions] of Object.entries(effectiveHooks)) { + const piEvent = eventMap[canonicalEvent]; + if (!piEvent) continue; + + const handlers: Handler[] = []; + for (const def of definitions) { + if ((def.type ?? "command") !== "command") continue; + if (!def.command) continue; + handlers.push({ + command: def.command, + matcher: def.matcher ? def.matcher : undefined, + }); + } + + if (handlers.length > 0) { + const existing = handlerGroups[piEvent]; + if (existing) { + existing.push(...handlers); + } else { + handlerGroups[piEvent] = handlers; + } + } + } + return handlerGroups; +} + +function buildSubscriptionLines(handlerGroups: HandlerGroup): string[] { + const lines: string[] = []; + for (const [piEvent, handlers] of Object.entries(handlerGroups)) { + const usesToolName = PI_TOOL_EVENTS.has(piEvent) && handlers.some((h) => h.matcher); + lines.push(` pi.on(${JSON.stringify(piEvent)}, async (${usesToolName ? "event" : ""}) => {`); + for (const handler of handlers) { + const embeddedCommand = JSON.stringify(handler.command); + if (usesToolName && handler.matcher) { + lines.push( + ` if (new RegExp(${matcherToEmbeddedLiteral(handler.matcher)}).test(event.toolName)) {`, + ); + lines.push(` await run(${embeddedCommand});`); + lines.push(" }"); + } else { + lines.push(` await run(${embeddedCommand});`); + } + } + lines.push(" });"); + } + return lines; +} + +/** + * Generate the rulesync-owned Pi extension (a TypeScript module with a + * default-export factory receiving Pi's ExtensionAPI) that subscribes to the + * mapped lifecycle events and executes the configured hook commands via the + * platform shell. The generated extension observes events only: it never + * blocks or mutates Pi events. + * + * @see https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/extensions.md + */ +export function generatePiExtensionCode( + config: HooksConfig, + supportedEvents: readonly string[], + eventMap: Record, +): string { + const supported: Set = new Set(supportedEvents); + const configHooks = { ...config.hooks, ...config.pi?.hooks }; + const effectiveHooks: HooksConfig["hooks"] = {}; + + for (const [event, defs] of Object.entries(configHooks)) { + if (supported.has(event)) effectiveHooks[event] = defs; + } + + const handlerGroups = collectPiHandlers({ effectiveHooks, eventMap }); + const subscriptionLines = buildSubscriptionLines(handlerGroups); + + const lines: string[] = ["// Generated by rulesync. Do not edit manually."]; + if (subscriptionLines.length === 0) { + lines.push("export default function () {}"); + lines.push(""); + return lines.join("\n"); + } + + lines.push('import { exec } from "node:child_process";'); + lines.push('import { promisify } from "node:util";'); + lines.push(""); + lines.push('import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";'); + lines.push(""); + lines.push("const run = promisify(exec);"); + lines.push(""); + lines.push("export default function (pi: ExtensionAPI) {"); + lines.push(...subscriptionLines); + lines.push("}"); + lines.push(""); + return lines.join("\n"); +} diff --git a/src/features/hooks/pi-hooks.test.ts b/src/features/hooks/pi-hooks.test.ts new file mode 100644 index 000000000..20537a934 --- /dev/null +++ b/src/features/hooks/pi-hooks.test.ts @@ -0,0 +1,355 @@ +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { tsImport } from "tsx/esm/api"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { RULESYNC_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; +import { setupTestDirectory } from "../../test-utils/test-directories.js"; +import { ensureDir, writeFileContent } from "../../utils/file.js"; +import { PiHooks } from "./pi-hooks.js"; +import { RulesyncHooks } from "./rulesync-hooks.js"; + +function buildRulesyncHooks({ + testDir, + config, +}: { + testDir: string; + config: Record; +}): RulesyncHooks { + return new RulesyncHooks({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify(config), + validate: false, + }); +} + +describe("PiHooks", () => { + let testDir: string; + let cleanup: () => Promise; + + beforeEach(async () => { + ({ testDir, cleanup } = await setupTestDirectory()); + vi.spyOn(process, "cwd").mockReturnValue(testDir); + }); + + afterEach(async () => { + await cleanup(); + vi.restoreAllMocks(); + }); + + describe("getSettablePaths", () => { + it("should return .pi/extensions and rulesync-hooks.ts", () => { + const paths = PiHooks.getSettablePaths(); + expect(paths).toEqual({ + relativeDirPath: join(".pi", "extensions"), + relativeFilePath: "rulesync-hooks.ts", + }); + }); + + it("should return .pi/agent/extensions for global mode", () => { + const paths = PiHooks.getSettablePaths({ global: true }); + expect(paths).toEqual({ + relativeDirPath: join(".pi", "agent", "extensions"), + relativeFilePath: "rulesync-hooks.ts", + }); + }); + }); + + describe("fromRulesyncHooks", () => { + it("should filter shared hooks to Pi-supported events and map to snake_case", () => { + const config = { + version: 1, + hooks: { + sessionStart: [{ type: "command", command: ".rulesync/hooks/session-start.sh" }], + sessionEnd: [{ command: "teardown.sh" }], + stop: [{ command: ".rulesync/hooks/audit.sh" }], + beforeSubmitPrompt: [{ command: "pre-prompt.sh" }], + preModelInvocation: [{ command: "pre-model.sh" }], + preCompact: [{ command: "pre-compact.sh" }], + // notification has no Pi extension event equivalent + notification: [{ command: "notify.sh" }], + // afterFileEdit has no Pi extension event equivalent + afterFileEdit: [{ command: "format.sh" }], + }, + }; + const piHooks = PiHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks: buildRulesyncHooks({ testDir, config }), + validate: false, + }); + + const content = piHooks.getFileContent(); + expect(content).toContain('pi.on("session_start", async () => {'); + expect(content).toContain(".rulesync/hooks/session-start.sh"); + expect(content).toContain('pi.on("session_shutdown", async () => {'); + expect(content).toContain("teardown.sh"); + expect(content).toContain('pi.on("agent_end", async () => {'); + expect(content).toContain(".rulesync/hooks/audit.sh"); + expect(content).toContain('pi.on("input", async () => {'); + expect(content).toContain("pre-prompt.sh"); + expect(content).toContain('pi.on("context", async () => {'); + expect(content).toContain("pre-model.sh"); + expect(content).toContain('pi.on("session_before_compact", async () => {'); + expect(content).toContain("pre-compact.sh"); + + // Unsupported events should not appear + expect(content).not.toContain("notify.sh"); + expect(content).not.toContain("format.sh"); + }); + + it("should generate tool event handlers honoring matchers against event.toolName", () => { + const config = { + version: 1, + hooks: { + preToolUse: [{ type: "command", command: "lint.sh", matcher: "Write|Edit" }], + postToolUse: [{ type: "command", command: "post-tool.sh" }], + }, + }; + const piHooks = PiHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks: buildRulesyncHooks({ testDir, config }), + validate: false, + }); + + const content = piHooks.getFileContent(); + expect(content).toContain('pi.on("tool_call", async (event) => {'); + expect(content).toContain('if (new RegExp("Write|Edit").test(event.toolName)) {'); + expect(content).toContain("lint.sh"); + // postToolUse has no matcher, so the handler ignores the event payload + expect(content).toContain('pi.on("tool_result", async () => {'); + expect(content).toContain("post-tool.sh"); + }); + + it("should normalize only bare wildcard matcher to regex match-all pattern", () => { + const config = { + version: 1, + hooks: { + preToolUse: [ + { type: "command", command: "all-tools.sh", matcher: "*" }, + { type: "command", command: "read-tools.sh", matcher: "Read*" }, + ], + }, + }; + const piHooks = PiHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks: buildRulesyncHooks({ testDir, config }), + validate: false, + }); + + const content = piHooks.getFileContent(); + expect(content).toContain('new RegExp(".*")'); + expect(content).toContain('new RegExp("Read*")'); + expect(content).toContain("all-tools.sh"); + expect(content).toContain("read-tools.sh"); + }); + + it("should skip prompt-type hooks", () => { + const config = { + version: 1, + hooks: { + sessionStart: [ + { type: "command", command: ".rulesync/hooks/session-start.sh" }, + { type: "prompt", prompt: "Remember to use TypeScript" }, + ], + }, + }; + const piHooks = PiHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks: buildRulesyncHooks({ testDir, config }), + validate: false, + }); + + const content = piHooks.getFileContent(); + expect(content).toContain(".rulesync/hooks/session-start.sh"); + expect(content).not.toContain("Remember to use TypeScript"); + }); + + it("should merge config.pi.hooks on top of shared hooks", () => { + const config = { + version: 1, + hooks: { + sessionStart: [{ type: "command", command: "shared.sh" }], + }, + pi: { + hooks: { + sessionStart: [{ type: "command", command: "pi-override.sh" }], + stop: [{ command: "pi-only.sh" }], + }, + }, + }; + const piHooks = PiHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks: buildRulesyncHooks({ testDir, config }), + validate: false, + }); + + const content = piHooks.getFileContent(); + expect(content).toContain("pi-override.sh"); + expect(content).not.toContain("shared.sh"); + expect(content).toContain("pi-only.sh"); + }); + + it("should generate an inert extension for an empty hooks config", () => { + const config = { + version: 1, + hooks: {}, + }; + const piHooks = PiHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks: buildRulesyncHooks({ testDir, config }), + validate: false, + }); + + expect(piHooks.getFileContent()).toBe( + [ + "// Generated by rulesync. Do not edit manually.", + "export default function () {}", + "", + ].join("\n"), + ); + }); + + it("should embed commands as JS string literals with quotes and backslashes escaped", () => { + const config = { + version: 1, + hooks: { + sessionStart: [{ type: "command", command: 'echo "C:\\temp" `date` ${HOME}' }], + }, + }; + const piHooks = PiHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks: buildRulesyncHooks({ testDir, config }), + validate: false, + }); + + const content = piHooks.getFileContent(); + expect(content).toContain(`await run(${JSON.stringify('echo "C:\\temp" `date` ${HOME}')});`); + }); + + it("should throw on invalid regex in matcher", () => { + const config = { + version: 1, + hooks: { + preToolUse: [{ type: "command", command: "lint.sh", matcher: "[invalid" }], + }, + }; + expect(() => + PiHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks: buildRulesyncHooks({ testDir, config }), + validate: false, + }), + ).toThrow("Invalid regex pattern in hook matcher"); + }); + + it("should strip control characters from matcher", () => { + const config = { + version: 1, + hooks: { + preToolUse: [{ type: "command", command: "lint.sh", matcher: "Write\n|Edit\r\0" }], + }, + }; + const piHooks = PiHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks: buildRulesyncHooks({ testDir, config }), + validate: false, + }); + + expect(piHooks.getFileContent()).toContain('new RegExp("Write|Edit")'); + }); + + it("should generate a loadable TypeScript module that registers the mapped events", async () => { + const config = { + version: 1, + hooks: { + sessionStart: [{ type: "command", command: 'echo "hi" `date` ${HOME}' }], + preToolUse: [ + { type: "command", command: "lint.sh", matcher: "Write|Edit" }, + { type: "command", command: "audit.sh" }, + ], + stop: [{ command: "done.sh" }], + }, + }; + const piHooks = PiHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks: buildRulesyncHooks({ testDir, config }), + validate: false, + }); + + const extensionsDir = join(testDir, ".pi", "extensions"); + await ensureDir(extensionsDir); + const filePath = join(extensionsDir, "rulesync-hooks.ts"); + await writeFileContent(filePath, piHooks.getFileContent()); + + const mod = await tsImport(pathToFileURL(filePath).href, import.meta.url); + const on = vi.fn(); + mod.default({ on }); + expect(on.mock.calls.map(([event]) => event)).toEqual([ + "session_start", + "tool_call", + "agent_end", + ]); + expect(on).toHaveBeenCalledWith("session_start", expect.any(Function)); + }); + }); + + describe("toRulesyncHooks", () => { + it("should throw because Pi hooks cannot be converted back", () => { + const piHooks = new PiHooks({ + outputRoot: testDir, + relativeDirPath: join(".pi", "extensions"), + relativeFilePath: "rulesync-hooks.ts", + fileContent: "export default function () {}", + validate: false, + }); + + expect(() => piHooks.toRulesyncHooks()).toThrow( + "Not implemented because Pi hooks are generated as a TypeScript extension file.", + ); + }); + }); + + describe("fromFile", () => { + it("should load from .pi/extensions/rulesync-hooks.ts", async () => { + const extensionsDir = join(testDir, ".pi", "extensions"); + await ensureDir(extensionsDir); + const content = "export default function () {}"; + await writeFileContent(join(extensionsDir, "rulesync-hooks.ts"), content); + + const piHooks = await PiHooks.fromFile({ + outputRoot: testDir, + validate: false, + }); + expect(piHooks).toBeInstanceOf(PiHooks); + expect(piHooks.getFileContent()).toBe(content); + }); + }); + + describe("forDeletion", () => { + it("should return PiHooks instance with empty content for deletion", () => { + const hooks = PiHooks.forDeletion({ + outputRoot: testDir, + relativeDirPath: join(".pi", "extensions"), + relativeFilePath: "rulesync-hooks.ts", + }); + expect(hooks).toBeInstanceOf(PiHooks); + expect(hooks.getFileContent()).toBe(""); + }); + }); + + describe("isDeletable", () => { + it("should return true (extension file is standalone and deletable)", () => { + const hooks = new PiHooks({ + outputRoot: testDir, + relativeDirPath: join(".pi", "extensions"), + relativeFilePath: "rulesync-hooks.ts", + fileContent: "", + validate: false, + }); + expect(hooks.isDeletable()).toBe(true); + }); + }); +}); diff --git a/src/features/hooks/pi-hooks.ts b/src/features/hooks/pi-hooks.ts new file mode 100644 index 000000000..f2710105f --- /dev/null +++ b/src/features/hooks/pi-hooks.ts @@ -0,0 +1,108 @@ +import { join } from "node:path"; + +import { + PI_AGENT_EXTENSIONS_DIR_PATH, + PI_EXTENSIONS_DIR_PATH, + PI_HOOKS_FILE_NAME, +} from "../../constants/pi-paths.js"; +import type { AiFileParams, ValidationResult } from "../../types/ai-file.js"; +import { CANONICAL_TO_PI_EVENT_NAMES, PI_HOOK_EVENTS } from "../../types/hooks.js"; +import { readFileContent } from "../../utils/file.js"; +import { generatePiExtensionCode } from "./pi-extension-generator.js"; +import type { RulesyncHooks } from "./rulesync-hooks.js"; +import { + ToolHooks, + type ToolHooksForDeletionParams, + type ToolHooksFromFileParams, + type ToolHooksFromRulesyncHooksParams, + type ToolHooksSettablePaths, +} from "./tool-hooks.js"; + +/** + * Pi Coding Agent has no static hooks config file; its extension API exposes + * lifecycle events instead. rulesync bridges canonical hooks by generating a + * rulesync-owned TypeScript extension in Pi's extension discovery paths: + * `.pi/extensions/rulesync-hooks.ts` (project) and + * `~/.pi/agent/extensions/rulesync-hooks.ts` (global). + * + * @see https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/extensions.md + */ +export class PiHooks extends ToolHooks { + constructor(params: AiFileParams) { + super({ + ...params, + fileContent: params.fileContent ?? "", + }); + } + + static getSettablePaths(options?: { global?: boolean }): ToolHooksSettablePaths { + return { + relativeDirPath: options?.global ? PI_AGENT_EXTENSIONS_DIR_PATH : PI_EXTENSIONS_DIR_PATH, + relativeFilePath: PI_HOOKS_FILE_NAME, + }; + } + + static async fromFile({ + outputRoot = process.cwd(), + validate = true, + global = false, + }: ToolHooksFromFileParams): Promise { + const paths = PiHooks.getSettablePaths({ global }); + const fileContent = await readFileContent( + join(outputRoot, paths.relativeDirPath, paths.relativeFilePath), + ); + return new PiHooks({ + outputRoot, + relativeDirPath: paths.relativeDirPath, + relativeFilePath: paths.relativeFilePath, + fileContent, + validate, + }); + } + + static fromRulesyncHooks({ + outputRoot = process.cwd(), + rulesyncHooks, + validate = true, + global = false, + }: ToolHooksFromRulesyncHooksParams & { global?: boolean }): PiHooks { + const config = rulesyncHooks.getJson(); + const fileContent = generatePiExtensionCode( + config, + PI_HOOK_EVENTS, + CANONICAL_TO_PI_EVENT_NAMES, + ); + const paths = PiHooks.getSettablePaths({ global }); + return new PiHooks({ + outputRoot, + relativeDirPath: paths.relativeDirPath, + relativeFilePath: paths.relativeFilePath, + fileContent, + validate, + }); + } + + toRulesyncHooks(): RulesyncHooks { + throw new Error( + "Not implemented because Pi hooks are generated as a TypeScript extension file.", + ); + } + + validate(): ValidationResult { + return { success: true, error: null }; + } + + static forDeletion({ + outputRoot = process.cwd(), + relativeDirPath, + relativeFilePath, + }: ToolHooksForDeletionParams): PiHooks { + return new PiHooks({ + outputRoot, + relativeDirPath, + relativeFilePath, + fileContent: "", + validate: false, + }); + } +} diff --git a/src/types/hooks.ts b/src/types/hooks.ts index 6515a7650..df3d06971 100644 --- a/src/types/hooks.ts +++ b/src/types/hooks.ts @@ -270,6 +270,27 @@ export const OPENCODE_HOOK_EVENTS: readonly HookEvent[] = [ /** Hook events supported by Kilo. (Currently identical to OpenCode) */ export const KILO_HOOK_EVENTS: readonly HookEvent[] = OPENCODE_HOOK_EVENTS; +/** + * Hook events supported by Pi Coding Agent, bridged through a generated + * TypeScript extension (Pi has no static hook config file; its extension API + * exposes lifecycle events instead). + * + * Only canonical events with a semantically faithful Pi extension event are + * listed; see CANONICAL_TO_PI_EVENT_NAMES for the mapping. + * + * @see https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/extensions.md + */ +export const PI_HOOK_EVENTS: readonly HookEvent[] = [ + "sessionStart", + "sessionEnd", + "preToolUse", + "postToolUse", + "preModelInvocation", + "beforeSubmitPrompt", + "stop", + "preCompact", +]; + /** * Hook events supported by GitHub Copilot (cloud coding agent). * @@ -696,6 +717,7 @@ export const HooksConfigSchema = z.looseObject({ copilotcli: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })), opencode: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })), kilo: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })), + pi: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })), factorydroid: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })), codexcli: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })), goose: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })), @@ -910,6 +932,30 @@ export const CANONICAL_TO_OPENCODE_EVENT_NAMES: Record = { export const CANONICAL_TO_KILO_EVENT_NAMES: Record = CANONICAL_TO_OPENCODE_EVENT_NAMES; +/** + * Map canonical camelCase event names to Pi Coding Agent extension event + * names (snake_case). + * + * Mapping notes: `sessionEnd` → `session_shutdown` (fires on session + * teardown), `beforeSubmitPrompt` → `input` (user input interception), + * `preModelInvocation` → `context` (fires before each LLM call), and + * `stop` → `agent_end` (agent finished responding). Pi events without a + * faithful canonical counterpart (e.g. `turn_start`, `agent_settled`) are + * intentionally unmapped. + * + * @see https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/extensions.md + */ +export const CANONICAL_TO_PI_EVENT_NAMES: Record = { + sessionStart: "session_start", + sessionEnd: "session_shutdown", + preToolUse: "tool_call", + postToolUse: "tool_result", + preModelInvocation: "context", + beforeSubmitPrompt: "input", + stop: "agent_end", + preCompact: "session_before_compact", +}; + /** * Map canonical camelCase event names to Copilot camelCase. */ diff --git a/src/types/tool-target-tuples.ts b/src/types/tool-target-tuples.ts index 6ce570acb..deaf8aead 100644 --- a/src/types/tool-target-tuples.ts +++ b/src/types/tool-target-tuples.ts @@ -205,6 +205,7 @@ export const hooksProcessorToolTargetTuple = [ "copilot", "copilotcli", "opencode", + "pi", "factorydroid", "goose", "hermesagent", From 33b37fa8e8cdb28cc02e5b8e7dbd82a6a273b8ee Mon Sep 17 00:00:00 2001 From: dyoshikawa Date: Sun, 19 Jul 2026 20:54:47 -0700 Subject: [PATCH 2/2] refactor: use object argument for generatePiExtensionCode and clarify stop mapping note Applies review feedback: the repo coding guidelines require object arguments for multi-parameter functions, and the CANONICAL_TO_PI_EVENT_NAMES docstring now notes the agent_end vs agent_settled trade-off for the canonical stop event. Co-Authored-By: Claude Fable 5 --- src/features/hooks/pi-extension-generator.ts | 14 +++++++++----- src/features/hooks/pi-hooks.ts | 8 ++++---- src/types/hooks.ts | 8 +++++--- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/features/hooks/pi-extension-generator.ts b/src/features/hooks/pi-extension-generator.ts index 0bf17d594..9ce438ebd 100644 --- a/src/features/hooks/pi-extension-generator.ts +++ b/src/features/hooks/pi-extension-generator.ts @@ -94,11 +94,15 @@ function buildSubscriptionLines(handlerGroups: HandlerGroup): string[] { * * @see https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/extensions.md */ -export function generatePiExtensionCode( - config: HooksConfig, - supportedEvents: readonly string[], - eventMap: Record, -): string { +export function generatePiExtensionCode({ + config, + supportedEvents, + eventMap, +}: { + config: HooksConfig; + supportedEvents: readonly string[]; + eventMap: Record; +}): string { const supported: Set = new Set(supportedEvents); const configHooks = { ...config.hooks, ...config.pi?.hooks }; const effectiveHooks: HooksConfig["hooks"] = {}; diff --git a/src/features/hooks/pi-hooks.ts b/src/features/hooks/pi-hooks.ts index f2710105f..d96bbb370 100644 --- a/src/features/hooks/pi-hooks.ts +++ b/src/features/hooks/pi-hooks.ts @@ -67,11 +67,11 @@ export class PiHooks extends ToolHooks { global = false, }: ToolHooksFromRulesyncHooksParams & { global?: boolean }): PiHooks { const config = rulesyncHooks.getJson(); - const fileContent = generatePiExtensionCode( + const fileContent = generatePiExtensionCode({ config, - PI_HOOK_EVENTS, - CANONICAL_TO_PI_EVENT_NAMES, - ); + supportedEvents: PI_HOOK_EVENTS, + eventMap: CANONICAL_TO_PI_EVENT_NAMES, + }); const paths = PiHooks.getSettablePaths({ global }); return new PiHooks({ outputRoot, diff --git a/src/types/hooks.ts b/src/types/hooks.ts index df3d06971..a8b5801de 100644 --- a/src/types/hooks.ts +++ b/src/types/hooks.ts @@ -939,9 +939,11 @@ export const CANONICAL_TO_KILO_EVENT_NAMES: Record = * Mapping notes: `sessionEnd` → `session_shutdown` (fires on session * teardown), `beforeSubmitPrompt` → `input` (user input interception), * `preModelInvocation` → `context` (fires before each LLM call), and - * `stop` → `agent_end` (agent finished responding). Pi events without a - * faithful canonical counterpart (e.g. `turn_start`, `agent_settled`) are - * intentionally unmapped. + * `stop` → `agent_end` (agent finished responding; unlike Claude Code's + * Stop, this also fires before Pi auto-retries or auto-compacts — + * `agent_settled` would skip queued follow-ups instead, a pure trade-off). + * Pi events without a faithful canonical counterpart (e.g. `turn_start`, + * `agent_settled`) are intentionally unmapped. * * @see https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/extensions.md */