From a395980dbde7cabb46033356c897910842d6da14 Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Fri, 1 May 2026 14:33:22 -0700 Subject: [PATCH 1/3] [luv-267] feat: add Pi (pi-coding-agent) integration (beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the Codex (#220, #226, #232), Copilot (#236), and Cursor (#245) rollouts for Pi (@mariozechner/pi-coding-agent, binary `pi`). Hook install/uninstall, handler canonicalization, policy-evaluator output adapter, path-protection, activity dashboard filter + per-CLI badge, /projects merge, and session viewer fallback. Empirically verified against pi-coding-agent v0.71.1 (full Phase 0 discovery captured in CLAUDE.md and lib/pi-{projects,sessions}.ts header comments). Pi's model differs from the other four CLIs: - **No JSON hook config in Pi's docs.** Pi loads TypeScript extension packages registered in `.pi/settings.json`. Schema is a flat string array — `{"packages": ["./relative/path", ...]}` — NOT objects with a marker field, so failproofai identifies its entry by source-path substring (`includes("pi-extension") && includes("failproofai")`). - **User-scope settings live at `~/.pi/agent/settings.json`** (NOT `~/.pi/settings.json`, which doesn't exist on a fresh install). - **Pi events use camelCase** (`tool_call` payload: `{toolName, toolCallId, input}`); the shim canonicalizes `toolName` to Claude-shaped `Bash`/`Read`/etc before sending to failproofai so existing builtin policy `toolNames: ["Bash"]` clauses fire unchanged. - **Pi events have NO cwd** — the shim falls back to `process.cwd()` of the extension (which is Pi's launch cwd) so `.failproofai/` config is discoverable. - **No documented `post_tool_call`/`session_end`/`stop` events** — we wire the four observed events (`tool_call`, `user_bash`, `input`, `session_start`) to PreToolUse/UserPromptSubmit/SessionStart via PI_EVENT_MAP. PostToolUse/Stop policies install but never fire for Pi sessions; called out in beta notes. Architecture: - **Direct write to settings.json**, NOT `pi install` subprocess — same pattern as the four other CLIs. Idempotent merge with the user's existing packages array. - **pi-extension package shipped inside the failproofai npm tarball** at `pi-extension/` (added to package.json `files`). Pi loads the `.ts` source directly. The shim spawns failproofai per event, preferring `dist/cli.mjs` (node-compatible production binary) and falling back to `bun bin/failproofai.mjs` for dev. - **Policy-evaluator emits flat `{permission, reason}` JSON** for `cli=pi`, mirroring the Cursor branch but without the user_message/agent_message fan-out (Pi has just one channel). - **Path-protection** extends `isAgentInternalPath` to cover `~/.pi/` and `isAgentSettingsFile` to cover both `.pi/settings.json` and the Pi-managed extension dirs (`.pi/extensions/`, `.pi/agent/extensions/`). Frontend: - `lib/cli-registry.ts` adds Pi with a pink badge. - `lib/projects.ts` merges Pi projects via new `lib/pi-projects.ts`, which parses `~/.pi/agent/sessions//_.jsonl`. Encoded-cwd is intentionally NOT decoded for the canonical cwd — Pi's encoding is lossy; the parser reads the `cwd` field from each JSONL's first `{type: "session", ...}` record. - `lib/pi-sessions.ts` parses Pi's record types (`session`/`message`/`model_change`/`thinking_level_change`) into `LogEntry[]` for the session viewer; rejects path-traversal sessionIds. - Session viewer page extends the external-CLI fallback chain; activity feed recognizes `/.pi/` transcript paths. Dogfooding (Phase 0.6.4 confirmed Case 1 — relative paths portable): ships `.pi/settings.json` containing `"../pi-extension"` so contributors working on this repo with Pi installed get hooks active automatically, mirroring the existing `.claude/settings.json`, `.codex/hooks.json`, `.github/hooks/failproofai.json`, and `.cursor/hooks.json` configs. Live verification: `pi -p "use bash to run sudo apt list"` from project root → shim fires on `tool_call` → spawns `failproofai --hook tool_call --cli pi` → block-sudo deny → flat `{permission, reason}` JSON → Pi cancels the bash tool and surfaces the reason to the LLM. Tests: 1305 unit pass (62 files, +35 new Pi tests across 7 files); lint clean; bun run build clean. New e2e suite at __tests__/e2e/hooks/pi-integration.e2e.test.ts covers PreToolUse deny + allow + instruct + agent-settings guard + activity tagging + install/uninstall flows; live `pi list` roundtrip tests are gated behind `describe.skipIf(!piIsAvailable())` so CI without Pi installed still passes. Bumps version to 0.0.10-beta.2. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 4 + .pi/settings.json | 5 + CHANGELOG.md | 2 + CLAUDE.md | 45 +++ README.md | 11 +- __tests__/components/project-list.test.tsx | 35 ++ __tests__/e2e/helpers/hook-runner.ts | 31 +- __tests__/e2e/helpers/payloads.ts | 76 ++++ .../e2e/hooks/pi-integration.e2e.test.ts | 370 ++++++++++++++++++ __tests__/hooks/handler.test.ts | 150 +++++++ __tests__/hooks/integrations.test.ts | 181 ++++++++- __tests__/lib/cli-registry.test.ts | 15 +- __tests__/lib/pi-projects.test.ts | 223 +++++++++++ __tests__/lib/pi-sessions.test.ts | 208 ++++++++++ __tests__/lib/projects.test.ts | 99 +++++ app/policies/hooks-client.tsx | 6 +- app/project/[name]/page.tsx | 15 +- .../[name]/session/[sessionId]/page.tsx | 23 +- assets/logos/pi-dark.svg | 7 + assets/logos/pi-light.svg | 7 + bin/failproofai.mjs | 42 +- docs/configuration.mdx | 8 +- docs/dashboard.mdx | 12 +- docs/getting-started.mdx | 5 +- lib/cli-registry.ts | 7 +- lib/pi-projects.ts | 222 +++++++++++ lib/pi-sessions.ts | 322 +++++++++++++++ lib/projects.ts | 18 +- package.json | 3 +- pi-extension/index.ts | 203 ++++++++++ pi-extension/package.json | 12 + src/hooks/builtin-policies.ts | 9 +- src/hooks/handler.ts | 10 +- src/hooks/install-prompt.ts | 4 +- src/hooks/integrations.ts | 186 +++++++++ src/hooks/policy-evaluator.ts | 60 +++ src/hooks/resolve-permission-mode.ts | 6 +- src/hooks/types.ts | 45 ++- 38 files changed, 2626 insertions(+), 61 deletions(-) create mode 100644 .pi/settings.json create mode 100644 __tests__/e2e/hooks/pi-integration.e2e.test.ts create mode 100644 __tests__/lib/pi-projects.test.ts create mode 100644 __tests__/lib/pi-sessions.test.ts create mode 100644 assets/logos/pi-dark.svg create mode 100644 assets/logos/pi-light.svg create mode 100644 lib/pi-projects.ts create mode 100644 lib/pi-sessions.ts create mode 100644 pi-extension/index.ts create mode 100644 pi-extension/package.json diff --git a/.gitignore b/.gitignore index 83e0b1bb..b988f3b2 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,10 @@ next-env.d.ts .cursor/* !.cursor/hooks.json +# pi +.pi/* +!.pi/settings.json + # custom hooks loader temp files *.__failproofai_tmp__.* diff --git a/.pi/settings.json b/.pi/settings.json new file mode 100644 index 00000000..db8faf57 --- /dev/null +++ b/.pi/settings.json @@ -0,0 +1,5 @@ +{ + "packages": [ + "../pi-extension" + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 700d370b..734f4a2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased ### Features +- Add Pi (`@mariozechner/pi-coding-agent`) integration (beta) across hooks, activity dashboard, session viewer, and `/projects` listing. New `--cli pi` flag writes a `packages` entry into `~/.pi/agent/settings.json` (user) or `/.pi/settings.json` (project) pointing at failproofai's bundled `pi-extension/` package. The extension subscribes to Pi's `tool_call` / `user_bash` / `input` / `session_start` events and bridges to `failproofai --hook --cli pi`; the handler canonicalizes Pi's underscore_lower_snake_case event names to PascalCase via `PI_EVENT_MAP`. The policy evaluator emits Pi's flat `{permission, reason}` stdout shape, which the extension translates to `{block: true, reason}` to veto tool calls. Path-protection (`isAgentInternalPath` + `isAgentSettingsFile`) covers `~/.pi/`, `.pi/settings.json`, and the Pi-managed extension dirs. Frontend: `lib/cli-registry.ts` adds a `Pi` entry with a pink badge; `lib/projects.ts` merges Pi projects into `/projects`; `app/project/[name]` and `/session/[id]` extend the external-CLI fallback chain. Verified empirically against pi-coding-agent v0.71.1. - Add GitHub Copilot CLI integration (beta) across hooks, activity dashboard, session fallback, and `/projects` listing. Also ships this repo's own `.github/hooks/failproofai.json` so contributors developing failproofai with the GitHub Copilot CLI get hooks active automatically, mirroring the existing `.claude/settings.json` and `.codex/hooks.json` (#236) - Add Cursor Agent CLI integration (beta) across hooks, activity dashboard, session viewer, and `/projects` listing. New `--cli cursor` flag installs into `~/.cursor/hooks.json` (user) or `/.cursor/hooks.json` (project) using Cursor's flat-array schema with camelCase event keys (`preToolUse`, `beforeSubmitPrompt`, …); the handler canonicalizes to PascalCase via `CURSOR_EVENT_MAP` so existing builtin policies fire unchanged. The policy evaluator emits Cursor's `{permission, user_message, agent_message, additional_context, followup_message}` stdout shape. Path-protection (`isAgentInternalPath` + `isAgentSettingsFile`) covers `~/.cursor/` and `.cursor/hooks.json`. Frontend: `lib/cli-registry.ts` adds a `Cursor Agent` entry with an emerald badge; `lib/projects.ts` merges Cursor projects into `/projects`; `app/project/[name]` and `/session/[id]` extend the external-CLI fallback chain. Also ships this repo's own `.cursor/hooks.json` so contributors using Cursor get hooks active automatically (#245). - Project page (`/project/[name]`): list Copilot and Cursor sessions alongside Claude + Codex, mirroring the existing merge logic on the projects index. Previously the project detail view only enumerated Claude + Codex transcripts (#245). @@ -14,6 +15,7 @@ - Auto-translated MDX: stop the recurring `mintlify validate` parse error in `docs/de/dashboard.mdx` (``) by adding a `sanitizeJsxAttributes` post-processor to the translation pipeline that strips stray ASCII `"` left after typographic-quote pairs (and any unmatched opening typographic quote) in JSX attribute values, and by tightening the translator system prompt to forbid ASCII `"` inside attribute values. Same regression PR #229 fixed by hand — now it can't recur. Includes the immediate file fix on `docs/de/dashboard.mdx`. (#247) ### Docs +- README: add Pi to the supported-CLIs intro line and visual list, with light/dark logo variants (`assets/logos/pi-light.svg` + `pi-dark.svg`); update beta callout to include Pi alongside Copilot and Cursor. - README: add Cursor Agent to the supported-CLIs intro line and visual list, with light/dark logo variants (`assets/logos/cursor-light.svg` + `cursor-dark.svg`). Note that GitHub Copilot CLI testing is ongoing in the beta callout (#245). ## 0.0.9 — 2026-04-28 diff --git a/CLAUDE.md b/CLAUDE.md index db066159..3ca01125 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,6 +97,51 @@ which writes a portable `npx -y failproofai --hook ... --cli cursor` command. Same self-reference caveat applies — do **not** install the standard `npx` form from inside this repo. +### Pi hooks (`.pi/settings.json`) + +This repo also ships a `.pi/settings.json` for Pi (`@mariozechner/pi-coding-agent`) +sessions. Pi's model differs from the other four CLIs in two important ways: + +**Direct settings-file write, not subcommand-based.** Pi exposes +`pi install [-l]` and `pi remove [-l]` for managing +extensions, but failproofai writes `.pi/settings.json` directly — same pattern +as `.cursor/hooks.json` and `.codex/hooks.json`. This keeps install/uninstall +fast (no subprocess), works without `pi` on PATH, and stays consistent with +the other four integrations. + +**Settings file paths** (verified empirically against pi-coding-agent +v0.71.1): + +| Scope | Path | +|---------|-------------------------------------| +| user | `~/.pi/agent/settings.json` | +| project | `/.pi/settings.json` | + +Note: `~/.pi/settings.json` does NOT exist on a fresh install; user-scope +settings live one level deeper under `~/.pi/agent/`. + +**Schema** is a flat string array — `{"packages": ["./relative/path", ...]}`. +Each entry is a path Pi resolves relative to the directory containing +`settings.json` (so `/.pi/` for project scope). For dogfood we write +`"../pi-extension"` so each contributor's clone resolves to their own +on-disk `/pi-extension/`. + +**The pi-extension package** ships inside the failproofai npm tarball at +`pi-extension/` (sibling of `bin/`, `dist/`, etc.). Its `index.ts` is loaded +by Pi at startup; the shim spawns `failproofai --hook --cli pi` per +Pi event and translates Pi's `{toolName, input, ...}` event payload to the +Claude-shape stdin JSON the handler expects. Pi spawns extensions with an +undefined cwd contract, so the shim resolves the failproofai binary +relatively from `import.meta.url`, NOT from `process.cwd()`. + +For production users (outside this repo), the recommended Pi install is: +```bash +failproofai policies --install --cli pi --scope project +``` +which writes a `.pi/settings.json` referencing failproofai's bundled +pi-extension. Same self-reference caveat applies — do **not** install the +standard `npx` form from inside this repo. + ## Workflow rules ### One PR per branch diff --git a/README.md b/README.md index 851c6186..ecb5a311 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ **Translations**: [简体中文](docs/i18n/README.zh.md) | [日本語](docs/i18n/README.ja.md) | [한국어](docs/i18n/README.ko.md) | [Español](docs/i18n/README.es.md) | [Português](docs/i18n/README.pt-br.md) | [Deutsch](docs/i18n/README.de.md) | [Français](docs/i18n/README.fr.md) | [Русский](docs/i18n/README.ru.md) | [हिन्दी](docs/i18n/README.hi.md) | [Türkçe](docs/i18n/README.tr.md) | [Tiếng Việt](docs/i18n/README.vi.md) | [Italiano](docs/i18n/README.it.md) | [العربية](docs/i18n/README.ar.md) | [עברית](docs/i18n/README.he.md) -The easiest way to manage policies that keep your AI agents reliable, on-task, and running autonomously - for **Claude Code**, **OpenAI Codex**, **GitHub Copilot CLI** _(beta)_, **Cursor Agent** _(beta)_ & the **Agents SDK**. +The easiest way to manage policies that keep your AI agents reliable, on-task, and running autonomously - for **Claude Code**, **OpenAI Codex**, **GitHub Copilot CLI** _(beta)_, **Cursor Agent** _(beta)_, **Pi** _(beta)_ & the **Agents SDK**.

Failproof AI in action @@ -51,10 +51,17 @@ The easiest way to manage policies that keep your AI agents reliable, on-task, a        + + + + Pi + + +        + more coming soon

-> Install hooks for one or any combination: `failproofai policies --install --cli cursor` (or `--cli claude codex copilot cursor`). Omit `--cli` to auto-detect installed CLIs and prompt. **GitHub Copilot CLI and Cursor Agent support are in beta — testing is ongoing.** +> Install hooks for one or any combination: `failproofai policies --install --cli pi` (or `--cli claude codex copilot cursor pi`). Omit `--cli` to auto-detect installed CLIs and prompt. **GitHub Copilot CLI, Cursor Agent, and Pi support are in beta — testing is ongoing.** - **39 Built-in Policies** - Catch common agent failure modes out of the box. Block destructive commands, prevent secret leakage, keep agents inside project boundaries, detect loops, and more. - **Custom Policies** - Write your own reliability rules in JavaScript. Use the `allow`/`deny`/`instruct` API to enforce conventions, prevent drift, gate operations, or integrate with external systems. diff --git a/__tests__/components/project-list.test.tsx b/__tests__/components/project-list.test.tsx index 2ab64c9c..0331f321 100644 --- a/__tests__/components/project-list.test.tsx +++ b/__tests__/components/project-list.test.tsx @@ -161,6 +161,40 @@ describe("ProjectList", () => { expect(badgeNodes("Cursor Agent")).toHaveLength(1); }); + it("renders a Pi badge for cli=['pi']", () => { + const folders: ProjectFolder[] = [ + { + name: "-home-u-pi", + path: "/home/u/pi", + isDirectory: true, + lastModified: new Date(), + lastModifiedFormatted: "Jun 15, 2024", + cli: ["pi"], + }, + ]; + render(); + expect(badgeNodes("Pi")).toHaveLength(1); + }); + + it("renders all five badges when cli=['claude','codex','copilot','cursor','pi']", () => { + const folders: ProjectFolder[] = [ + { + name: "-home-u-five", + path: "/home/u/five", + isDirectory: true, + lastModified: new Date(), + lastModifiedFormatted: "Jun 15, 2024", + cli: ["claude", "codex", "copilot", "cursor", "pi"], + }, + ]; + render(); + expect(badgeNodes("Claude Code")).toHaveLength(1); + expect(badgeNodes("OpenAI Codex")).toHaveLength(1); + expect(badgeNodes("GitHub Copilot")).toHaveLength(1); + expect(badgeNodes("Cursor Agent")).toHaveLength(1); + expect(badgeNodes("Pi")).toHaveLength(1); + }); + it("links to /project/[name]", () => { const folders = makeFolders(1); render(); @@ -200,6 +234,7 @@ describe("ProjectList", () => { "OpenAI Codex", "GitHub Copilot", "Cursor Agent", + "Pi", ]); }); diff --git a/__tests__/e2e/helpers/hook-runner.ts b/__tests__/e2e/helpers/hook-runner.ts index ee75fd73..dfd04a42 100644 --- a/__tests__/e2e/helpers/hook-runner.ts +++ b/__tests__/e2e/helpers/hook-runner.ts @@ -40,7 +40,7 @@ export interface HookRunResult { export function runHook( event: string, payload: Record, - opts?: { homeDir?: string; cli?: "claude" | "codex" | "copilot" | "cursor" }, + opts?: { homeDir?: string; cli?: "claude" | "codex" | "copilot" | "cursor" | "pi" }, ): HookRunResult { const binaryPath = getBinaryPath(); @@ -139,3 +139,32 @@ export function assertCursorStopInstruct(result: HookRunResult): void { expect(result.exitCode).toBe(0); expect(result.parsed?.followup_message).toMatch(/^Instruction from failproofai:/); } + +/** + * Pi emits a flat `{permission, reason}` JSON shape — the pi-extension shim + * parses this and translates `permission === "deny"` into a `{block, reason}` + * return from its `pi.on("tool_call", ...)` handler. + */ +export function assertPiDeny(result: HookRunResult): void { + expect(result.exitCode).toBe(0); + expect(result.parsed?.permission).toBe("deny"); + expect(typeof result.parsed?.reason).toBe("string"); + expect(result.parsed?.reason).toMatch(/Blocked/i); + // Pi uses the flat shape — no Claude-style hookSpecificOutput wrapper. + expect(result.parsed?.hookSpecificOutput).toBeUndefined(); +} + +export function assertPiInstruct(result: HookRunResult): void { + expect(result.exitCode).toBe(0); + expect(result.parsed?.permission).toBe("allow"); + expect(result.parsed?.reason).toMatch(/^Instruction from failproofai:/); +} + +export function assertPiAllow(result: HookRunResult): void { + expect(result.exitCode).toBe(0); + // Allow can be either an empty stdout or a flat `{permission: "allow"}` + // (when there are info-only allow entries). The shim treats both as no-block. + if (result.parsed) { + expect(result.parsed.permission).not.toBe("deny"); + } +} diff --git a/__tests__/e2e/helpers/payloads.ts b/__tests__/e2e/helpers/payloads.ts index b1e8be1e..0c3ff295 100644 --- a/__tests__/e2e/helpers/payloads.ts +++ b/__tests__/e2e/helpers/payloads.ts @@ -323,3 +323,79 @@ export const CopilotPayloads = { }; }, }; + +/** + * Pi (pi-coding-agent) payload factories. The on-disk shape we forward to + * `failproofai --hook ... --cli pi` is the same as Claude's stdin shape + * (snake_case `tool_name`, `tool_input`, …) — the pi-extension shim does + * the camelCase-to-snake_case translation before spawning failproofai. + * + * These payload factories reproduce what the shim writes, NOT what Pi + * itself emits, because the e2e tests run against the bare failproofai + * binary and don't go through the shim. The hook_event_name is the Pi-side + * underscore_lower_snake_case form (`tool_call`, `user_bash`, `input`, + * `session_start`); the handler canonicalizes to PascalCase via PI_EVENT_MAP. + */ +const PI_SESSION_ID = "test-session-pi-001"; + +export const PiPayloads = { + toolCall: { + bash(command: string, cwd: string): Record { + return { + session_id: PI_SESSION_ID, + transcript_path: TRANSCRIPT_PATH, + cwd, + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_input: { command }, + }; + }, + write(filePath: string, content: string, cwd: string): Record { + return { + session_id: PI_SESSION_ID, + transcript_path: TRANSCRIPT_PATH, + cwd, + hook_event_name: "PreToolUse", + tool_name: "Write", + tool_input: { file_path: filePath, content }, + }; + }, + read(filePath: string, cwd: string): Record { + return { + session_id: PI_SESSION_ID, + transcript_path: TRANSCRIPT_PATH, + cwd, + hook_event_name: "PreToolUse", + tool_name: "Read", + tool_input: { file_path: filePath }, + }; + }, + }, + userBash(command: string, cwd: string): Record { + return { + session_id: PI_SESSION_ID, + transcript_path: TRANSCRIPT_PATH, + cwd, + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_input: { command }, + }; + }, + input(prompt: string, cwd: string): Record { + return { + session_id: PI_SESSION_ID, + transcript_path: TRANSCRIPT_PATH, + cwd, + hook_event_name: "UserPromptSubmit", + prompt, + }; + }, + sessionStart(cwd: string): Record { + return { + session_id: PI_SESSION_ID, + transcript_path: TRANSCRIPT_PATH, + cwd, + hook_event_name: "SessionStart", + }; + }, +}; diff --git a/__tests__/e2e/hooks/pi-integration.e2e.test.ts b/__tests__/e2e/hooks/pi-integration.e2e.test.ts new file mode 100644 index 00000000..283808b3 --- /dev/null +++ b/__tests__/e2e/hooks/pi-integration.e2e.test.ts @@ -0,0 +1,370 @@ +/** + * E2E: Pi (pi-coding-agent) hook integration. + * + * Exercises the full install → fire → decide flow using the real failproofai + * binary as a subprocess (no mocks). Each test runs against an isolated + * fixture HOME so we don't pollute the user's ~/.pi/. + * + * Three groups: + * 1. Hook protocol (handler-only): synthesize Pi-shaped event payloads and + * run them through `bin/failproofai.mjs --hook ... --cli pi`. No Pi + * installation required. + * 2. Install/uninstall: write/read .pi/settings.json directly through the + * `policies --install --cli pi` and `--uninstall` flow. + * 3. Live `pi list` round-trip: confirm Pi recognizes the settings.json we + * wrote. Skipped when `pi` is not on PATH (CI runners without pi). + */ +import { describe, it, expect } from "vitest"; +import { execSync, spawnSync } from "node:child_process"; +import { mkdtempSync, writeFileSync, readFileSync, existsSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + runHook, + assertAllow, + assertPiDeny, + assertPiAllow, +} from "../helpers/hook-runner"; +import { PiPayloads } from "../helpers/payloads"; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); +const BINARY_PATH = resolve(REPO_ROOT, "bin/failproofai.mjs"); + +function createPiEnv(): { home: string; cwd: string; cleanup: () => void } { + const home = mkdtempSync(join(tmpdir(), "fp-e2e-pi-home-")); + const cwd = mkdtempSync(join(tmpdir(), "fp-e2e-pi-cwd-")); + // Pre-create the .failproofai dir under cwd so the parent-walk finds it. + mkdirSync(resolve(cwd, ".failproofai"), { recursive: true }); + return { + home, + cwd, + cleanup() { + rmSync(home, { recursive: true, force: true }); + rmSync(cwd, { recursive: true, force: true }); + }, + }; +} + +function writeConfig(cwd: string, enabledPolicies: string[]): void { + const configPath = resolve(cwd, ".failproofai", "policies-config.json"); + mkdirSync(dirname(configPath), { recursive: true }); + writeFileSync(configPath, JSON.stringify({ enabledPolicies }, null, 2)); +} + +function piIsAvailable(): boolean { + try { + const probe = spawnSync("pi", ["--version"], { encoding: "utf8", timeout: 5_000 }); + return probe.status === 0; + } catch { + return false; + } +} + +describe("E2E: Pi integration — hook protocol (handler-only)", () => { + it("tool_call → PreToolUse: block-sudo emits {permission:'deny', reason}", () => { + const env = createPiEnv(); + try { + writeConfig(env.cwd, ["block-sudo"]); + const result = runHook( + "tool_call", + PiPayloads.toolCall.bash("sudo apt install foo", env.cwd), + { homeDir: env.home, cli: "pi" }, + ); + assertPiDeny(result); + expect(result.parsed?.hookSpecificOutput).toBeUndefined(); + } finally { + env.cleanup(); + } + }); + + it("tool_call: allow path returns no permission='deny' field", () => { + const env = createPiEnv(); + try { + writeConfig(env.cwd, []); + const result = runHook( + "tool_call", + PiPayloads.toolCall.bash("ls -la", env.cwd), + { homeDir: env.home, cli: "pi" }, + ); + assertPiAllow(result); + } finally { + env.cleanup(); + } + }); + + it("user_bash: deny path fires (synthetic PreToolUse with toolName=Bash)", () => { + const env = createPiEnv(); + try { + writeConfig(env.cwd, ["block-rm-rf"]); + const result = runHook( + "user_bash", + PiPayloads.userBash("rm -rf /tmp/whatever", env.cwd), + { homeDir: env.home, cli: "pi" }, + ); + assertPiDeny(result); + } finally { + env.cleanup(); + } + }); + + it("input → UserPromptSubmit: allow path when no policy matches", () => { + const env = createPiEnv(); + try { + writeConfig(env.cwd, []); + const result = runHook( + "input", + PiPayloads.input("Just a normal user prompt", env.cwd), + { homeDir: env.home, cli: "pi" }, + ); + assertAllow(result); + } finally { + env.cleanup(); + } + }); + + it("session_start → SessionStart: exit 0", () => { + const env = createPiEnv(); + try { + writeConfig(env.cwd, []); + const result = runHook( + "session_start", + PiPayloads.sessionStart(env.cwd), + { homeDir: env.home, cli: "pi" }, + ); + expect(result.exitCode).toBe(0); + } finally { + env.cleanup(); + } + }); + + it("agent-settings guard: Bash read of .pi/settings.json is denied", () => { + const env = createPiEnv(); + try { + writeConfig(env.cwd, ["block-read-outside-cwd"]); + const settingsPath = resolve(env.cwd, ".pi", "settings.json"); + const result = runHook( + "tool_call", + PiPayloads.toolCall.bash(`cat ${settingsPath}`, env.cwd), + { homeDir: env.home, cli: "pi" }, + ); + assertPiDeny(result); + } finally { + env.cleanup(); + } + }); + + it("agent-settings guard: Read of ~/.pi/agent/settings.json is denied", () => { + const env = createPiEnv(); + try { + writeConfig(env.cwd, ["block-read-outside-cwd"]); + const settingsPath = resolve(env.home, ".pi", "agent", "settings.json"); + const result = runHook( + "tool_call", + PiPayloads.toolCall.read(settingsPath, env.cwd), + { homeDir: env.home, cli: "pi" }, + ); + assertPiDeny(result); + } finally { + env.cleanup(); + } + }); + + it("activity entry tags decision with integration: pi", () => { + const env = createPiEnv(); + try { + writeConfig(env.cwd, ["block-sudo"]); + runHook( + "tool_call", + PiPayloads.toolCall.bash("sudo cat /etc/passwd", env.cwd), + { homeDir: env.home, cli: "pi" }, + ); + const activityPath = resolve(env.home, ".failproofai", "cache", "hook-activity", "current.jsonl"); + expect(existsSync(activityPath)).toBe(true); + const lines = readFileSync(activityPath, "utf-8").trim().split("\n").filter(Boolean); + const last = JSON.parse(lines[lines.length - 1]) as Record; + expect(last.integration).toBe("pi"); + expect(last.decision).toBe("deny"); + // Canonical event name lands in the activity entry, not the snake_case wire form. + expect(last.eventType).toBe("PreToolUse"); + } finally { + env.cleanup(); + } + }); + + it("permission-mode resolves to 'default' for cli=pi", () => { + const env = createPiEnv(); + try { + writeConfig(env.cwd, []); + runHook( + "session_start", + PiPayloads.sessionStart(env.cwd), + { homeDir: env.home, cli: "pi" }, + ); + const activityPath = resolve(env.home, ".failproofai", "cache", "hook-activity", "current.jsonl"); + expect(existsSync(activityPath)).toBe(true); + const lines = readFileSync(activityPath, "utf-8").trim().split("\n").filter(Boolean); + const last = JSON.parse(lines[lines.length - 1]) as Record; + expect(last.permissionMode).toBe("default"); + } finally { + env.cleanup(); + } + }); +}); + +describe("E2E: Pi integration — install/uninstall", () => { + it("policies --install --cli pi --scope project writes .pi/settings.json with packages array", () => { + const env = createPiEnv(); + try { + execSync( + `bun ${BINARY_PATH} policies --install block-sudo --cli pi --scope project`, + { cwd: env.cwd, env: { ...process.env, HOME: env.home, FAILPROOFAI_TELEMETRY_DISABLED: "1", FAILPROOFAI_BINARY_OVERRIDE: BINARY_PATH } }, + ); + const settingsPath = resolve(env.cwd, ".pi", "settings.json"); + expect(existsSync(settingsPath)).toBe(true); + const settings = JSON.parse(readFileSync(settingsPath, "utf-8")) as Record; + const packages = settings.packages as string[]; + expect(Array.isArray(packages)).toBe(true); + expect(packages.length).toBe(1); + // The entry references failproofai's pi-extension package directory. + expect(packages[0]).toContain("pi-extension"); + expect(packages[0]).toContain("failproofai"); + } finally { + env.cleanup(); + } + }); + + it("policies --install --cli pi --scope user writes ~/.pi/agent/settings.json", () => { + const env = createPiEnv(); + try { + execSync( + `bun ${BINARY_PATH} policies --install block-sudo --cli pi --scope user`, + { cwd: env.cwd, env: { ...process.env, HOME: env.home, FAILPROOFAI_TELEMETRY_DISABLED: "1", FAILPROOFAI_BINARY_OVERRIDE: BINARY_PATH } }, + ); + const settingsPath = resolve(env.home, ".pi", "agent", "settings.json"); + expect(existsSync(settingsPath)).toBe(true); + const settings = JSON.parse(readFileSync(settingsPath, "utf-8")) as Record; + const packages = settings.packages as string[]; + expect(packages.length).toBe(1); + expect(packages[0]).toContain("pi-extension"); + } finally { + env.cleanup(); + } + }); + + it("policies --uninstall --cli pi removes the failproofai entry from .pi/settings.json", () => { + const env = createPiEnv(); + try { + const baseEnv = { ...process.env, HOME: env.home, FAILPROOFAI_TELEMETRY_DISABLED: "1", FAILPROOFAI_BINARY_OVERRIDE: BINARY_PATH }; + execSync( + `bun ${BINARY_PATH} policies --install block-sudo --cli pi --scope project`, + { cwd: env.cwd, env: baseEnv }, + ); + const settingsPath = resolve(env.cwd, ".pi", "settings.json"); + expect(existsSync(settingsPath)).toBe(true); + + execSync( + `bun ${BINARY_PATH} policies --uninstall --cli pi --scope project`, + { cwd: env.cwd, env: baseEnv }, + ); + const settings = JSON.parse(readFileSync(settingsPath, "utf-8")) as Record; + // Empty packages array drops to undefined; user entries are preserved if present. + expect(settings.packages).toBeUndefined(); + } finally { + env.cleanup(); + } + }); + + it("re-install is idempotent: running install twice leaves exactly one failproofai entry", () => { + const env = createPiEnv(); + try { + const baseEnv = { ...process.env, HOME: env.home, FAILPROOFAI_TELEMETRY_DISABLED: "1", FAILPROOFAI_BINARY_OVERRIDE: BINARY_PATH }; + execSync( + `bun ${BINARY_PATH} policies --install block-sudo --cli pi --scope project`, + { cwd: env.cwd, env: baseEnv }, + ); + execSync( + `bun ${BINARY_PATH} policies --install block-sudo --cli pi --scope project`, + { cwd: env.cwd, env: baseEnv }, + ); + const settings = JSON.parse( + readFileSync(resolve(env.cwd, ".pi", "settings.json"), "utf-8"), + ) as { packages: string[] }; + const failproofaiEntries = settings.packages.filter((p) => p.includes("pi-extension")); + expect(failproofaiEntries.length).toBe(1); + } finally { + env.cleanup(); + } + }); + + it("install preserves existing user packages alongside the failproofai entry", () => { + const env = createPiEnv(); + try { + // Pre-populate .pi/settings.json with a user-owned package + const settingsPath = resolve(env.cwd, ".pi", "settings.json"); + mkdirSync(dirname(settingsPath), { recursive: true }); + writeFileSync( + settingsPath, + JSON.stringify({ packages: ["npm:@user/other-extension"] }, null, 2), + ); + + execSync( + `bun ${BINARY_PATH} policies --install block-sudo --cli pi --scope project`, + { cwd: env.cwd, env: { ...process.env, HOME: env.home, FAILPROOFAI_TELEMETRY_DISABLED: "1", FAILPROOFAI_BINARY_OVERRIDE: BINARY_PATH } }, + ); + + const settings = JSON.parse(readFileSync(settingsPath, "utf-8")) as { packages: string[] }; + expect(settings.packages.length).toBe(2); + expect(settings.packages).toContain("npm:@user/other-extension"); + expect(settings.packages.some((p) => p.includes("pi-extension"))).toBe(true); + } finally { + env.cleanup(); + } + }); +}); + +// ────────────────────────────────────────────────────────────────────────── +// Live Pi roundtrip — gated behind `which pi` so CI without Pi installed +// passes. When run on a developer machine with Pi installed, these confirm +// Pi actually parses the settings.json failproofai writes. +// ────────────────────────────────────────────────────────────────────────── + +const piPresent = piIsAvailable(); +const describePi = piPresent ? describe : describe.skip; + +describePi("E2E: Pi integration — live `pi list` roundtrip (real binary)", () => { + it("after policies --install, `pi list` shows the failproofai entry", () => { + const env = createPiEnv(); + try { + execSync( + `bun ${BINARY_PATH} policies --install block-sudo --cli pi --scope project`, + { cwd: env.cwd, env: { ...process.env, HOME: env.home, FAILPROOFAI_TELEMETRY_DISABLED: "1", FAILPROOFAI_BINARY_OVERRIDE: BINARY_PATH } }, + ); + const result = spawnSync("pi", ["list"], { cwd: env.cwd, encoding: "utf8" }); + expect(result.status).toBe(0); + expect(result.stdout).toContain("pi-extension"); + } finally { + env.cleanup(); + } + }); + + it("after policies --uninstall, `pi list` no longer shows the failproofai entry", () => { + const env = createPiEnv(); + try { + const baseEnv = { ...process.env, HOME: env.home, FAILPROOFAI_TELEMETRY_DISABLED: "1", FAILPROOFAI_BINARY_OVERRIDE: BINARY_PATH }; + execSync( + `bun ${BINARY_PATH} policies --install block-sudo --cli pi --scope project`, + { cwd: env.cwd, env: baseEnv }, + ); + execSync( + `bun ${BINARY_PATH} policies --uninstall --cli pi --scope project`, + { cwd: env.cwd, env: baseEnv }, + ); + const result = spawnSync("pi", ["list"], { cwd: env.cwd, encoding: "utf8" }); + expect(result.status).toBe(0); + expect(result.stdout).not.toContain("pi-extension"); + } finally { + env.cleanup(); + } + }); +}); diff --git a/__tests__/hooks/handler.test.ts b/__tests__/hooks/handler.test.ts index eaf036d5..ec4e086e 100644 --- a/__tests__/hooks/handler.test.ts +++ b/__tests__/hooks/handler.test.ts @@ -297,6 +297,156 @@ describe("hooks/handler", () => { ); }); + it("canonicalizes Pi tool_call → PreToolUse before evaluating", async () => { + const { evaluatePolicies } = await import("../../src/hooks/policy-evaluator"); + vi.mocked(evaluatePolicies).mockResolvedValueOnce({ + exitCode: 0, + stdout: "", + stderr: "", + policyName: null, + reason: null, + decision: "allow", + }); + mockStdin(JSON.stringify({ tool_name: "bash", hook_event_name: "PreToolUse" })); + const { persistHookActivity } = await import("../../src/hooks/hook-activity-store"); + + await handleHookEvent("tool_call", "pi"); + + expect(evaluatePolicies).toHaveBeenCalledWith( + "PreToolUse", + expect.any(Object), + expect.any(Object), + expect.any(Object), + ); + expect(persistHookActivity).toHaveBeenCalledWith( + expect.objectContaining({ integration: "pi", eventType: "PreToolUse" }), + ); + }); + + it("canonicalizes Pi user_bash → PreToolUse (synthetic Bash)", async () => { + const { evaluatePolicies } = await import("../../src/hooks/policy-evaluator"); + vi.mocked(evaluatePolicies).mockResolvedValueOnce({ + exitCode: 0, + stdout: "", + stderr: "", + policyName: null, + reason: null, + decision: "allow", + }); + mockStdin(JSON.stringify({ tool_name: "Bash" })); + const { persistHookActivity } = await import("../../src/hooks/hook-activity-store"); + + await handleHookEvent("user_bash", "pi"); + + expect(evaluatePolicies).toHaveBeenCalledWith( + "PreToolUse", + expect.any(Object), + expect.any(Object), + expect.any(Object), + ); + expect(persistHookActivity).toHaveBeenCalledWith( + expect.objectContaining({ integration: "pi", eventType: "PreToolUse" }), + ); + }); + + it("canonicalizes Pi input → UserPromptSubmit", async () => { + const { evaluatePolicies } = await import("../../src/hooks/policy-evaluator"); + vi.mocked(evaluatePolicies).mockResolvedValueOnce({ + exitCode: 0, + stdout: "", + stderr: "", + policyName: null, + reason: null, + decision: "allow", + }); + mockStdin(JSON.stringify({ prompt: "hello" })); + const { persistHookActivity } = await import("../../src/hooks/hook-activity-store"); + + await handleHookEvent("input", "pi"); + + expect(evaluatePolicies).toHaveBeenCalledWith( + "UserPromptSubmit", + expect.any(Object), + expect.any(Object), + expect.any(Object), + ); + expect(persistHookActivity).toHaveBeenCalledWith( + expect.objectContaining({ integration: "pi", eventType: "UserPromptSubmit" }), + ); + }); + + it("canonicalizes Pi session_start → SessionStart", async () => { + const { evaluatePolicies } = await import("../../src/hooks/policy-evaluator"); + vi.mocked(evaluatePolicies).mockResolvedValueOnce({ + exitCode: 0, + stdout: "", + stderr: "", + policyName: null, + reason: null, + decision: "allow", + }); + mockStdin(JSON.stringify({ cwd: "/home/u/repo" })); + const { persistHookActivity } = await import("../../src/hooks/hook-activity-store"); + + await handleHookEvent("session_start", "pi"); + + expect(evaluatePolicies).toHaveBeenCalledWith( + "SessionStart", + expect.any(Object), + expect.any(Object), + expect.any(Object), + ); + expect(persistHookActivity).toHaveBeenCalledWith( + expect.objectContaining({ integration: "pi", eventType: "SessionStart" }), + ); + }); + + it("passes through unknown Pi event names unchanged", async () => { + const { evaluatePolicies } = await import("../../src/hooks/policy-evaluator"); + vi.mocked(evaluatePolicies).mockResolvedValueOnce({ + exitCode: 0, + stdout: "", + stderr: "", + policyName: null, + reason: null, + decision: "allow", + }); + mockStdin(JSON.stringify({})); + + await handleHookEvent("model_select", "pi"); + + // Unknown pi event passes through verbatim — handler doesn't map it + // and policy evaluation simply finds no matching policies. + expect(evaluatePolicies).toHaveBeenCalledWith( + "model_select", + expect.any(Object), + expect.any(Object), + expect.any(Object), + ); + }); + + it("tags telemetry with cli=pi when invoked with --cli pi", async () => { + const { evaluatePolicies } = await import("../../src/hooks/policy-evaluator"); + vi.mocked(evaluatePolicies).mockResolvedValueOnce({ + exitCode: 0, + stdout: '{"permission":"deny","reason":"sudo blocked"}', + stderr: "", + policyName: "block-sudo", + reason: "sudo blocked", + decision: "deny", + }); + mockStdin(JSON.stringify({ tool_name: "Bash" })); + const { trackHookEvent } = await import("../../src/hooks/hook-telemetry"); + + await handleHookEvent("tool_call", "pi"); + + expect(trackHookEvent).toHaveBeenCalledWith( + "test-instance-id", + "hook_policy_triggered", + expect.objectContaining({ cli: "pi", event_type: "PreToolUse" }), + ); + }); + it("fires telemetry with full payload for instruct decisions", async () => { const { evaluatePolicies } = await import("../../src/hooks/policy-evaluator"); vi.mocked(evaluatePolicies).mockResolvedValueOnce({ diff --git a/__tests__/hooks/integrations.test.ts b/__tests__/hooks/integrations.test.ts index 9cf96c05..bb352e06 100644 --- a/__tests__/hooks/integrations.test.ts +++ b/__tests__/hooks/integrations.test.ts @@ -18,6 +18,7 @@ import { codex, copilot, cursor, + pi, getIntegration, listIntegrations, } from "../../src/hooks/integrations"; @@ -27,10 +28,13 @@ import { COPILOT_HOOK_EVENT_TYPES, CURSOR_HOOK_EVENT_TYPES, CURSOR_EVENT_MAP, + PI_HOOK_EVENT_TYPES, + PI_EVENT_MAP, HOOK_EVENT_TYPES, FAILPROOFAI_HOOK_MARKER, type CodexHookEventType, type CursorHookEventType, + type PiHookEventType, } from "../../src/hooks/types"; let tempDir: string; @@ -44,9 +48,9 @@ afterEach(() => { }); describe("integrations registry", () => { - it("listIntegrations returns claude, codex, copilot, and cursor", () => { + it("listIntegrations returns claude, codex, copilot, cursor, and pi", () => { const ids = listIntegrations().map((i) => i.id); - expect(ids).toEqual(["claude", "codex", "copilot", "cursor"]); + expect(ids).toEqual(["claude", "codex", "copilot", "cursor", "pi"]); }); it("getIntegration('claude') returns claudeCode", () => { @@ -65,6 +69,10 @@ describe("integrations registry", () => { expect(getIntegration("cursor")).toBe(cursor); }); + it("getIntegration('pi') returns pi", () => { + expect(getIntegration("pi")).toBe(pi); + }); + it("getIntegration throws for unknown id", () => { // @ts-expect-error — testing error path expect(() => getIntegration("unknown-cli")).toThrow(); @@ -458,3 +466,172 @@ describe("CURSOR_EVENT_MAP", () => { expect(CURSOR_EVENT_MAP[sample]).toBe("PreToolUse"); }); }); + +describe("Pi integration", () => { + it("getSettingsPath user → ~/.pi/agent/settings.json (NOT ~/.pi/settings.json)", () => { + const userPath = pi.getSettingsPath("user"); + expect(userPath).toContain(".pi"); + expect(userPath.endsWith(`/.pi/agent/settings.json`)).toBe(true); + }); + + it("getSettingsPath project → /.pi/settings.json", () => { + expect(pi.getSettingsPath("project", tempDir)).toBe(resolve(tempDir, ".pi", "settings.json")); + }); + + it("scopes are user|project (no local)", () => { + expect([...pi.scopes]).toEqual(["user", "project"]); + }); + + it("eventTypes are exactly the 4 Pi events (snake_case)", () => { + expect([...pi.eventTypes]).toEqual([...PI_HOOK_EVENT_TYPES]); + }); + + it("buildHookEntry includes the FAILPROOFAI_HOOK_MARKER", () => { + const entry = pi.buildHookEntry("/usr/local/bin/failproofai", "tool_call", "user"); + expect(entry[FAILPROOFAI_HOOK_MARKER]).toBe(true); + }); + + it("writeHookEntries adds a packages-array entry to a fresh settings.json", () => { + const settings: Record = {}; + pi.writeHookEntries(settings, "/usr/local/bin/failproofai", "user"); + const packages = (settings as { packages?: unknown[] }).packages; + expect(Array.isArray(packages)).toBe(true); + expect(packages?.length).toBe(1); + const entry = (packages?.[0] ?? "") as string; + expect(typeof entry).toBe("string"); + expect(entry).toContain("pi-extension"); + expect(entry).toContain("failproofai"); + }); + + it("writeHookEntries appends to an existing packages array, preserving user entries", () => { + const settings: Record = { packages: ["npm:@user/foo"] }; + pi.writeHookEntries(settings, "/usr/local/bin/failproofai", "user"); + const packages = (settings as { packages?: unknown[] }).packages ?? []; + expect(packages.length).toBe(2); + expect(packages[0]).toBe("npm:@user/foo"); + expect(typeof packages[1]).toBe("string"); + expect((packages[1] as string)).toContain("pi-extension"); + }); + + it("writeHookEntries is idempotent — re-running replaces (not duplicates) failproofai", () => { + const settings: Record = {}; + pi.writeHookEntries(settings, "/usr/local/bin/failproofai", "user"); + pi.writeHookEntries(settings, "/usr/local/bin/failproofai", "user"); + const packages = (settings as { packages?: unknown[] }).packages ?? []; + expect(packages.filter((p) => typeof p === "string" && (p as string).includes("pi-extension")).length).toBe(1); + }); + + it("writeHookEntries with --scope project writes a relative path under ", () => { + // Set cwd to tempDir so the project-scope relative-path computation lines up. + const origCwd = process.cwd(); + try { + process.chdir(tempDir); + const settings: Record = {}; + pi.writeHookEntries(settings, "/usr/local/bin/failproofai", "project"); + // The entry will only be relative if pi-extension lives under cwd. Since + // we're in a temp dir, the helper falls back to absolute — so just assert + // an entry was written and it looks like a path. + const packages = (settings as { packages?: unknown[] }).packages ?? []; + expect(packages.length).toBe(1); + expect(typeof packages[0]).toBe("string"); + } finally { + process.chdir(origCwd); + } + }); + + it("removeHooksFromFile filters out the failproofai entry, keeps user entries", () => { + const settingsPath = resolve(tempDir, ".pi", "settings.json"); + mkdirSync(resolve(tempDir, ".pi"), { recursive: true }); + writeFileSync( + settingsPath, + JSON.stringify({ + packages: [ + "npm:@user/foo", + "/usr/local/lib/node_modules/failproofai/pi-extension", + ], + }), + ); + const removed = pi.removeHooksFromFile(settingsPath); + expect(removed).toBe(1); + const after = JSON.parse(readFileSync(settingsPath, "utf8")) as { packages?: unknown[] }; + expect(after.packages).toEqual(["npm:@user/foo"]); + }); + + it("removeHooksFromFile drops the empty packages array after removing the last failproofai entry", () => { + const settingsPath = resolve(tempDir, ".pi", "settings.json"); + mkdirSync(resolve(tempDir, ".pi"), { recursive: true }); + writeFileSync( + settingsPath, + JSON.stringify({ + packages: ["/usr/local/lib/node_modules/failproofai/pi-extension"], + }), + ); + pi.removeHooksFromFile(settingsPath); + const after = JSON.parse(readFileSync(settingsPath, "utf8")) as Record; + expect(after.packages).toBeUndefined(); + }); + + it("removeHooksFromFile returns 0 when no failproofai entry was present", () => { + const settingsPath = resolve(tempDir, ".pi", "settings.json"); + mkdirSync(resolve(tempDir, ".pi"), { recursive: true }); + writeFileSync(settingsPath, JSON.stringify({ packages: ["npm:@user/foo"] })); + expect(pi.removeHooksFromFile(settingsPath)).toBe(0); + }); + + it("removeHooksFromFile returns 0 when settings.json doesn't exist", () => { + const settingsPath = resolve(tempDir, ".pi", "settings.json"); + expect(pi.removeHooksFromFile(settingsPath)).toBe(0); + }); + + it("hooksInstalledInSettings finds the entry by source-path substring", () => { + const settingsPath = resolve(tempDir, ".pi", "settings.json"); + mkdirSync(resolve(tempDir, ".pi"), { recursive: true }); + writeFileSync( + settingsPath, + JSON.stringify({ + packages: ["/usr/local/lib/node_modules/failproofai/pi-extension"], + }), + ); + expect(pi.hooksInstalledInSettings("project", tempDir)).toBe(true); + }); + + it("hooksInstalledInSettings returns false when settings.json doesn't exist", () => { + expect(pi.hooksInstalledInSettings("project", tempDir)).toBe(false); + }); + + it("hooksInstalledInSettings returns false on corrupt JSON (fail-open)", () => { + const settingsPath = resolve(tempDir, ".pi", "settings.json"); + mkdirSync(resolve(tempDir, ".pi"), { recursive: true }); + writeFileSync(settingsPath, "{not json"); + expect(pi.hooksInstalledInSettings("project", tempDir)).toBe(false); + }); + + it("isFailproofaiHook detects {source: '...pi-extension/failproofai'}", () => { + expect(pi.isFailproofaiHook({ source: "/path/to/failproofai/pi-extension" })).toBe(true); + expect(pi.isFailproofaiHook({ source: "npm:@user/other" })).toBe(false); + }); + + it("isFailproofaiHook detects FAILPROOFAI_HOOK_MARKER=true", () => { + expect(pi.isFailproofaiHook({ [FAILPROOFAI_HOOK_MARKER]: true })).toBe(true); + }); +}); + +describe("PI_EVENT_MAP", () => { + it("maps every Pi event to a PascalCase HookEventType", () => { + expect(PI_EVENT_MAP.tool_call).toBe("PreToolUse"); + expect(PI_EVENT_MAP.user_bash).toBe("PreToolUse"); + expect(PI_EVENT_MAP.input).toBe("UserPromptSubmit"); + expect(PI_EVENT_MAP.session_start).toBe("SessionStart"); + }); + + it("PI_EVENT_MAP keys exactly match PI_HOOK_EVENT_TYPES", () => { + const mapKeys = Object.keys(PI_EVENT_MAP).sort(); + const eventTypes = [...PI_HOOK_EVENT_TYPES].sort(); + expect(mapKeys).toEqual(eventTypes); + }); + + it("PiHookEventType is exhaustive", () => { + const sample: PiHookEventType = "tool_call"; + expect(PI_EVENT_MAP[sample]).toBe("PreToolUse"); + }); +}); diff --git a/__tests__/lib/cli-registry.test.ts b/__tests__/lib/cli-registry.test.ts index 56fc30c5..97415749 100644 --- a/__tests__/lib/cli-registry.test.ts +++ b/__tests__/lib/cli-registry.test.ts @@ -12,7 +12,7 @@ import { describe("lib/cli-registry", () => { it("KNOWN_CLI_IDS lists all supported CLIs in stable order", () => { - expect(KNOWN_CLI_IDS).toEqual(["claude", "codex", "copilot", "cursor"]); + expect(KNOWN_CLI_IDS).toEqual(["claude", "codex", "copilot", "cursor", "pi"]); }); it("getCliEntry returns the entry for known ids and undefined for unknown", () => { @@ -20,11 +20,13 @@ describe("lib/cli-registry", () => { expect(getCliEntry("codex")?.label).toBe("OpenAI Codex"); expect(getCliEntry("copilot")?.label).toBe("GitHub Copilot"); expect(getCliEntry("cursor")?.label).toBe("Cursor Agent"); + expect(getCliEntry("pi")?.label).toBe("Pi"); expect(getCliEntry("unknown")).toBeUndefined(); }); it("getCliLabel falls back to the id itself for unknown", () => { expect(getCliLabel("claude")).toBe("Claude Code"); + expect(getCliLabel("pi")).toBe("Pi"); expect(getCliLabel("xyz")).toBe("xyz"); }); @@ -33,6 +35,7 @@ describe("lib/cli-registry", () => { expect(getCliBadgeClasses("codex")).toContain("purple"); expect(getCliBadgeClasses("claude")).toContain("orange"); expect(getCliBadgeClasses("cursor")).toContain("emerald"); + expect(getCliBadgeClasses("pi")).toContain("pink"); expect(getCliBadgeClasses("unknown")).toContain("orange"); // falls back to claude }); @@ -40,12 +43,20 @@ describe("lib/cli-registry", () => { expect(isKnownCli("claude")).toBe(true); expect(isKnownCli("copilot")).toBe(true); expect(isKnownCli("cursor")).toBe(true); + expect(isKnownCli("pi")).toBe(true); expect(isKnownCli("nope")).toBe(false); expect(isKnownCli(null)).toBe(false); expect(isKnownCli(undefined)).toBe(false); expect(isKnownCli("")).toBe(false); }); + it("isKnownCli rejects inherited Object.prototype keys", () => { + // Regression for the hasOwnProperty fix landed in #236. + expect(isKnownCli("toString")).toBe(false); + expect(isKnownCli("constructor")).toBe(false); + expect(isKnownCli("hasOwnProperty")).toBe(false); + }); + it("listCliEntries returns one entry per known id", () => { const ids = listCliEntries().map((c) => c.id); expect(ids).toEqual(KNOWN_CLI_IDS); @@ -53,7 +64,7 @@ describe("lib/cli-registry", () => { it("listExternalCliEntries excludes claude", () => { const ids = listExternalCliEntries().map((c) => c.id); - expect(ids).toEqual(["codex", "copilot", "cursor"]); + expect(ids).toEqual(["codex", "copilot", "cursor", "pi"]); }); it("each CLI has a unique badgeClasses string", () => { diff --git a/__tests__/lib/pi-projects.test.ts b/__tests__/lib/pi-projects.test.ts new file mode 100644 index 00000000..205eff8a --- /dev/null +++ b/__tests__/lib/pi-projects.test.ts @@ -0,0 +1,223 @@ +// @vitest-environment node +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, utimesSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +/** + * Compose a Pi-shaped JSONL session file's first record. + * The real Pi format is `{type: "session", version, id, timestamp, cwd}` — + * the parser only requires `type` and `cwd` to surface the project. + */ +function sessionRecord(id: string, cwd: string, ts = "2026-05-01T20:36:22.628Z"): string { + return JSON.stringify({ type: "session", version: 3, id, timestamp: ts, cwd }); +} + +describe("lib/pi-projects", () => { + let originalHome: string | undefined; + let originalSessionsDir: string | undefined; + let fakeHome: string; + let getPiProjects: typeof import("@/lib/pi-projects").getPiProjects; + let getPiSessionsForCwd: typeof import("@/lib/pi-projects").getPiSessionsForCwd; + let getPiSessionsByEncodedName: typeof import("@/lib/pi-projects").getPiSessionsByEncodedName; + + /** + * Write a synthetic Pi session file. Encodes cwd into the per-cwd dir name + * (Pi's `--foo-bar--` scheme) and uses the canonical `_.jsonl` + * filename pattern. + */ + function writeSession( + sessionId: string, + cwd: string, + opts?: { + additionalLines?: string[]; + mtime?: Date; + filename?: string; + sessionRoot?: string; + }, + ): string { + const root = opts?.sessionRoot ?? join(fakeHome, ".pi", "agent", "sessions"); + // Pi's encoding: replace `/` with `-`, wrap in `--…--`. + const encoded = `--${cwd.replace(/^\//, "").replace(/\//g, "-")}--`; + const dir = join(root, encoded); + mkdirSync(dir, { recursive: true }); + const fname = opts?.filename ?? `2026-05-01T20-36-22-628Z_${sessionId}.jsonl`; + const path = join(dir, fname); + const lines = [sessionRecord(sessionId, cwd), ...(opts?.additionalLines ?? [])]; + writeFileSync(path, lines.join("\n") + "\n"); + if (opts?.mtime) utimesSync(path, opts.mtime, opts.mtime); + return path; + } + + beforeEach(async () => { + originalHome = process.env.HOME; + originalSessionsDir = process.env.PI_SESSIONS_DIR; + fakeHome = mkdtempSync(join(tmpdir(), "pi-projects-")); + process.env.HOME = fakeHome; + delete process.env.PI_SESSIONS_DIR; + vi.resetModules(); + vi.doMock("node:os", async () => { + const actual = await vi.importActual("node:os"); + return { ...actual, homedir: () => fakeHome }; + }); + const mod = await import("@/lib/pi-projects"); + getPiProjects = mod.getPiProjects; + getPiSessionsForCwd = mod.getPiSessionsForCwd; + getPiSessionsByEncodedName = mod.getPiSessionsByEncodedName; + }); + + afterEach(() => { + rmSync(fakeHome, { recursive: true, force: true }); + if (originalHome !== undefined) process.env.HOME = originalHome; + else delete process.env.HOME; + if (originalSessionsDir !== undefined) process.env.PI_SESSIONS_DIR = originalSessionsDir; + else delete process.env.PI_SESSIONS_DIR; + vi.doUnmock("node:os"); + vi.resetModules(); + }); + + it("returns [] when ~/.pi/agent/sessions doesn't exist", async () => { + expect(await getPiProjects()).toEqual([]); + }); + + it("returns [] when sessions root exists but is empty", async () => { + mkdirSync(join(fakeHome, ".pi", "agent", "sessions"), { recursive: true }); + expect(await getPiProjects()).toEqual([]); + }); + + it("surfaces a single Pi project when one session file exists", async () => { + writeSession( + "00000000-0000-4000-8000-000000000001", + "/home/u/repo", + { mtime: new Date("2026-05-01T00:00:00Z") }, + ); + const projects = await getPiProjects(); + expect(projects).toHaveLength(1); + expect(projects[0].path).toBe("/home/u/repo"); + expect(projects[0].cli).toEqual(["pi"]); + // Encoded folder name uses failproofai's slash → dash convention. + expect(projects[0].name).toBe("-home-u-repo"); + }); + + it("uses the cwd from the JSONL first record (NOT the dir name encoding)", async () => { + // The dir-name encoding is lossy when a real path contains `-`. Verify + // that getPiProjects pulls the canonical cwd from the record text, not + // from decoding the directory name. + writeSession( + "00000000-0000-4000-8000-000000000001", + "/home/u/has-dashes-here", + { mtime: new Date("2026-05-01T00:00:00Z") }, + ); + const projects = await getPiProjects(); + expect(projects[0].path).toBe("/home/u/has-dashes-here"); + }); + + it("groups multiple sessions under the same cwd into one project row", async () => { + writeSession("00000000-0000-4000-8000-000000000001", "/home/u/repo", { + mtime: new Date("2026-05-01T00:00:00Z"), + filename: "2026-05-01T00-00-00-000Z_00000000-0000-4000-8000-000000000001.jsonl", + }); + writeSession("00000000-0000-4000-8000-000000000002", "/home/u/repo", { + mtime: new Date("2026-05-02T00:00:00Z"), + filename: "2026-05-02T00-00-00-000Z_00000000-0000-4000-8000-000000000002.jsonl", + }); + const projects = await getPiProjects(); + expect(projects).toHaveLength(1); + // Newest mtime wins for the project's lastModified. + expect(projects[0].lastModified.toISOString()).toBe("2026-05-02T00:00:00.000Z"); + }); + + it("surfaces multiple cwds as separate projects", async () => { + writeSession("00000000-0000-4000-8000-000000000001", "/home/u/repoA"); + writeSession("00000000-0000-4000-8000-000000000002", "/home/u/repoB"); + const projects = await getPiProjects(); + expect(projects).toHaveLength(2); + expect(projects.map((p) => p.path).sort()).toEqual(["/home/u/repoA", "/home/u/repoB"]); + }); + + it("sorts by lastModified desc", async () => { + writeSession("00000000-0000-4000-8000-000000000001", "/home/u/old", { + mtime: new Date("2024-01-01T00:00:00Z"), + }); + writeSession("00000000-0000-4000-8000-000000000002", "/home/u/new", { + mtime: new Date("2026-05-01T00:00:00Z"), + }); + const projects = await getPiProjects(); + expect(projects[0].path).toBe("/home/u/new"); + expect(projects[1].path).toBe("/home/u/old"); + }); + + it("ignores files that don't match the Pi session-file naming pattern", async () => { + const root = join(fakeHome, ".pi", "agent", "sessions"); + const dir = join(root, "--home-u-repo--"); + mkdirSync(dir, { recursive: true }); + // Stray non-jsonl file + writeFileSync(join(dir, "README"), "junk"); + // Wrong filename pattern + writeFileSync(join(dir, "no-uuid-here.jsonl"), sessionRecord("x", "/home/u/repo")); + expect(await getPiProjects()).toEqual([]); + }); + + it("skips sessions whose first record is corrupt JSON", async () => { + const root = join(fakeHome, ".pi", "agent", "sessions"); + const dir = join(root, "--home-u-broken--"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "2026-05-01T00-00-00-000Z_00000000-0000-4000-8000-000000000001.jsonl"), + "{not json\n", + ); + expect(await getPiProjects()).toEqual([]); + }); + + it("skips sessions whose first record is the wrong type", async () => { + const root = join(fakeHome, ".pi", "agent", "sessions"); + const dir = join(root, "--home-u-wrong--"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "2026-05-01T00-00-00-000Z_00000000-0000-4000-8000-000000000001.jsonl"), + JSON.stringify({ type: "model_change", id: "x" }) + "\n", + ); + expect(await getPiProjects()).toEqual([]); + }); + + it("getPiSessionsForCwd returns sessions only for the matching cwd", async () => { + writeSession("00000000-0000-4000-8000-000000000001", "/home/u/repoA"); + writeSession("00000000-0000-4000-8000-000000000002", "/home/u/repoB"); + const sessions = await getPiSessionsForCwd("/home/u/repoA"); + expect(sessions).toHaveLength(1); + expect(sessions[0].sessionId).toBe("00000000-0000-4000-8000-000000000001"); + }); + + it("getPiSessionsByEncodedName matches by encoded cwd round-trip", async () => { + writeSession("00000000-0000-4000-8000-000000000001", "/home/u/has-dashes"); + // failproofai's encodeFolderName(`/home/u/has-dashes`) → `-home-u-has-dashes` + const result = await getPiSessionsByEncodedName("-home-u-has-dashes"); + expect(result.cwd).toBe("/home/u/has-dashes"); + expect(result.sessions).toHaveLength(1); + }); + + it("getPiSessionsByEncodedName returns null cwd when no match", async () => { + writeSession("00000000-0000-4000-8000-000000000001", "/home/u/repo"); + const result = await getPiSessionsByEncodedName("-different-path"); + expect(result.cwd).toBeNull(); + expect(result.sessions).toEqual([]); + }); + + it("PI_SESSIONS_DIR env var overrides the default sessions root", async () => { + const overrideRoot = mkdtempSync(join(tmpdir(), "pi-override-")); + try { + process.env.PI_SESSIONS_DIR = overrideRoot; + // Re-import after env mutation so getPiSessionsRoot picks up the change. + vi.resetModules(); + const mod = await import("@/lib/pi-projects"); + writeSession("00000000-0000-4000-8000-000000000099", "/home/u/under-override", { + sessionRoot: overrideRoot, + }); + const projects = await mod.getPiProjects(); + expect(projects).toHaveLength(1); + expect(projects[0].path).toBe("/home/u/under-override"); + } finally { + rmSync(overrideRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/__tests__/lib/pi-sessions.test.ts b/__tests__/lib/pi-sessions.test.ts new file mode 100644 index 00000000..2be52274 --- /dev/null +++ b/__tests__/lib/pi-sessions.test.ts @@ -0,0 +1,208 @@ +// @vitest-environment node +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +const SAFE_UUID = "00000000-0000-4000-8000-000000000001"; +const SECOND_UUID = "00000000-0000-4000-8000-000000000002"; + +function sessionRecord(id: string, cwd: string, ts = "2026-05-01T20:36:22.628Z"): string { + return JSON.stringify({ type: "session", version: 3, id, timestamp: ts, cwd }); +} + +function messageRecord(role: "user" | "assistant", text: string, ts = "2026-05-01T20:36:23.000Z"): string { + return JSON.stringify({ + type: "message", + id: "msg-" + Math.random().toString(36).slice(2, 10), + timestamp: ts, + message: { role, content: [{ type: "text", text }] }, + }); +} + +describe("lib/pi-sessions", () => { + let originalHome: string | undefined; + let originalSessionsDir: string | undefined; + let fakeHome: string; + let mod: typeof import("@/lib/pi-sessions"); + + function writeSession(sessionId: string, cwd: string, additionalLines: string[] = []): string { + const root = join(fakeHome, ".pi", "agent", "sessions"); + const encoded = `--${cwd.replace(/^\//, "").replace(/\//g, "-")}--`; + const dir = join(root, encoded); + mkdirSync(dir, { recursive: true }); + const path = join(dir, `2026-05-01T20-36-22-628Z_${sessionId}.jsonl`); + const lines = [sessionRecord(sessionId, cwd), ...additionalLines]; + writeFileSync(path, lines.join("\n") + "\n"); + return path; + } + + beforeEach(async () => { + originalHome = process.env.HOME; + originalSessionsDir = process.env.PI_SESSIONS_DIR; + fakeHome = mkdtempSync(join(tmpdir(), "pi-sessions-")); + process.env.HOME = fakeHome; + delete process.env.PI_SESSIONS_DIR; + vi.resetModules(); + vi.doMock("node:os", async () => { + const actual = await vi.importActual("node:os"); + return { ...actual, homedir: () => fakeHome }; + }); + mod = await import("@/lib/pi-sessions"); + }); + + afterEach(() => { + rmSync(fakeHome, { recursive: true, force: true }); + if (originalHome !== undefined) process.env.HOME = originalHome; + else delete process.env.HOME; + if (originalSessionsDir !== undefined) process.env.PI_SESSIONS_DIR = originalSessionsDir; + else delete process.env.PI_SESSIONS_DIR; + vi.doUnmock("node:os"); + vi.resetModules(); + }); + + describe("findPiTranscript", () => { + it("finds the transcript by sessionId UUID across cwd subdirs", () => { + const path = writeSession(SAFE_UUID, "/home/u/repo"); + expect(mod.findPiTranscript(SAFE_UUID)).toBe(path); + }); + + it("returns null when no matching session file exists", () => { + writeSession(SAFE_UUID, "/home/u/repo"); + expect(mod.findPiTranscript(SECOND_UUID)).toBeNull(); + }); + + it("returns null when sessions root doesn't exist", () => { + expect(mod.findPiTranscript(SAFE_UUID)).toBeNull(); + }); + + it("rejects sessionId with path traversal — `../foo`", () => { + // No file written; the rejection happens regardless via the UUID regex. + expect(mod.findPiTranscript("../foo")).toBeNull(); + }); + + it("rejects sessionId `..`", () => { + expect(mod.findPiTranscript("..")).toBeNull(); + }); + + it("rejects absolute sessionId `/etc/passwd`", () => { + expect(mod.findPiTranscript("/etc/passwd")).toBeNull(); + }); + + it("rejects empty sessionId", () => { + expect(mod.findPiTranscript("")).toBeNull(); + }); + + it("accepts a valid UUID", () => { + const path = writeSession(SAFE_UUID, "/home/u/repo"); + expect(mod.findPiTranscript(SAFE_UUID)).toBe(path); + }); + }); + + describe("getPiSessionLog", () => { + it("parses session record into cwd + a Session-Started entry", async () => { + writeSession(SAFE_UUID, "/home/u/repo"); + const result = await mod.getPiSessionLog(SAFE_UUID); + expect(result).not.toBeNull(); + expect(result!.cwd).toBe("/home/u/repo"); + expect(result!.entries[0]?.type).toBe("queue-operation"); + }); + + it("parses user.message records as user entries", async () => { + writeSession(SAFE_UUID, "/home/u/repo", [messageRecord("user", "hello")]); + const result = await mod.getPiSessionLog(SAFE_UUID); + const userEntries = result!.entries.filter((e) => e.type === "user"); + expect(userEntries).toHaveLength(1); + // Type is `user` so message.content is a string for user messages. + expect((userEntries[0] as { message: { content: string } }).message.content).toBe("hello"); + }); + + it("parses assistant.message records as assistant entries with text content blocks", async () => { + writeSession(SAFE_UUID, "/home/u/repo", [ + messageRecord("assistant", "I will help."), + ]); + const result = await mod.getPiSessionLog(SAFE_UUID); + const asst = result!.entries.find((e) => e.type === "assistant"); + expect(asst).toBeDefined(); + }); + + it("ignores non-string text content via typeof guard", async () => { + writeSession(SAFE_UUID, "/home/u/repo", [ + JSON.stringify({ + type: "message", + id: "x", + timestamp: "2026-05-01T20:36:23.000Z", + message: { + role: "user", + content: [{ type: "text", text: { malicious: "object" } }], + }, + }), + ]); + const result = await mod.getPiSessionLog(SAFE_UUID); + // Non-string text is skipped → no user entry surfaces from this record. + const userEntries = result!.entries.filter((e) => e.type === "user"); + expect(userEntries).toHaveLength(0); + }); + + it("preserves unknown record types as system entries (does not silently drop)", async () => { + writeSession(SAFE_UUID, "/home/u/repo", [ + JSON.stringify({ + type: "model_change", + id: "mc1", + timestamp: "2026-05-01T20:36:23.000Z", + provider: "openai", + modelId: "gpt-5", + }), + ]); + const result = await mod.getPiSessionLog(SAFE_UUID); + const systemEntries = result!.entries.filter((e) => e.type === "system"); + expect(systemEntries.length).toBeGreaterThan(0); + }); + + it("returns gracefully when JSONL has unparseable garbage as the only line", async () => { + // Write a transcript file whose only line is invalid JSON. The parser + // should skip it and produce an empty entries array, NOT throw. + const root = join(fakeHome, ".pi", "agent", "sessions"); + const dir = join(root, "--home-u-broken--"); + mkdirSync(dir, { recursive: true }); + const path = join(dir, `2026-05-01T20-36-22-628Z_${SAFE_UUID}.jsonl`); + writeFileSync(path, "{not json\n"); + const result = await mod.getPiSessionLog(SAFE_UUID); + expect(result).not.toBeNull(); + expect(result!.entries).toEqual([]); + }); + + it("returns null for unsafe sessionIds (path-traversal)", async () => { + expect(await mod.getPiSessionLog("../foo")).toBeNull(); + }); + + it("returns null when transcript file doesn't exist", async () => { + expect(await mod.getPiSessionLog(SAFE_UUID)).toBeNull(); + }); + + it("entries are sorted by timestamp ascending", async () => { + writeSession(SAFE_UUID, "/home/u/repo", [ + messageRecord("user", "second", "2026-05-01T20:37:00.000Z"), + messageRecord("assistant", "first", "2026-05-01T20:36:30.000Z"), + ]); + const result = await mod.getPiSessionLog(SAFE_UUID); + expect(result!.entries[0].timestampMs).toBeLessThanOrEqual(result!.entries[1].timestampMs); + }); + }); + + describe("readPiTranscriptSync", () => { + it("returns content for a valid sessionId", () => { + writeSession(SAFE_UUID, "/home/u/repo"); + const text = mod.readPiTranscriptSync(SAFE_UUID); + expect(text).toContain('"type":"session"'); + }); + + it("returns null for unknown sessionId", () => { + expect(mod.readPiTranscriptSync(SAFE_UUID)).toBeNull(); + }); + + it("returns null for path-traversal attempts", () => { + expect(mod.readPiTranscriptSync("../etc/passwd")).toBeNull(); + }); + }); +}); diff --git a/__tests__/lib/projects.test.ts b/__tests__/lib/projects.test.ts index 37552b39..43b8ff43 100644 --- a/__tests__/lib/projects.test.ts +++ b/__tests__/lib/projects.test.ts @@ -32,15 +32,21 @@ vi.mock("@/lib/cursor-projects", () => ({ getCursorProjects: vi.fn(async () => []), })); +vi.mock("@/lib/pi-projects", () => ({ + getPiProjects: vi.fn(async () => []), +})); + import { readdir, stat } from "fs/promises"; import { extractSessionId, getProjectFolders, getSessionFiles, type ProjectFolder } from "@/lib/projects"; import { getCodexProjects } from "@/lib/codex-projects"; import { getCopilotProjects } from "@/lib/copilot-projects"; import { getCursorProjects } from "@/lib/cursor-projects"; +import { getPiProjects } from "@/lib/pi-projects"; const mockGetCodexProjects = vi.mocked(getCodexProjects); const mockGetCopilotProjects = vi.mocked(getCopilotProjects); const mockGetCursorProjects = vi.mocked(getCursorProjects); +const mockGetPiProjects = vi.mocked(getPiProjects); const mockReaddir = vi.mocked(readdir); const mockStat = vi.mocked(stat); @@ -321,6 +327,99 @@ describe("getProjectFolders", () => { expect(result[0].path).toBe("/home/u/cursor-only"); }); + it("merges Claude + Codex + Copilot + Cursor + Pi rows that share an encoded name", async () => { + const claudeMtime = new Date("2024-01-01T00:00:00Z"); + const codexMtime = new Date("2025-01-01T00:00:00Z"); + const copilotMtime = new Date("2026-01-15T00:00:00Z"); + const cursorMtime = new Date("2026-04-15T00:00:00Z"); + const piMtime = new Date("2026-07-15T00:00:00Z"); + mockStat.mockResolvedValueOnce({ isDirectory: () => true } as any); + mockReaddir.mockResolvedValueOnce([ + { name: "-home-u-five", isDirectory: () => true, isFile: () => false } as any, + ] as any); + mockStat.mockResolvedValueOnce({ mtime: claudeMtime } as any); + mockGetCodexProjects.mockResolvedValueOnce([ + { + name: "-home-u-five", + path: "/home/u/five", + isDirectory: true, + lastModified: codexMtime, + lastModifiedFormatted: codexMtime.toISOString(), + cli: ["codex"], + } satisfies ProjectFolder, + ]); + mockGetCopilotProjects.mockResolvedValueOnce([ + { + name: "-home-u-five", + path: "/home/u/five", + isDirectory: true, + lastModified: copilotMtime, + lastModifiedFormatted: copilotMtime.toISOString(), + cli: ["copilot"], + } satisfies ProjectFolder, + ]); + mockGetCursorProjects.mockResolvedValueOnce([ + { + name: "-home-u-five", + path: "/home/u/five", + isDirectory: true, + lastModified: cursorMtime, + lastModifiedFormatted: cursorMtime.toISOString(), + cli: ["cursor"], + } satisfies ProjectFolder, + ]); + mockGetPiProjects.mockResolvedValueOnce([ + { + name: "-home-u-five", + path: "/home/u/five", + isDirectory: true, + lastModified: piMtime, + lastModifiedFormatted: piMtime.toISOString(), + cli: ["pi"], + } satisfies ProjectFolder, + ]); + + const result = await getProjectFolders(); + expect(result).toHaveLength(1); + expect(result[0].cli).toEqual(["claude", "codex", "copilot", "cursor", "pi"]); + // Newest mtime wins (Pi in this case). + expect(result[0].lastModified.getTime()).toBe(piMtime.getTime()); + }); + + it("includes Pi-only projects (no matching Claude/Codex/Copilot/Cursor folder)", async () => { + mockStat.mockResolvedValueOnce({ isDirectory: () => true } as any); + mockReaddir.mockResolvedValueOnce([] as any); + mockGetPiProjects.mockResolvedValueOnce([ + { + name: "-home-u-pi-only", + path: "/home/u/pi-only", + isDirectory: true, + lastModified: new Date("2026-07-15T00:00:00Z"), + lastModifiedFormatted: "2026-07-15T00:00:00.000Z", + cli: ["pi"], + } satisfies ProjectFolder, + ]); + + const result = await getProjectFolders(); + expect(result).toHaveLength(1); + expect(result[0].cli).toEqual(["pi"]); + expect(result[0].path).toBe("/home/u/pi-only"); + }); + + it("falls back gracefully when getPiProjects rejects", async () => { + mockStat.mockResolvedValueOnce({ isDirectory: () => true } as any); + mockReaddir.mockResolvedValueOnce([ + { name: "-home-u-claude", isDirectory: () => true, isFile: () => false } as any, + ] as any); + mockStat.mockResolvedValueOnce({ mtime: new Date("2026-04-01T00:00:00Z") } as any); + mockGetPiProjects.mockRejectedValueOnce(new Error("scan failed")); + + const result = await getProjectFolders(); + // Claude row still surfaces even though Pi scan blew up. + expect(result).toHaveLength(1); + expect(result[0].cli).toEqual(["claude"]); + }); + it("falls back gracefully when getCursorProjects rejects", async () => { mockStat.mockResolvedValueOnce({ isDirectory: () => true } as any); mockReaddir.mockResolvedValueOnce([ diff --git a/app/policies/hooks-client.tsx b/app/policies/hooks-client.tsx index 4dcd6ed7..53e735a9 100644 --- a/app/policies/hooks-client.tsx +++ b/app/policies/hooks-client.tsx @@ -92,10 +92,12 @@ function SessionCell({ (transcriptPath?.includes("/.copilot/session-state/") ?? false); const isCursor = integration === "cursor" || (transcriptPath?.includes("/.cursor/") ?? false); - if (isCodex || isCopilot || isCursor) { + const isPi = + integration === "pi" || (transcriptPath?.includes("/.pi/") ?? false); + if (isCodex || isCopilot || isCursor || isPi) { // The session route auto-detects CLI by file location, so [name] only // affects the breadcrumb. Encode the cwd Claude-style when we have it. - const fallbackSeg = isCodex ? "codex" : isCopilot ? "copilot" : "cursor"; + const fallbackSeg = isCodex ? "codex" : isCopilot ? "copilot" : isCursor ? "cursor" : "pi"; const projectSeg = cwd ? encodeCwdForUrl(cwd) : fallbackSeg; return ( !!s) .map((s) => s.lastModified) .reduce((acc, d) => (!acc || d.getTime() > acc.getTime() ? d : acc), null); @@ -93,6 +97,7 @@ export default async function ProjectPage({ params }: ProjectPageProps) { ...codexSessions, ...copilotSessions, ...cursorSessions, + ...piSessions, ].sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); // Path line: prefer the Claude storage dir if present (matches existing UX); diff --git a/app/project/[name]/session/[sessionId]/page.tsx b/app/project/[name]/session/[sessionId]/page.tsx index 9a645434..161902cf 100644 --- a/app/project/[name]/session/[sessionId]/page.tsx +++ b/app/project/[name]/session/[sessionId]/page.tsx @@ -6,6 +6,7 @@ import { getCachedSessionLog, type LogEntry } from "@/lib/log-entries"; import { getCachedCodexSessionLog } from "@/lib/codex-sessions"; import { getCachedCopilotSessionLog } from "@/lib/copilot-sessions"; import { getCachedCursorSessionLog } from "@/lib/cursor-sessions"; +import { getCachedPiSessionLog } from "@/lib/pi-sessions"; import { decodeFolderName } from "@/lib/paths"; import { baseSessionId } from "@/lib/utils/session-id"; import { resolveProjectPath, UUID_RE } from "@/lib/projects"; @@ -37,7 +38,7 @@ export default async function SessionPage({ params }: SessionPageProps) { let entries: LogEntry[] | null = null; let rawLines: Record[] | null = null; let error: string | null = null; - let cli: "claude" | "codex" | "copilot" | "cursor" = "claude"; + let cli: "claude" | "codex" | "copilot" | "cursor" | "pi" = "claude"; let externalCwd: string | undefined; try { @@ -48,7 +49,7 @@ export default async function SessionPage({ params }: SessionPageProps) { } catch (e) { const isNotFound = (e as NodeJS.ErrnoException).code === "ENOENT"; if (isNotFound) { - // Fall back through external stores in order: Codex → Copilot → Cursor. + // Fall back through external stores in order: Codex → Copilot → Cursor → Pi. // Each store keys by sessionId rather than the project slug, so the // [name] segment is irrelevant on these branches. const codex = await getCachedCodexSessionLog(decodedSessionId); @@ -72,7 +73,15 @@ export default async function SessionPage({ params }: SessionPageProps) { externalCwd = cursor.cwd; cli = "cursor"; } else { - error = "Session log file not found."; + const pi = await getCachedPiSessionLog(decodedSessionId); + if (pi) { + entries = pi.entries; + rawLines = pi.rawLines; + externalCwd = pi.cwd; + cli = "pi"; + } else { + error = "Session log file not found."; + } } } } @@ -90,7 +99,9 @@ export default async function SessionPage({ params }: SessionPageProps) { ? `GitHub Copilot${externalCwd ? ` · ${externalCwd}` : ""}` : cli === "cursor" ? `Cursor Agent${externalCwd ? ` · ${externalCwd}` : ""}` - : decodedName; + : cli === "pi" + ? `Pi${externalCwd ? ` · ${externalCwd}` : ""}` + : decodedName; return (
@@ -154,7 +165,9 @@ export default async function SessionPage({ params }: SessionPageProps) { ? "OpenAI Codex" : cli === "copilot" ? "GitHub Copilot" - : "Cursor Agent")) + : cli === "cursor" + ? "Cursor Agent" + : "Pi")) : decodedName } sessionId={decodedSessionId} diff --git a/assets/logos/pi-dark.svg b/assets/logos/pi-dark.svg new file mode 100644 index 00000000..84399ee3 --- /dev/null +++ b/assets/logos/pi-dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/logos/pi-light.svg b/assets/logos/pi-light.svg new file mode 100644 index 00000000..4dff8c18 --- /dev/null +++ b/assets/logos/pi-light.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/bin/failproofai.mjs b/bin/failproofai.mjs index 913d016f..6ef11def 100755 --- a/bin/failproofai.mjs +++ b/bin/failproofai.mjs @@ -43,7 +43,7 @@ const hookIdx = args.indexOf("--hook"); if (hookIdx >= 0) { if (!args[hookIdx + 1]) { console.error("Error: Missing event type after --hook"); - console.error("Usage: failproofai --hook [--cli ]"); + console.error("Usage: failproofai --hook [--cli ]"); process.exit(1); } const eventType = args[hookIdx + 1]; @@ -52,7 +52,13 @@ if (hookIdx >= 0) { // Default cli=claude preserves back-compat for hooks installed before // multi-CLI support landed. const cli = - cliArg && (cliArg === "claude" || cliArg === "codex" || cliArg === "copilot" || cliArg === "cursor") + cliArg && ( + cliArg === "claude" + || cliArg === "codex" + || cliArg === "copilot" + || cliArg === "cursor" + || cliArg === "pi" + ) ? cliArg : "claude"; try { @@ -105,18 +111,18 @@ COMMANDS policies, p List all available policies and their status policies --install, -i Enable policies in agent CLI settings [names...] Specific policy names to enable - --cli claude|codex|copilot|cursor + --cli claude|codex|copilot|cursor|pi Agent CLI(s) to install for; space-separated - (e.g. --cli claude codex copilot cursor) or repeated. + (e.g. --cli claude codex copilot cursor pi) or repeated. Default: detect installed CLIs and prompt. --scope user|project|local Config scope to write to (default: user) - (Codex / Copilot / Cursor support user|project only) + (Codex / Copilot / Cursor / Pi support user|project only) --beta Include beta policies --custom, -c Path to a JS file of custom policies policies --uninstall, -u Disable policies or remove hooks [names...] Specific policy names to disable - --cli claude|codex|copilot|cursor + --cli claude|codex|copilot|cursor|pi Agent CLI(s) to uninstall from --scope user|project|local|all Config scope to remove from (default: user) --beta Remove only beta policies @@ -145,13 +151,15 @@ EXAMPLES failproofai policies --install --cli codex --scope project failproofai policies --install --cli copilot --scope project failproofai policies --install --cli cursor --scope project - failproofai policies --install --cli claude codex copilot cursor + failproofai policies --install --cli pi --scope project + failproofai policies --install --cli claude codex copilot cursor pi failproofai policies --install --custom ./my-policies.js failproofai policies -i -c ./my-policies.js failproofai policies --uninstall block-sudo failproofai policies --uninstall --cli codex failproofai policies --uninstall --cli copilot failproofai policies --uninstall --cli cursor + failproofai policies --uninstall --cli pi failproofai policies --uninstall --custom LINKS @@ -191,20 +199,20 @@ USAGE OPTIONS (install) [names...] Specific policy names to enable (omit for interactive) - --cli claude|codex|copilot|cursor + --cli claude|codex|copilot|cursor|pi Agent CLI(s) to install for; space-separated - (e.g. --cli claude codex copilot cursor) or repeated. + (e.g. --cli claude codex copilot cursor pi) or repeated. Omit to detect installed CLIs and prompt (or auto-pick if only one is found). --scope user|project|local Config scope to write to (default: user) - (Codex / Copilot / Cursor support user|project only) + (Codex / Copilot / Cursor / Pi support user|project only) --beta Include beta policies --custom, -c Path to a JS file of custom policies (skips interactive prompt; validates file first) OPTIONS (uninstall) [names...] Specific policy names to disable (omit to remove hooks) - --cli claude|codex|copilot|cursor + --cli claude|codex|copilot|cursor|pi Agent CLI(s) to uninstall from --scope user|project|local|all Config scope to remove from (default: user) --beta Remove only beta policies @@ -217,13 +225,15 @@ EXAMPLES failproofai policies --install --cli codex --scope project failproofai policies --install --cli copilot --scope project failproofai policies --install --cli cursor --scope project - failproofai policies --install --cli claude codex copilot cursor + failproofai policies --install --cli pi --scope project + failproofai policies --install --cli claude codex copilot cursor pi failproofai policies --install --custom ./my-policies.js failproofai policies -i -c ./my-policies.js failproofai policies --uninstall block-sudo failproofai policies --uninstall --cli codex failproofai policies --uninstall --cli copilot failproofai policies --uninstall --cli cursor + failproofai policies --uninstall --cli pi failproofai policies -u failproofai policies --uninstall --custom `.trimStart()); @@ -255,7 +265,7 @@ EXAMPLES // --cli claude codex copilot // --cli claude --cli codex // Values are consumed greedily until the next flag or end of argv. - const VALID_CLIS = new Set(["claude", "codex", "copilot", "cursor"]); + const VALID_CLIS = new Set(["claude", "codex", "copilot", "cursor", "pi"]); const cliFlagValues = []; const cliConsumedIdxs = new Set(); const cliFlagIdxs = subArgs.map((a, i) => (a === "--cli" ? i : -1)).filter((i) => i >= 0); @@ -272,7 +282,7 @@ EXAMPLES consumed++; } if (consumed === 0) { - throw new CliError("Missing value(s) for --cli. Usage: --cli claude codex copilot cursor (or any subset)"); + throw new CliError("Missing value(s) for --cli. Usage: --cli claude codex copilot cursor pi (or any subset)"); } } @@ -335,7 +345,7 @@ EXAMPLES } // --cli accepts one or more space-separated values; same parser as install. - const VALID_CLIS = new Set(["claude", "codex", "copilot", "cursor"]); + const VALID_CLIS = new Set(["claude", "codex", "copilot", "cursor", "pi"]); const cliFlagValues = []; const cliConsumedIdxs = new Set(); const cliFlagIdxs = subArgs.map((a, i) => (a === "--cli" ? i : -1)).filter((i) => i >= 0); @@ -352,7 +362,7 @@ EXAMPLES consumed++; } if (consumed === 0) { - throw new CliError("Missing value(s) for --cli. Usage: --cli claude codex copilot cursor (or any subset)"); + throw new CliError("Missing value(s) for --cli. Usage: --cli claude codex copilot cursor pi (or any subset)"); } } diff --git a/docs/configuration.mdx b/docs/configuration.mdx index fd3f1b09..f7f87d23 100644 --- a/docs/configuration.mdx +++ b/docs/configuration.mdx @@ -196,18 +196,20 @@ The `policies --install` and `policies --uninstall` commands write to your agent - **OpenAI Codex**: `~/.codex/hooks.json` (user), `/.codex/hooks.json` (project) — Codex doesn't have a `local` scope - **GitHub Copilot CLI _(beta)_**: `~/.copilot/hooks/failproofai.json` (user), `/.github/hooks/failproofai.json` (project) — Copilot has no `local` scope. Hook entries use Copilot's OS-keyed `bash`/`powershell` command fields with `timeoutSec`; the file carries a top-level `version: 1` marker. Copilot CLI support is **beta** while we verify the `events.jsonl` record schema (which the public docs do not specify) against more real-world sessions. - **Cursor Agent _(beta)_**: `~/.cursor/hooks.json` (user), `/.cursor/hooks.json` (project) — Cursor has no `local` scope. Hook entries use the Claude-shaped `{type, command, timeout}` form (no `bash`/`powershell` split), but stored under camelCase event keys (`preToolUse`, `beforeSubmitPrompt`, …) in a flat array per Cursor's [hooks schema](https://cursor.com/docs/hooks); the file carries a top-level `version: 1` marker. The handler canonicalizes camelCase → PascalCase via `CURSOR_EVENT_MAP` so existing builtin policies fire unchanged. Cursor Agent support is **beta** while we verify Cursor's transcript on-disk format (not specified in the public docs) against more real-world installs. + - **Pi _(beta)_**: `~/.pi/agent/settings.json` (user), `/.pi/settings.json` (project) — Pi has no `local` scope. Pi loads TypeScript extension packages at startup; the settings file is a flat string array `{"packages": ["./relative/path", …]}`. failproofai writes a single packages-array entry pointing at its bundled `pi-extension/` directory. The extension internally subscribes to Pi's `tool_call` / `user_bash` / `input` / `session_start` events and shells out to `failproofai --hook --cli pi`; the handler canonicalizes underscore_lower_snake_case → PascalCase via `PI_EVENT_MAP` so existing builtin policies fire unchanged. Pi support is **beta** while Pi's extension API and session-log layout stabilize. - **`policies-config.json`** — tells failproofai which policies to evaluate and with what params (shared across all agent CLIs) -Pass `--cli claude|codex|copilot|cursor` to target a specific agent (space-separated or repeated for any subset): +Pass `--cli claude|codex|copilot|cursor|pi` to target a specific agent (space-separated or repeated for any subset): ```bash failproofai policies --install --cli codex --scope project failproofai policies --install --cli copilot --scope project failproofai policies --install --cli cursor --scope project -failproofai policies --install --cli claude codex copilot cursor +failproofai policies --install --cli pi --scope project +failproofai policies --install --cli claude codex copilot cursor pi ``` -When `--cli` is omitted, `failproofai` detects which agent CLIs are installed (`which claude` / `which codex` / `which copilot` / `which cursor-agent`): +When `--cli` is omitted, `failproofai` detects which agent CLIs are installed (`which claude` / `which codex` / `which copilot` / `which cursor-agent` / `which pi`): - **One CLI detected** — auto-selects that CLI without prompting. - **Multiple CLIs detected** in an interactive terminal — shows an arrow-key single-select prompt: when two CLIs are present the choices are `Both`, ` only`, ` only`; with three CLIs the first option becomes `All` (↑↓ to move, Enter to select, ^C to quit). diff --git a/docs/dashboard.mdx b/docs/dashboard.mdx index 22dc64e9..60a28015 100644 --- a/docs/dashboard.mdx +++ b/docs/dashboard.mdx @@ -24,11 +24,11 @@ The dashboard reads directly from the filesystem - your Claude Code project fold ### Projects -Lists all Claude Code, OpenAI Codex, GitHub Copilot CLI _(beta)_, and Cursor Agent _(beta)_ projects found on your machine. Claude projects are discovered from `~/.claude/projects/` (or the path set by `CLAUDE_PROJECTS_PATH`); Codex projects are discovered by scanning every transcript under `~/.codex/sessions///
/*.jsonl` and grouping by the `cwd` recorded in each session's first record; Copilot CLI projects are discovered by scanning each `~/.copilot/session-state//workspace.yaml` (configurable via `COPILOT_HOME`) and grouping by its `cwd` field; Cursor Agent projects are discovered by scanning per-session metadata under `~/.cursor/agent-sessions//` (configurable via `CURSOR_HOME`, with `conversations/` and `sessions/` probed as fallbacks) for a `cwd` scalar in `meta.json` / `session.json` / `workspace.yaml`. A project that has been used by multiple CLIs renders as a single row with all matching badges. Use the **CLI** dropdown above the table to filter by a specific agent CLI; the URL preserves your selection as `?cli=claude|codex|copilot|cursor`. +Lists all Claude Code, OpenAI Codex, GitHub Copilot CLI _(beta)_, Cursor Agent _(beta)_, and Pi _(beta)_ projects found on your machine. Claude projects are discovered from `~/.claude/projects/` (or the path set by `CLAUDE_PROJECTS_PATH`); Codex projects are discovered by scanning every transcript under `~/.codex/sessions///
/*.jsonl` and grouping by the `cwd` recorded in each session's first record; Copilot CLI projects are discovered by scanning each `~/.copilot/session-state//workspace.yaml` (configurable via `COPILOT_HOME`) and grouping by its `cwd` field; Cursor Agent projects are discovered by scanning per-session metadata under `~/.cursor/agent-sessions//` (configurable via `CURSOR_HOME`, with `conversations/` and `sessions/` probed as fallbacks) for a `cwd` scalar in `meta.json` / `session.json` / `workspace.yaml`; Pi projects are discovered by scanning per-session JSONL transcripts under `~/.pi/agent/sessions//_.jsonl` (configurable via `PI_SESSIONS_DIR`) and pulling the `cwd` from each session's first record. A project that has been used by multiple CLIs renders as a single row with all matching badges. Use the **CLI** dropdown above the table to filter by a specific agent CLI; the URL preserves your selection as `?cli=claude|codex|copilot|cursor|pi`. Each project shows: - Project name (derived from the folder path) -- A CLI badge — `Claude Code` (orange), `OpenAI Codex` (purple), `GitHub Copilot` (blue), and/or `Cursor Agent` (emerald) +- A CLI badge — `Claude Code` (orange), `OpenAI Codex` (purple), `GitHub Copilot` (blue), `Cursor Agent` (emerald), and/or `Pi` (pink) - Date of most recent session activity Click a project to see its sessions. @@ -47,7 +47,7 @@ Click a session to open the session viewer. ### Session viewer -The session viewer answers the key question for autonomous agents: what did the agent do, and did it stay on track? A CLI badge beside the header indicates whether the session is a Claude Code, OpenAI Codex, GitHub Copilot CLI, or Cursor Agent transcript. It shows a timeline of everything that happened in a session: +The session viewer answers the key question for autonomous agents: what did the agent do, and did it stay on track? A CLI badge beside the header indicates whether the session is a Claude Code, OpenAI Codex, GitHub Copilot CLI, Cursor Agent, or Pi transcript. It shows a timeline of everything that happened in a session: - **Messages** - Claude's text responses and user prompts - **Tool calls** - Every tool Claude invoked, with its input and output @@ -70,9 +70,9 @@ A two-tab page for managing policies and reviewing activity. - Full paginated history of every hook event that has fired across all sessions - - Filter by decision, event type, CLI (Claude Code / OpenAI Codex / GitHub Copilot _(beta)_ / Cursor Agent _(beta)_), policy name, or session ID - - Each row shows: timestamp, policy name, decision, CLI badge (orange = Claude Code, purple = OpenAI Codex, blue = GitHub Copilot, emerald = Cursor Agent), tool name, session ID, and the reason for deny/instruct decisions - - Click a session ID to open its transcript — the viewer auto-detects which CLI fired the hook (Claude `~/.claude/projects/…`, Codex `~/.codex/sessions/…`, Copilot CLI `~/.copilot/session-state//events.jsonl`, Cursor Agent `~/.cursor/agent-sessions//events.jsonl`) and renders the matching CLI badge in the header + - Filter by decision, event type, CLI (Claude Code / OpenAI Codex / GitHub Copilot _(beta)_ / Cursor Agent _(beta)_ / Pi _(beta)_), policy name, or session ID + - Each row shows: timestamp, policy name, decision, CLI badge (orange = Claude Code, purple = OpenAI Codex, blue = GitHub Copilot, emerald = Cursor Agent, pink = Pi), tool name, session ID, and the reason for deny/instruct decisions + - Click a session ID to open its transcript — the viewer auto-detects which CLI fired the hook (Claude `~/.claude/projects/…`, Codex `~/.codex/sessions/…`, Copilot CLI `~/.copilot/session-state//events.jsonl`, Cursor Agent `~/.cursor/agent-sessions//events.jsonl`, Pi `~/.pi/agent/sessions//.jsonl`) and renders the matching CLI badge in the header diff --git a/docs/getting-started.mdx b/docs/getting-started.mdx index 5633a4b5..82a5f285 100644 --- a/docs/getting-started.mdx +++ b/docs/getting-started.mdx @@ -37,15 +37,16 @@ bun add -g failproofai failproofai policies --install ``` - This writes hook entries into your installed agent CLIs (Claude Code's `~/.claude/settings.json`, OpenAI Codex's `~/.codex/hooks.json`, GitHub Copilot CLI's `~/.copilot/hooks/failproofai.json`, or Cursor Agent's `~/.cursor/hooks.json`). When more than one is present you'll be prompted; pass `--cli claude codex copilot cursor` (any subset) to skip the prompt. + This writes hook entries into your installed agent CLIs (Claude Code's `~/.claude/settings.json`, OpenAI Codex's `~/.codex/hooks.json`, GitHub Copilot CLI's `~/.copilot/hooks/failproofai.json`, Cursor Agent's `~/.cursor/hooks.json`, or Pi's `~/.pi/agent/settings.json`). When more than one is present you'll be prompted; pass `--cli claude codex copilot cursor pi` (any subset) to skip the prompt. - GitHub Copilot CLI and Cursor Agent support are **beta** — install with `--cli copilot` or `--cli cursor`. + GitHub Copilot CLI, Cursor Agent, and Pi support are **beta** — install with `--cli copilot`, `--cli cursor`, or `--cli pi`. ```bash failproofai policies --install --scope project failproofai policies --install --cli codex --scope project failproofai policies --install --cli copilot --scope project failproofai policies --install --cli cursor --scope project + failproofai policies --install --cli pi --scope project failproofai policies --install block-sudo block-rm-rf sanitize-api-keys ``` diff --git a/lib/cli-registry.ts b/lib/cli-registry.ts index ea42dd7f..474dbf73 100644 --- a/lib/cli-registry.ts +++ b/lib/cli-registry.ts @@ -25,7 +25,7 @@ import type { IntegrationType } from "@/src/hooks/types"; /** Canonical CLI ids the registry knows about. Mirrors `INTEGRATION_TYPES`. */ -export const KNOWN_CLI_IDS = ["claude", "codex", "copilot", "cursor"] as const satisfies readonly IntegrationType[]; +export const KNOWN_CLI_IDS = ["claude", "codex", "copilot", "cursor", "pi"] as const satisfies readonly IntegrationType[]; export type CliId = (typeof KNOWN_CLI_IDS)[number]; /** Per-CLI metadata consumed by the dashboard. */ @@ -57,6 +57,11 @@ const CLI_ENTRIES: Record = { label: "Cursor Agent", badgeClasses: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", }, + pi: { + id: "pi", + label: "Pi", + badgeClasses: "bg-pink-500/10 text-pink-400 border-pink-500/20", + }, }; export function getCliEntry(id: string): CliEntry | undefined { diff --git a/lib/pi-projects.ts b/lib/pi-projects.ts new file mode 100644 index 00000000..bf3bda60 --- /dev/null +++ b/lib/pi-projects.ts @@ -0,0 +1,222 @@ +/** + * Pi (pi-coding-agent) project discovery. + * + * Empirically verified against pi-coding-agent v0.71.1 (Phase 0.7 of plan): + * + * • Session-state root: `~/.pi/agent/sessions/` (NOT `~/.pi/sessions/`). + * • Per-cwd subdirs are encoded with `/` → `-` and wrapped in `--…--`, + * e.g. `/home/u/repo` → `--home-u-repo--`. The encoding is LOSSY + * (literal `-` in the path is preserved as `-` and indistinguishable + * from a path separator), so we never use the dir name as the canonical + * cwd — we read the first JSONL record (`{type: "session", cwd, ...}`) + * to recover it exactly. + * • Per-session file: `_.jsonl`, e.g. + * `2026-05-01T20-36-22-628Z_019de541-b7e3-7131-abac-d15df780042c.jsonl`. + * • File format: JSONL. First record always shape + * `{type: "session", version, id, timestamp, cwd}`. + * + * As with Cursor/Copilot, this module is intentionally permissive — a + * missing `~/.pi/` returns `[]`, and a malformed JSONL falls open without + * surfacing the session. + */ +import { readdir, readFile, stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { encodeFolderName } from "./paths"; +import type { ProjectFolder, SessionFile } from "./projects"; +import { runtimeCache } from "./runtime-cache"; +import { batchAll } from "./concurrency"; +import { formatDate } from "./format-date"; +import { logWarn } from "./logger"; + +/** Filename pattern for a Pi session JSONL: `_.jsonl`. */ +const SESSION_FILE_RE = /^[\d-]+T[\d-]+Z_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i; + +function getPiSessionsRoot(): string { + return process.env.PI_SESSIONS_DIR + || join(homedir(), ".pi", "agent", "sessions"); +} + +interface PiSessionMeta { + filePath: string; + sessionId: string; + cwd: string; + fileMtime: Date; +} + +async function safeReaddir(dir: string) { + try { + return await readdir(dir, { withFileTypes: true }); + } catch { + return null; + } +} + +async function statMtime(path: string): Promise { + try { + return (await stat(path)).mtime; + } catch { + return null; + } +} + +/** Reads the first newline-terminated record of a Pi JSONL file and returns + * its `cwd` field. Returns null on read/parse failure or when the first + * record isn't `{type: "session"}`. */ +async function readSessionCwd(filePath: string): Promise { + let text: string; + try { + text = await readFile(filePath, "utf-8"); + } catch { + return null; + } + const firstLine = text.indexOf("\n") >= 0 ? text.slice(0, text.indexOf("\n")) : text; + if (!firstLine) return null; + try { + const parsed = JSON.parse(firstLine) as { type?: unknown; cwd?: unknown }; + if (parsed.type !== "session") return null; + if (typeof parsed.cwd !== "string" || parsed.cwd.length === 0) return null; + return parsed.cwd; + } catch { + return null; + } +} + +async function scanPiSessions(): Promise { + const root = getPiSessionsRoot(); + const cwdDirs = await safeReaddir(root); + if (!cwdDirs) return []; + + const candidates: { sessionId: string; filePath: string }[] = []; + for (const cwdDir of cwdDirs) { + if (!cwdDir.isDirectory()) continue; + const cwdPath = join(root, cwdDir.name); + const sessionFiles = await safeReaddir(cwdPath); + if (!sessionFiles) continue; + for (const f of sessionFiles) { + if (!f.isFile()) continue; + const m = SESSION_FILE_RE.exec(f.name); + if (!m) continue; + candidates.push({ sessionId: m[1], filePath: join(cwdPath, f.name) }); + } + } + if (candidates.length === 0) return []; + + const settled = await batchAll( + candidates.map((c) => async (): Promise => { + const cwd = await readSessionCwd(c.filePath); + if (!cwd) return null; + const mtime = await statMtime(c.filePath); + if (!mtime) return null; + return { + filePath: c.filePath, + sessionId: c.sessionId, + cwd, + fileMtime: mtime, + }; + }), + 16, + ); + return settled + .filter((r): r is PromiseFulfilledResult => r.status === "fulfilled") + .map((r) => r.value) + .filter((v): v is PiSessionMeta => v !== null); +} + +const cachedScan = runtimeCache(scanPiSessions, 30); + +/** Returns one ProjectFolder per unique cwd discovered in Pi transcripts. */ +export async function getPiProjects(): Promise { + let metas: PiSessionMeta[]; + try { + metas = await cachedScan(); + } catch (error) { + logWarn("Failed to scan Pi sessions:", error); + return []; + } + + const byCwd = new Map(); + for (const m of metas) { + const existing = byCwd.get(m.cwd); + if (!existing || m.fileMtime.getTime() > existing.latest.getTime()) { + byCwd.set(m.cwd, { latest: m.fileMtime, cwd: m.cwd }); + } + } + + const folders: ProjectFolder[] = []; + for (const { cwd, latest } of byCwd.values()) { + folders.push({ + name: encodeFolderName(cwd), + path: cwd, + isDirectory: true, + lastModified: latest, + lastModifiedFormatted: formatDate(latest), + cli: ["pi"], + }); + } + folders.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); + return folders; +} + +function metasToSessionFiles(metas: PiSessionMeta[]): SessionFile[] { + const files: SessionFile[] = metas.map((m) => ({ + name: m.sessionId, + path: m.filePath, + lastModified: m.fileMtime, + lastModifiedFormatted: formatDate(m.fileMtime), + sessionId: m.sessionId, + cli: "pi", + })); + files.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); + return files; +} + +/** Returns SessionFile entries for every Pi transcript whose cwd matches `cwd` exactly. */ +export async function getPiSessionsForCwd(cwd: string): Promise { + let metas: PiSessionMeta[]; + try { + metas = await cachedScan(); + } catch (error) { + logWarn("Failed to scan Pi sessions:", error); + return []; + } + return metasToSessionFiles(metas.filter((m) => m.cwd === cwd)); +} + +export interface PiProjectByName { + cwd: string | null; + sessions: SessionFile[]; +} + +/** + * Looks up Pi sessions for a project URL slug. `decodeFolderName` is lossy on + * cwds containing `-`, so we re-encode each session's cwd via + * `encodeFolderName` and match in that direction. Returns both the canonical + * cwd and the matching sessions. + */ +export async function getPiSessionsByEncodedName(name: string): Promise { + let metas: PiSessionMeta[]; + try { + metas = await cachedScan(); + } catch (error) { + logWarn("Failed to scan Pi sessions:", error); + return { cwd: null, sessions: [] }; + } + const matches = metas.filter((m) => encodeFolderName(m.cwd) === name); + return { + cwd: matches[0]?.cwd ?? null, + sessions: metasToSessionFiles(matches), + }; +} + +export const getCachedPiProjects = runtimeCache(getPiProjects, 30); +export const getCachedPiSessionsForCwd = runtimeCache( + (cwd: string) => getPiSessionsForCwd(cwd), + 30, + { maxSize: 50 }, +); +export const getCachedPiSessionsByEncodedName = runtimeCache( + (name: string) => getPiSessionsByEncodedName(name), + 30, + { maxSize: 50 }, +); diff --git a/lib/pi-sessions.ts b/lib/pi-sessions.ts new file mode 100644 index 00000000..56f513dd --- /dev/null +++ b/lib/pi-sessions.ts @@ -0,0 +1,322 @@ +/** + * Pi (pi-coding-agent) session transcript discovery + JSONL parser. + * + * Empirically verified against pi-coding-agent v0.71.1 (Phase 0.7 of plan): + * + * Session files live at + * `~/.pi/agent/sessions//_.jsonl` + * where `` wraps `--`-prefixed-and-suffixed `/`-separated paths + * (e.g. `/home/user/repo` → `--home-user-repo--`). The encoding is lossy + * (literal `-` is preserved); we use the `cwd` field of the first JSONL + * record (`{type: "session", cwd, …}`) as the canonical cwd. + * + * Record schema (observed): + * {type: "session", version, id, timestamp, cwd} + * {type: "model_change", id, parentId, timestamp, provider, modelId} + * {type: "thinking_level_change", id, parentId, timestamp, thinkingLevel} + * {type: "message", id, parentId, timestamp, + * message: {role, content[], timestamp}} + * + * `message.content[]` items can be `{type: "text", text}` or + * `{type: "thinking", thinking, thinkingSignature}`. Tool-call blocks are not + * yet observed in this codebase (no tool-using runs were captured during + * Phase 0); when Pi does emit them, this parser preserves them as-is via the + * fallback "system" branch and the test suite asserts at least the + * round-trip rather than a specific shape. + */ +import { readFileSync, readdirSync, existsSync, statSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { join, resolve, sep } from "node:path"; +import { homedir } from "node:os"; +import { runtimeCache } from "./runtime-cache"; +import { + baseEntry, + formatTimestamp, + type LogEntry, + type UserEntry, + type AssistantEntry, + type GenericEntry, + type QueueOperationEntry, + type ContentBlock, + type LogSource, +} from "./log-entries"; + +// ── Paths ── + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const SESSION_FILE_RE = /^[\d-]+T[\d-]+Z_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i; + +/** Root directory for Pi session state, honoring PI_SESSIONS_DIR. */ +export function getPiSessionStateRoot(): string { + return process.env.PI_SESSIONS_DIR + || join(homedir(), ".pi", "agent", "sessions"); +} + +/** Reject a sessionId that isn't a UUID — defends against path traversal. */ +function isSafeSessionId(sessionId: string): boolean { + return UUID_RE.test(sessionId); +} + +/** Find the JSONL transcript for `sessionId` by walking each per-cwd subdir + * of the session-state root. Rejects path-traversal sessionIds and verifies + * the resolved path stays under the root. Returns null on miss. */ +export function findPiTranscript(sessionId: string): string | null { + if (!isSafeSessionId(sessionId)) return null; + const root = resolve(getPiSessionStateRoot()); + + let cwdDirs: string[]; + try { + cwdDirs = readdirSync(root); + } catch { + return null; + } + + for (const cwdDir of cwdDirs) { + const cwdPath = resolve(root, cwdDir); + if (!cwdPath.startsWith(`${root}${sep}`)) continue; + let files: string[]; + try { + files = readdirSync(cwdPath); + } catch { + continue; + } + for (const f of files) { + const m = SESSION_FILE_RE.exec(f); + if (!m || m[1].toLowerCase() !== sessionId.toLowerCase()) continue; + const candidate = resolve(cwdPath, f); + if (!candidate.startsWith(`${cwdPath}${sep}`)) continue; + if (existsSync(candidate)) return candidate; + } + } + return null; +} + +// ── Parser ── + +interface PiSessionRecord { + type?: string; + id?: string; + parentId?: string | null; + timestamp?: string; + cwd?: string; + version?: number; + provider?: string; + modelId?: string; + thinkingLevel?: string; + message?: { + role?: string; + content?: Array>; + timestamp?: number; + }; +} + +interface PiParseResult { + entries: LogEntry[]; + rawLines: Record[]; + /** Working directory pulled from the first session record, when available. */ + cwd?: string; +} + +/** Extract a plain-text summary of a Pi message content block. */ +function extractMessageText(content: Array> | undefined): string { + if (!Array.isArray(content)) return ""; + for (const block of content) { + if (block?.type === "text" && typeof block.text === "string") return block.text; + } + return ""; +} + +/** Build a list of ContentBlocks for the assistant entry, preserving text and + * thinking blocks. Skips blocks with non-string payloads (typeof guards). */ +function buildAssistantContent(content: Array> | undefined): ContentBlock[] { + if (!Array.isArray(content)) return []; + const blocks: ContentBlock[] = []; + for (const block of content) { + if (block?.type === "text" && typeof block.text === "string" && block.text.length > 0) { + blocks.push({ type: "text", text: block.text }); + } + // Pi's "thinking" blocks aren't a first-class entry type in our LogEntry + // hierarchy; embed as a text block prefixed for clarity. + if (block?.type === "thinking" && typeof block.thinking === "string" && block.thinking.length > 0) { + blocks.push({ type: "text", text: `[thinking] ${block.thinking}` }); + } + } + return blocks; +} + +/** + * Parse a Pi JSONL transcript into `LogEntry[]` plus the raw lines. + * Yields to the event loop every 200 lines so big transcripts don't block + * the request. + */ +export async function parsePiLog( + fileContent: string, + source: LogSource = "session", +): Promise { + const lines = fileContent.split("\n").filter((line) => line.trim() !== ""); + const entries: LogEntry[] = []; + const rawLines: Record[] = []; + let cwd: string | undefined; + let seenSessionStart = false; + + for (let i = 0; i < lines.length; i++) { + if (i > 0 && i % 200 === 0) await new Promise((r) => setImmediate(r)); + + const line = lines[i]; + let raw: PiSessionRecord; + try { + raw = JSON.parse(line) as PiSessionRecord; + } catch { + continue; + } + + const rawCopy = { ...(raw as Record), _source: source }; + rawLines.push(rawCopy); + + const timestampStr = raw.timestamp; + if (!timestampStr) continue; + const date = new Date(timestampStr); + if (Number.isNaN(date.getTime())) continue; + const timestamp = date.toISOString(); + + const recType = raw.type; + + // Pi's first record per session is `{type: "session", cwd, ...}`. + if (recType === "session") { + if (typeof raw.cwd === "string" && !cwd) cwd = raw.cwd; + const label: QueueOperationEntry["label"] = seenSessionStart ? "Session Resumed" : "Session Started"; + seenSessionStart = true; + entries.push({ + type: "queue-operation", + ...baseEntry(rawCopy, timestamp, date, source), + label, + } satisfies QueueOperationEntry); + continue; + } + + // Pi messages are `{type: "message", message: {role, content[]}}`. Branch + // on role; render text/thinking content. Validate types defensively. + if (recType === "message" && raw.message && typeof raw.message === "object") { + const role = raw.message.role; + const content = raw.message.content; + + if (role === "user") { + const text = extractMessageText(content); + if (!text) continue; + entries.push({ + type: "user", + ...baseEntry(rawCopy, timestamp, date, source), + message: { role: "user", content: text }, + } satisfies UserEntry); + continue; + } + + if (role === "assistant") { + const blocks = buildAssistantContent(content); + if (blocks.length === 0) { + entries.push({ + type: "system", + ...baseEntry(rawCopy, timestamp, date, source), + raw: rawCopy, + } satisfies GenericEntry); + continue; + } + entries.push({ + type: "assistant", + ...baseEntry(rawCopy, timestamp, date, source), + message: { role: "assistant", content: blocks }, + } satisfies AssistantEntry); + continue; + } + + // Unknown role — preserve raw so nothing is dropped. + entries.push({ + type: "system", + ...baseEntry(rawCopy, timestamp, date, source), + raw: rawCopy, + } satisfies GenericEntry); + continue; + } + + // model_change / thinking_level_change / unknown — preserve raw as system + // so the dashboard can surface them without ad-hoc renderers. + entries.push({ + type: "system", + ...baseEntry(rawCopy, timestamp, date, source), + raw: rawCopy, + } satisfies GenericEntry); + } + + if (entries.length > 500) await new Promise((r) => setImmediate(r)); + entries.sort((a, b) => a.timestampMs - b.timestampMs); + + return { entries, rawLines, cwd }; +} + +// ── Public loader ── + +export interface PiSessionLogData { + entries: LogEntry[]; + rawLines: Record[]; + cwd?: string; + filePath: string; +} + +export async function getPiSessionLog(sessionId: string): Promise { + const filePath = findPiTranscript(sessionId); + if (!filePath) return null; + let fileContent: string; + try { + fileContent = await readFile(filePath, "utf-8"); + } catch { + // The file vanished between findPiTranscript and read — fall open. + return null; + } + let parsed: PiParseResult; + try { + parsed = await parsePiLog(fileContent, "session"); + } catch { + return null; + } + return { + entries: parsed.entries, + rawLines: parsed.rawLines, + cwd: parsed.cwd, + filePath, + }; +} + +export const getCachedPiSessionLog = runtimeCache( + (sessionId: string) => getPiSessionLog(sessionId), + 60, + { maxSize: 50 }, +); + +// ── Test helpers ── + +/** For tests: read raw stat of the transcript path, returning null on miss. */ +export function _statPiTranscript(sessionId: string): { mtimeMs: number } | null { + const path = findPiTranscript(sessionId); + if (!path) return null; + try { + const s = statSync(path); + return { mtimeMs: s.mtimeMs }; + } catch { + return null; + } +} + +/** For tests: read transcript synchronously. Returns null on missing/error. */ +export function readPiTranscriptSync(sessionId: string): string | null { + const path = findPiTranscript(sessionId); + if (!path) return null; + try { + return readFileSync(path, "utf-8"); + } catch { + return null; + } +} + +/** Suppress unused-import warning for formatTimestamp; reserved for tool-call + * rendering once Pi emits it (see header comment). */ +void formatTimestamp; diff --git a/lib/projects.ts b/lib/projects.ts index 89860132..f311a010 100644 --- a/lib/projects.ts +++ b/lib/projects.ts @@ -16,7 +16,7 @@ import { formatDate } from "./format-date"; export const UUID_RE = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/; export const PATH_TRAVERSAL_RE = /(^|[\\/])\.\.($|[\\/])/; -export type ProjectCli = "claude" | "codex" | "copilot" | "cursor"; +export type ProjectCli = "claude" | "codex" | "copilot" | "cursor" | "pi"; export interface ProjectFolder { name: string; @@ -132,12 +132,18 @@ function mergeProjectFolders(...sources: ProjectFolder[][]): ProjectFolder[] { export async function getProjectFolders(): Promise { // Lazy imports keep the per-CLI project providers out of the dep graph for // callers that only need Claude helpers (e.g. CLI codepaths). - const [{ getCodexProjects }, { getCopilotProjects }, { getCursorProjects }] = await Promise.all([ + const [ + { getCodexProjects }, + { getCopilotProjects }, + { getCursorProjects }, + { getPiProjects }, + ] = await Promise.all([ import("./codex-projects"), import("./copilot-projects"), import("./cursor-projects"), + import("./pi-projects"), ]); - const [claude, codex, copilot, cursor] = await Promise.all([ + const [claude, codex, copilot, cursor, pi] = await Promise.all([ getClaudeProjectFolders(), getCodexProjects().catch((error) => { logError("Error reading Codex projects:", error); @@ -151,8 +157,12 @@ export async function getProjectFolders(): Promise { logError("Error reading Cursor projects:", error); return [] as ProjectFolder[]; }), + getPiProjects().catch((error) => { + logError("Error reading Pi projects:", error); + return [] as ProjectFolder[]; + }), ]); - return mergeProjectFolders(claude, codex, copilot, cursor); + return mergeProjectFolders(claude, codex, copilot, cursor, pi); } /** diff --git a/package.json b/package.json index 74da32ef..1ddfe5a8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "failproofai", - "version": "0.0.10-beta.1", + "version": "0.0.10-beta.2", "description": "The easiest way to manage policies that keep your AI agents reliable, on-task, and running autonomously — for Claude Code & the Agents SDK", "bin": { "failproofai": "./dist/cli.mjs" @@ -10,6 +10,7 @@ "src/", "scripts/", "lib/", + "pi-extension/", ".next/standalone/", "dist/", "README.md" diff --git a/pi-extension/index.ts b/pi-extension/index.ts new file mode 100644 index 00000000..ed4a9d94 --- /dev/null +++ b/pi-extension/index.ts @@ -0,0 +1,203 @@ +/** + * failproofai policy bridge for Pi (pi-coding-agent). + * + * This extension is loaded by Pi at startup and registered via + * `pi install [-l]` (or by hand-authoring an entry in + * `/.pi/settings.json`). It subscribes to Pi's `tool_call`, `user_bash`, + * `input`, and `session_start` events and forwards them to the failproofai + * binary as `failproofai --hook --cli pi`. failproofai prints a + * decision JSON to stdout; this shim parses it and translates into Pi's + * `{ block: true, reason }` return shape so policy `deny` decisions cancel + * tool execution. + * + * Marker comment for failproofai's installer detection (do not remove): + * __failproofai_hook__: true + * + * Binary resolution. failproofai ships two entrypoints: + * • dist/cli.mjs — bundled, node-compatible (production npm install) + * • bin/failproofai.mjs — source, requires `bun` (dev / monorepo) + * + * dist/cli.mjs is preferred because spawning `node bin/failproofai.mjs` + * fails with ERR_IMPORT_ATTRIBUTE_MISSING (the source `import package.json` + * needs `with { type: "json" }` under node, which bun handles transparently + * but the build:cli step transpiles away in dist/cli.mjs). When dist/cli.mjs + * isn't present, fall back to running bin/failproofai.mjs with `bun`. Pi + * spawns extensions with an undefined cwd contract, so paths are resolved + * relative to this file via `import.meta.url`, NOT process.cwd(). + */ +import { spawnSync } from "node:child_process"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { existsSync } from "node:fs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const DIST_BIN = resolve(HERE, "..", "dist", "cli.mjs"); +const SRC_BIN = resolve(HERE, "..", "bin", "failproofai.mjs"); +// Prefer the bundled dist/cli.mjs (node-compatible); fall back to source + +// bun for dev workflows where dist/ hasn't been built yet. +function resolveSpawn(): { cmd: string; args: string[] } { + if (process.env.FAILPROOFAI_BINARY_OVERRIDE) { + return { cmd: "node", args: [process.env.FAILPROOFAI_BINARY_OVERRIDE] }; + } + if (existsSync(DIST_BIN)) { + return { cmd: "node", args: [DIST_BIN] }; + } + return { cmd: "bun", args: [SRC_BIN] }; +} + +interface PolicyDecision { + permission?: "allow" | "deny"; + reason?: string; +} + +/** + * Spawn `failproofai --hook --cli pi`, write the JSON payload to + * stdin, and parse the flat `{permission, reason}` JSON we expect failproofai + * to print on stdout. Fail-open on any subprocess / parse error. + */ +/** Optional stderr trace for debugging the shim. Enabled with + * FAILPROOFAI_PI_DEBUG=1; silent otherwise. */ +function debug(msg: string): void { + if (process.env.FAILPROOFAI_PI_DEBUG === "1") { + process.stderr.write(`[failproofai-pi-shim] ${msg}\n`); + } +} + +function callPolicy(eventName: string, payload: unknown): { block: boolean; reason: string } { + const { cmd, args } = resolveSpawn(); + debug(`callPolicy event=${eventName} cmd=${cmd}`); + try { + const result = spawnSync( + cmd, + [...args, "--hook", eventName, "--cli", "pi"], + { + input: JSON.stringify(payload), + encoding: "utf8", + timeout: 60_000, + }, + ); + if (result.status !== 0) return { block: false, reason: "" }; + const stdout = (result.stdout || "").trim(); + if (!stdout) return { block: false, reason: "" }; + const parsed = JSON.parse(stdout) as PolicyDecision; + if (parsed.permission === "deny") { + debug(`DENY reason=${parsed.reason}`); + return { block: true, reason: parsed.reason ?? "Blocked by failproofai" }; + } + } catch (err) { + debug(`EXCEPTION ${err instanceof Error ? err.message : String(err)}`); + // Fail-open: never block tool execution because of an infra failure. + } + return { block: false, reason: "" }; +} + +interface PiToolCallEvent { + type?: string; + toolName?: string; + toolCallId?: string; + input?: Record; + cwd?: string; + sessionId?: string; +} + +/** + * Pi emits tool names in lowercase (`bash`, `read`, `edit`, `write`). + * failproofai's builtin policies match on Claude-shaped capitalized names + * (`Bash`, `Read`, `Edit`, `Write`). Map between the two so existing + * tool-name match clauses fire on Pi sessions. + */ +function canonicalizeToolName(piToolName: string | undefined): string | undefined { + if (!piToolName) return undefined; + return piToolName.charAt(0).toUpperCase() + piToolName.slice(1); +} + +/** Resolve the cwd for the policy payload. Pi events don't include cwd, so + * fall back to the extension's process.cwd() — which is where Pi was + * launched and where `.failproofai/` config lives. */ +function resolveCwd(eventCwd: string | undefined): string { + return eventCwd ?? process.cwd(); +} + +interface PiUserBashEvent { + type?: string; + command?: string; + cwd?: string; + sessionId?: string; +} + +interface PiInputEvent { + type?: string; + text?: string; + source?: string; + cwd?: string; + sessionId?: string; +} + +interface PiSessionStartEvent { + type?: string; + reason?: string; + cwd?: string; + sessionId?: string; +} + +interface PiExtensionApi { + on(event: string, handler: (event: unknown) => unknown): void; +} + +export default function failproofaiBridge(pi: PiExtensionApi) { + // tool_call → PreToolUse. Block tool execution when failproofai denies. + pi.on("tool_call", (event: unknown): unknown => { + const e = event as PiToolCallEvent; + const decision = callPolicy("tool_call", { + tool_name: canonicalizeToolName(e.toolName), + tool_input: e.input, + session_id: e.sessionId, + cwd: resolveCwd(e.cwd), + hook_event_name: "PreToolUse", + }); + if (decision.block) return { block: true, reason: decision.reason }; + return undefined; + }); + + // user_bash → PreToolUse with synthesized toolName=Bash. + pi.on("user_bash", (event: unknown): unknown => { + const e = event as PiUserBashEvent; + const decision = callPolicy("user_bash", { + tool_name: "Bash", + tool_input: { command: e.command }, + session_id: e.sessionId, + cwd: resolveCwd(e.cwd), + hook_event_name: "PreToolUse", + }); + if (decision.block) return { block: true, reason: decision.reason }; + return undefined; + }); + + // input → UserPromptSubmit. Honor block decisions if Pi accepts them + // (Pi's docs describe block on input but it's not exhaustively tested). + pi.on("input", (event: unknown): unknown => { + const e = event as PiInputEvent; + const decision = callPolicy("input", { + prompt: e.text, + session_id: e.sessionId, + cwd: resolveCwd(e.cwd), + hook_event_name: "UserPromptSubmit", + }); + if (decision.block) return { block: true, reason: decision.reason }; + return undefined; + }); + + // session_start → SessionStart. Observe-only; we still forward so the + // activity feed records the session and any UserPromptSubmit policies that + // need session_id continuity see the metadata. + pi.on("session_start", (event: unknown): unknown => { + const e = event as PiSessionStartEvent; + callPolicy("session_start", { + session_id: e.sessionId, + cwd: resolveCwd(e.cwd), + reason: e.reason, + hook_event_name: "SessionStart", + }); + return undefined; + }); +} diff --git a/pi-extension/package.json b/pi-extension/package.json new file mode 100644 index 00000000..a38bab01 --- /dev/null +++ b/pi-extension/package.json @@ -0,0 +1,12 @@ +{ + "name": "@failproofai/pi-extension", + "version": "0.0.1", + "description": "failproofai policy bridge for Pi (pi-coding-agent)", + "type": "module", + "main": "./index.ts", + "private": true, + "keywords": [ + "pi-extension", + "failproofai" + ] +} diff --git a/src/hooks/builtin-policies.ts b/src/hooks/builtin-policies.ts index 860efa67..b93a0394 100644 --- a/src/hooks/builtin-policies.ts +++ b/src/hooks/builtin-policies.ts @@ -12,11 +12,11 @@ import { hookLogWarn } from "./hook-logger"; /** * Whether `resolved` lives under an agent CLI's home directory - * (~/.claude/, ~/.codex/, ~/.copilot/, or ~/.cursor/). Used to whitelist + * (~/.claude/, ~/.codex/, ~/.copilot/, ~/.cursor/, or ~/.pi/). Used to whitelist * agent self-reads of their own config and transcripts. */ function isAgentInternalPath(resolved: string): boolean { - for (const dir of [".claude", ".codex", ".copilot", ".cursor"]) { + for (const dir of [".claude", ".codex", ".copilot", ".cursor", ".pi"]) { const root = join(homedir(), dir); if (resolved === root || resolved.startsWith(root + "/")) return true; } @@ -29,6 +29,9 @@ function isAgentInternalPath(resolved: string): boolean { * • Codex: `.codex/hooks.json` * • Copilot CLI: `.copilot/hooks/*.json`, `.github/hooks/*.json` * • Cursor Agent: `.cursor/hooks.json` + * • Pi: `.pi/settings.json` (project) and `.pi/agent/settings.json` + * (user); also the Pi-managed extension dir + * `.pi/extensions/` / `.pi/agent/extensions/`. * These must NEVER be edited by the agent itself — that would let it disable * its own protections. */ @@ -38,6 +41,8 @@ function isAgentSettingsFile(resolved: string): boolean { if (/[\\/]\.copilot[\\/]hooks[\\/][^/\\]+\.json$/.test(resolved)) return true; if (/[\\/]\.github[\\/]hooks[\\/][^/\\]+\.json$/.test(resolved)) return true; if (/[\\/]\.cursor[\\/]hooks\.json$/.test(resolved)) return true; + if (/[\\/]\.pi[\\/](?:agent[\\/])?settings\.json$/.test(resolved)) return true; + if (/[\\/]\.pi[\\/](?:agent[\\/])?extensions[\\/]/.test(resolved)) return true; return false; } diff --git a/src/hooks/handler.ts b/src/hooks/handler.ts index 09e6427d..bf873996 100644 --- a/src/hooks/handler.ts +++ b/src/hooks/handler.ts @@ -11,8 +11,9 @@ import type { SessionMetadata, CodexHookEventType, CursorHookEventType, + PiHookEventType, } from "./types"; -import { CODEX_EVENT_MAP, CURSOR_EVENT_MAP } from "./types"; +import { CODEX_EVENT_MAP, CURSOR_EVENT_MAP, PI_EVENT_MAP } from "./types"; import type { PolicyFunction, PolicyResult } from "./policy-types"; import { readMergedHooksConfig } from "./hooks-config"; import { registerBuiltinPolicies } from "./builtin-policies"; @@ -29,7 +30,8 @@ import { hookLogInfo, hookLogWarn } from "./hook-logger"; /** * Canonicalize an event name to PascalCase. Codex sends snake_case event names * on stdin and as the --hook arg; Cursor sends camelCase (`preToolUse`, - * `beforeSubmitPrompt`); Claude Code sends PascalCase. Copilot CLI is installed + * `beforeSubmitPrompt`); Pi sends underscore_lower_snake_case (`tool_call`, + * `session_start`); Claude Code sends PascalCase. Copilot CLI is installed * in "VS Code compatible" PascalCase mode (see integrations.ts), so its events * arrive PascalCase already. The internal registry, builtin policies, and * policy.match.events all key on PascalCase. @@ -43,6 +45,10 @@ function canonicalizeEventType(raw: string, cli: IntegrationType): HookEventType const mapped = CURSOR_EVENT_MAP[raw as CursorHookEventType]; if (mapped) return mapped; } + if (cli === "pi") { + const mapped = PI_EVENT_MAP[raw as PiHookEventType]; + if (mapped) return mapped; + } // claude / copilot / unknown — already PascalCase, pass through. // HOOK_EVENT_TYPES type-checks downstream. return raw as HookEventType; diff --git a/src/hooks/install-prompt.ts b/src/hooks/install-prompt.ts index a266785e..59d6e096 100644 --- a/src/hooks/install-prompt.ts +++ b/src/hooks/install-prompt.ts @@ -60,13 +60,13 @@ export async function resolveTargetClis( // Uninstall flow: no agent CLIs detected — nothing to remove from. Default to // claude so removeHooks operates over Claude's scopes (no-op if no settings file). console.log( - "\x1B[33mWarning: no agent CLI binary found in PATH (claude, codex, copilot, cursor-agent). " + + "\x1B[33mWarning: no agent CLI binary found in PATH (claude, codex, copilot, cursor-agent, pi). " + "Defaulting to Claude Code; nothing will be removed if no settings file exists.\x1B[0m", ); return ["claude"]; } console.log( - "\x1B[33mWarning: no agent CLI binary found in PATH (claude, codex, copilot, cursor-agent). " + + "\x1B[33mWarning: no agent CLI binary found in PATH (claude, codex, copilot, cursor-agent, pi). " + "Defaulting to Claude Code; hooks will activate when an agent is installed.\x1B[0m", ); return ["claude"]; diff --git a/src/hooks/integrations.ts b/src/hooks/integrations.ts index 351e613e..b8d198a0 100644 --- a/src/hooks/integrations.ts +++ b/src/hooks/integrations.ts @@ -9,6 +9,7 @@ import { execSync } from "node:child_process"; import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; import { homedir } from "node:os"; import { HOOK_EVENT_TYPES, @@ -20,6 +21,8 @@ import { COPILOT_HOOK_SCOPES, CURSOR_HOOK_EVENT_TYPES, CURSOR_HOOK_SCOPES, + PI_HOOK_EVENT_TYPES, + PI_HOOK_SCOPES, FAILPROOFAI_HOOK_MARKER, INTEGRATION_TYPES, type IntegrationType, @@ -645,6 +648,188 @@ export const cursor: Integration = { }, }; +// ── Pi (pi-coding-agent) integration ─────────────────────────────────────── +// +// Pi loads TypeScript extension packages registered in `.pi/settings.json`. +// Schema (verified empirically against pi-coding-agent v0.71.1): +// +// {"packages": ["./relative/path", "/abs/path", "npm:@scope/name"]} +// +// Entries are PLAIN STRINGS — there's no per-entry object where the +// FAILPROOFAI_HOOK_MARKER could live. We identify failproofai's entry by a +// path-substring match (`includes("pi-extension") && includes("failproofai")`). +// +// Path semantics: a relative entry like `../pi-extension` is resolved relative +// to the directory containing settings.json (i.e. `/.pi/`). For dogfood +// where the extension lives at `/pi-extension/`, the correct entry is +// `"../pi-extension"`. For user-scope global installs where failproofai lives +// in the npm global root, we write the absolute path. +// +// Settings file paths (verified — `~/.pi/settings.json` does NOT exist on a +// fresh install; user-scope is under `~/.pi/agent/`): +// user → ~/.pi/agent/settings.json +// project → /.pi/settings.json +// +// Pi events arrive as `tool_call` / `user_bash` / `input` / `session_start` +// (underscore_lower_snake_case); handler.ts canonicalizes via PI_EVENT_MAP. +// Tool-call payloads use camelCase: `event.toolName`, `event.input`, +// `event.toolCallId`. `tool_call` handlers can `return { block: true, reason }` +// to veto the tool call — this is how PreToolUse deny is enforced. +// +// Detected via the `pi` binary on PATH. + +interface PiSettingsFile { + packages?: string[]; + [key: string]: unknown; +} + +/** Returns the absolute path to the failproofai-shipped Pi extension package. */ +function getPiExtensionPath(): string { + // Resolve relative to the installed failproofai package root, falling back + // to FAILPROOFAI_PACKAGE_ROOT (set by bin/failproofai.mjs) for dev mode. + const fromEnv = process.env.FAILPROOFAI_PACKAGE_ROOT; + if (fromEnv) return resolve(fromEnv, "pi-extension"); + // Fallback: walk up from this file (src/hooks/integrations.ts) two levels. + return resolve(fileURLToPath(import.meta.url), "..", "..", "..", "pi-extension"); +} + +/** True iff a Pi packages-array entry was written by failproofai. */ +function isFailproofaiPiEntry(source: unknown): boolean { + if (typeof source !== "string") return false; + // Path-substring match: matches the canonical `/pi-extension/` + // path AND a future npm-scoped `@failproofai/pi-extension` package. + return source.includes("pi-extension") && source.includes("failproofai"); +} + +export const pi: Integration = { + id: "pi", + displayName: "Pi", + scopes: PI_HOOK_SCOPES, + eventTypes: PI_HOOK_EVENT_TYPES, + + getSettingsPath(scope, cwd) { + const base = cwd ? resolve(cwd) : process.cwd(); + switch (scope) { + case "user": + return resolve(homedir(), ".pi", "agent", "settings.json"); + case "project": + return resolve(base, ".pi", "settings.json"); + case "local": + // Pi has no "local" scope; CLI rejects --cli pi --scope local before + // reaching here, but fall back to project so callers don't crash. + return resolve(base, ".pi", "settings.json"); + } + }, + + readSettings(settingsPath) { + return readJsonFile(settingsPath); + }, + + writeSettings(settingsPath, settings) { + writeJsonFile(settingsPath, settings); + }, + + buildHookEntry(_binaryPath, _eventType, scope) { + // Pi registers extensions at the package level — one entry covers all + // events. The package's index.ts wires the four pi.on(...) handlers. + // The "entry" returned here is a sentinel object so the Integration + // interface's typing is satisfied; writeHookEntries resolves the actual + // string entry below. + return { + [FAILPROOFAI_HOOK_MARKER]: true, + _piPackagePath: getPiExtensionPath(), + _piScope: scope, + }; + }, + + isFailproofaiHook(hook) { + if (hook[FAILPROOFAI_HOOK_MARKER] === true) return true; + // Pi entries are strings — also accept a {source} shape used by tests + if (typeof hook.source === "string") return isFailproofaiPiEntry(hook.source); + return false; + }, + + writeHookEntries(settings, _binaryPath, scope) { + const s = settings as PiSettingsFile; + if (!Array.isArray(s.packages)) s.packages = []; + + const extPath = getPiExtensionPath(); + // Project-scope writes a relative path (resolved by Pi at load time + // against `/.pi/`) so a committed `.pi/settings.json` is portable + // across contributors. User-scope writes an absolute path because each + // user's failproofai install has its own absolute location. + const entry = scope === "project" + ? makePiProjectRelativeEntry(extPath) + : extPath; + + // Idempotent: replace any existing failproofai entry, otherwise append. + const idx = s.packages.findIndex((p) => isFailproofaiPiEntry(p)); + if (idx >= 0) { + s.packages[idx] = entry; + } else { + s.packages.push(entry); + } + }, + + removeHooksFromFile(settingsPath) { + if (!existsSync(settingsPath)) return 0; + const settings = this.readSettings(settingsPath) as PiSettingsFile; + if (!Array.isArray(settings.packages)) return 0; + + const before = settings.packages.length; + settings.packages = settings.packages.filter((p) => !isFailproofaiPiEntry(p)); + const removed = before - settings.packages.length; + + if (settings.packages.length === 0) delete settings.packages; + this.writeSettings(settingsPath, settings as Record); + return removed; + }, + + hooksInstalledInSettings(scope, cwd) { + const settingsPath = this.getSettingsPath(scope, cwd); + if (!existsSync(settingsPath)) return false; + try { + const settings = this.readSettings(settingsPath) as PiSettingsFile; + if (!Array.isArray(settings.packages)) return false; + return settings.packages.some((p) => isFailproofaiPiEntry(p)); + } catch { + // Corrupt settings — treat as not installed + return false; + } + }, + + detectInstalled() { + return binaryExists("pi"); + }, +}; + +/** + * Compute a relative path from `` to the extension + * directory, so the entry is portable across contributors who clone the repo + * to different absolute paths. + * + * For project scope, settings.json lives at `/.pi/settings.json`, and + * the extension at `/pi-extension/`. The relative path Pi expects + * (resolved against `/.pi/`) is `../pi-extension`. + * + * If the extension path is not under the project root (e.g. failproofai is + * installed globally and being written to a project), falls back to the + * absolute path so resolution still works on this machine. + */ +function makePiProjectRelativeEntry(extPath: string): string { + const cwd = process.cwd(); + const cwdResolved = resolve(cwd); + const extResolved = resolve(extPath); + if (extResolved.startsWith(cwdResolved + "/") || extResolved === cwdResolved) { + // Walk back up from /.pi/ to /, then forward to the extension. + const fromSettingsDir = "../" + extResolved.slice(cwdResolved.length + 1); + return fromSettingsDir; + } + // Extension lives outside the project — keep it absolute. Not portable, but + // works for the local user. + return extResolved; +} + // ── Registry ──────────────────────────────────────────────────────────────── const INTEGRATIONS: Record = { @@ -652,6 +837,7 @@ const INTEGRATIONS: Record = { codex, copilot, cursor, + pi, }; export function getIntegration(id: IntegrationType): Integration { diff --git a/src/hooks/policy-evaluator.ts b/src/hooks/policy-evaluator.ts index b2859426..cae98c23 100644 --- a/src/hooks/policy-evaluator.ts +++ b/src/hooks/policy-evaluator.ts @@ -143,6 +143,26 @@ export async function evaluatePolicies( }; } + // Pi's shim parses a flat `{permission, reason}` JSON shape from stdout + // and translates `permission === "deny"` into a `{block: true, reason}` + // return value from its `pi.on("tool_call", ...)` handler. Pi has no + // event-specific decision wrappers, so all events flow through the + // same flat shape. + if (session?.cli === "pi") { + const response = { + permission: "deny", + reason: blockedMessage, + }; + return { + exitCode: 0, + stdout: JSON.stringify(response), + stderr: "", + policyName: policy.name, + reason, + decision: "deny", + }; + } + if (eventType === "PreToolUse") { const response = { hookSpecificOutput: { @@ -277,6 +297,26 @@ export async function evaluatePolicies( }; } + // Pi: instruct emits `{permission: "allow", reason}`. The shim won't + // block (no `"deny"`); it surfaces `reason` to the user where possible + // (Pi has no first-class `additional_context` channel in its tool-call + // return shape, so we log it). + if (session?.cli === "pi") { + const response = { + permission: "allow", + reason: `Instruction from failproofai: ${combined}`, + }; + return { + exitCode: 0, + stdout: JSON.stringify(response), + stderr: "", + policyName: policyNames[0], + policyNames, + reason: combined, + decision: "instruct", + }; + } + if (eventType === "Stop") { // Stop hook: exitCode 2 blocks Claude from stopping. // Reason goes to stderr so Claude Code receives it as context. @@ -337,6 +377,26 @@ export async function evaluatePolicies( }; } + // Pi: same shape as Cursor — flat `{permission: "allow", reason}`. + if (session?.cli === "pi") { + const response = { + permission: "allow", + reason: `Note from failproofai: ${combined}`, + }; + const stderrMsg = allowEntries + .map((e) => `[failproofai] ${e.policyName}: ${e.reason}`) + .join("\n"); + return { + exitCode: 0, + stdout: JSON.stringify(response), + stderr: stderrMsg + "\n", + policyName: policyNames[0], + policyNames, + reason: combined, + decision: "allow", + }; + } + const supportsHookSpecificOutput = eventType === "PreToolUse" || eventType === "PostToolUse" || diff --git a/src/hooks/resolve-permission-mode.ts b/src/hooks/resolve-permission-mode.ts index 2f3df158..6fb26caa 100644 --- a/src/hooks/resolve-permission-mode.ts +++ b/src/hooks/resolve-permission-mode.ts @@ -22,6 +22,10 @@ * • Cursor Agent CLI: no permission-mode field in the hook payload (Cursor's * `loop_limit` is per-hook, not per-session). Falls back to "default" via * the same final branch as Copilot. + * + * • Pi (pi-coding-agent): no permission-mode concept in the extension API; + * `tool_call` handlers always run with the same authority. Falls back to + * "default" via the same final branch as Copilot/Cursor. */ import { readFileSync } from "node:fs"; import { findCodexTranscript } from "../../lib/codex-sessions"; @@ -40,7 +44,7 @@ export function resolvePermissionMode( return resolveCodexMode(sessionId) ?? "default"; } - // copilot, cursor, unknown integrations, or codex without a sessionId + // copilot, cursor, pi, unknown integrations, or codex without a sessionId return "default"; } diff --git a/src/hooks/types.ts b/src/hooks/types.ts index 97e4ab8d..8239516e 100644 --- a/src/hooks/types.ts +++ b/src/hooks/types.ts @@ -5,7 +5,7 @@ export const HOOK_SCOPES = ["user", "project", "local"] as const; export type HookScope = (typeof HOOK_SCOPES)[number]; -export const INTEGRATION_TYPES = ["claude", "codex", "copilot", "cursor"] as const; +export const INTEGRATION_TYPES = ["claude", "codex", "copilot", "cursor", "pi"] as const; export type IntegrationType = (typeof INTEGRATION_TYPES)[number]; export const CODEX_HOOK_SCOPES = ["user", "project"] as const; @@ -92,6 +92,47 @@ export const CURSOR_EVENT_MAP: Record = { stop: "Stop", }; +// ── Pi (pi-coding-agent) ─────────────────────────────────────────────────── +// +// Pi loads TypeScript extensions from packages registered in `.pi/settings.json` +// (project, `/.pi/settings.json`) or `~/.pi/agent/settings.json` (user- +// scope — confirmed empirically; the bare `~/.pi/settings.json` does NOT +// exist on a fresh install). Extensions are default-exported functions that +// receive an ExtensionAPI and call `pi.on("", handler)`. A handler can +// `return { block: true, reason }` from `tool_call` / `user_bash` to veto the +// tool call. +// +// Settings file schema is a FLAT string array — `{"packages": ["..."]}` — +// where each entry is a path resolved relative to `.pi/` (so `../pi-extension` +// for `/pi-extension`). NOT an array of objects, so the +// FAILPROOFAI_HOOK_MARKER convention used by Claude/Codex/Copilot/Cursor is +// not applicable; failproofai's entry is identified by a path-substring match +// (`source.includes("pi-extension") && source.includes("failproofai")`). +// +// Pi events arrive in camelCase (like Cursor): `event.toolName`, +// `event.toolCallId`, `event.input`, `event.text`, `event.cwd`. The handler +// canonicalizes Pi's underscore_lower_snake_case event names +// (session_start / input / tool_call / user_bash) to PascalCase via +// PI_EVENT_MAP before policy lookup. + +export const PI_HOOK_SCOPES = ["user", "project"] as const; +export type PiHookScope = (typeof PI_HOOK_SCOPES)[number]; + +export const PI_HOOK_EVENT_TYPES = [ + "session_start", + "input", + "tool_call", + "user_bash", +] as const; +export type PiHookEventType = (typeof PI_HOOK_EVENT_TYPES)[number]; + +export const PI_EVENT_MAP: Record = { + session_start: "SessionStart", + input: "UserPromptSubmit", + tool_call: "PreToolUse", + user_bash: "PreToolUse", +}; + export const HOOK_EVENT_TYPES = [ "SessionStart", "SessionEnd", @@ -144,7 +185,7 @@ export interface SessionMetadata { cwd?: string; permissionMode?: string; hookEventName?: string; - /** Which agent CLI fired this hook (claude | codex | copilot | cursor). Set by handler.ts from --cli. */ + /** Which agent CLI fired this hook (claude | codex | copilot | cursor | pi). Set by handler.ts from --cli. */ cli?: IntegrationType; } From e83e5759495bb1f1fb458d552d1d624e69fec951 Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Fri, 1 May 2026 14:35:47 -0700 Subject: [PATCH 2/3] docs: add (#264) PR number to CHANGELOG Pi entries Convention: each Unreleased bullet ends with `(#)`. Also extends the Features bullet to mention this repo dogfoods `.pi/settings.json`, matching the wording style used for the Cursor entry in #245. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 734f4a2e..fb700aed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## Unreleased ### Features -- Add Pi (`@mariozechner/pi-coding-agent`) integration (beta) across hooks, activity dashboard, session viewer, and `/projects` listing. New `--cli pi` flag writes a `packages` entry into `~/.pi/agent/settings.json` (user) or `/.pi/settings.json` (project) pointing at failproofai's bundled `pi-extension/` package. The extension subscribes to Pi's `tool_call` / `user_bash` / `input` / `session_start` events and bridges to `failproofai --hook --cli pi`; the handler canonicalizes Pi's underscore_lower_snake_case event names to PascalCase via `PI_EVENT_MAP`. The policy evaluator emits Pi's flat `{permission, reason}` stdout shape, which the extension translates to `{block: true, reason}` to veto tool calls. Path-protection (`isAgentInternalPath` + `isAgentSettingsFile`) covers `~/.pi/`, `.pi/settings.json`, and the Pi-managed extension dirs. Frontend: `lib/cli-registry.ts` adds a `Pi` entry with a pink badge; `lib/projects.ts` merges Pi projects into `/projects`; `app/project/[name]` and `/session/[id]` extend the external-CLI fallback chain. Verified empirically against pi-coding-agent v0.71.1. +- Add Pi (`@mariozechner/pi-coding-agent`) integration (beta) across hooks, activity dashboard, session viewer, and `/projects` listing. New `--cli pi` flag writes a `packages` entry into `~/.pi/agent/settings.json` (user) or `/.pi/settings.json` (project) pointing at failproofai's bundled `pi-extension/` package. The extension subscribes to Pi's `tool_call` / `user_bash` / `input` / `session_start` events and bridges to `failproofai --hook --cli pi`; the handler canonicalizes Pi's underscore_lower_snake_case event names to PascalCase via `PI_EVENT_MAP`. The policy evaluator emits Pi's flat `{permission, reason}` stdout shape, which the extension translates to `{block: true, reason}` to veto tool calls. Path-protection (`isAgentInternalPath` + `isAgentSettingsFile`) covers `~/.pi/`, `.pi/settings.json`, and the Pi-managed extension dirs. Frontend: `lib/cli-registry.ts` adds a `Pi` entry with a pink badge; `lib/projects.ts` merges Pi projects into `/projects`; `app/project/[name]` and `/session/[id]` extend the external-CLI fallback chain. Also ships this repo's own `.pi/settings.json` so contributors using Pi get hooks active automatically. Verified empirically against pi-coding-agent v0.71.1 (#264). - Add GitHub Copilot CLI integration (beta) across hooks, activity dashboard, session fallback, and `/projects` listing. Also ships this repo's own `.github/hooks/failproofai.json` so contributors developing failproofai with the GitHub Copilot CLI get hooks active automatically, mirroring the existing `.claude/settings.json` and `.codex/hooks.json` (#236) - Add Cursor Agent CLI integration (beta) across hooks, activity dashboard, session viewer, and `/projects` listing. New `--cli cursor` flag installs into `~/.cursor/hooks.json` (user) or `/.cursor/hooks.json` (project) using Cursor's flat-array schema with camelCase event keys (`preToolUse`, `beforeSubmitPrompt`, …); the handler canonicalizes to PascalCase via `CURSOR_EVENT_MAP` so existing builtin policies fire unchanged. The policy evaluator emits Cursor's `{permission, user_message, agent_message, additional_context, followup_message}` stdout shape. Path-protection (`isAgentInternalPath` + `isAgentSettingsFile`) covers `~/.cursor/` and `.cursor/hooks.json`. Frontend: `lib/cli-registry.ts` adds a `Cursor Agent` entry with an emerald badge; `lib/projects.ts` merges Cursor projects into `/projects`; `app/project/[name]` and `/session/[id]` extend the external-CLI fallback chain. Also ships this repo's own `.cursor/hooks.json` so contributors using Cursor get hooks active automatically (#245). - Project page (`/project/[name]`): list Copilot and Cursor sessions alongside Claude + Codex, mirroring the existing merge logic on the projects index. Previously the project detail view only enumerated Claude + Codex transcripts (#245). @@ -15,7 +15,7 @@ - Auto-translated MDX: stop the recurring `mintlify validate` parse error in `docs/de/dashboard.mdx` (``) by adding a `sanitizeJsxAttributes` post-processor to the translation pipeline that strips stray ASCII `"` left after typographic-quote pairs (and any unmatched opening typographic quote) in JSX attribute values, and by tightening the translator system prompt to forbid ASCII `"` inside attribute values. Same regression PR #229 fixed by hand — now it can't recur. Includes the immediate file fix on `docs/de/dashboard.mdx`. (#247) ### Docs -- README: add Pi to the supported-CLIs intro line and visual list, with light/dark logo variants (`assets/logos/pi-light.svg` + `pi-dark.svg`); update beta callout to include Pi alongside Copilot and Cursor. +- README: add Pi to the supported-CLIs intro line and visual list, with light/dark logo variants (`assets/logos/pi-light.svg` + `pi-dark.svg`); update beta callout to include Pi alongside Copilot and Cursor (#264). - README: add Cursor Agent to the supported-CLIs intro line and visual list, with light/dark logo variants (`assets/logos/cursor-light.svg` + `cursor-dark.svg`). Note that GitHub Copilot CLI testing is ongoing in the beta callout (#245). ## 0.0.9 — 2026-04-28 From 2a542e4f0731294355397de7a433d16dec9d7b2a Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Fri, 1 May 2026 14:38:29 -0700 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20e2e=20user=5Fbash=20test=20=E2=80=94?= =?UTF-8?q?=20single-component=20path=20for=20block-rm-rf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit block-rm-rf's regex (`/^\/[A-Za-z_][\w.-]*$/` against each token's `normalized` path) only matches single-component absolute paths — `/`, `/home`, `/etc`, the temp root, `/root`. Multi-component paths contain a `/` that breaks the regex match, so the policy allows them through. The user_bash e2e test used a multi-component path, which silently passed the policy and left the deny assertion failing. Switch to a single-component conventional destructive target so the test exercises the canonicalization path it's named for. Add an inline comment pointing at the regex so the choice is obvious to the next reader. Local: 16/16 pi-integration.e2e tests pass after the fix. Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/e2e/hooks/pi-integration.e2e.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/__tests__/e2e/hooks/pi-integration.e2e.test.ts b/__tests__/e2e/hooks/pi-integration.e2e.test.ts index 283808b3..c4b8d1c4 100644 --- a/__tests__/e2e/hooks/pi-integration.e2e.test.ts +++ b/__tests__/e2e/hooks/pi-integration.e2e.test.ts @@ -97,9 +97,13 @@ describe("E2E: Pi integration — hook protocol (handler-only)", () => { const env = createPiEnv(); try { writeConfig(env.cwd, ["block-rm-rf"]); + // `block-rm-rf` only triggers on single-component absolute paths + // (`/`, `/home`, `/etc`, `/tmp` — see the regex in builtin-policies.ts); + // multi-component paths like `/tmp/whatever` slip through. `/tmp` is + // the conventional "destructive target" in tests. const result = runHook( "user_bash", - PiPayloads.userBash("rm -rf /tmp/whatever", env.cwd), + PiPayloads.userBash("rm -rf /tmp", env.cwd), { homeDir: env.home, cli: "pi" }, ); assertPiDeny(result);