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..fb700aed 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. 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). @@ -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 (#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 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..c4b8d1c4 --- /dev/null +++ b/__tests__/e2e/hooks/pi-integration.e2e.test.ts @@ -0,0 +1,374 @@ +/** + * 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"]); + // `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", 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; }