From ef6105241ede24e67c1d7f032b01d2dba29341a5 Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Mon, 4 May 2026 13:31:41 -0700 Subject: [PATCH 1/4] fix: cache pi sessionId in shim so dashboard rows have a session link Pi only emits sessionId on session_start; tool_call, user_bash, input, tool_result, agent_end, session_shutdown all leave it undefined per pi-coding-agent v0.72.1. The shim now caches the most-recent session_start sessionId in module scope and falls back to it on every other event, so activity records keep a stable id instead of recording dash-dash for every Pi row after the first. Also drops version from 0.0.13-beta.1 back to 0.0.9-beta.3 to match the intended publish sequence. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 1 + __tests__/hooks/pi-extension-shim.test.ts | 76 +++++++++++++++++++++++ package.json | 2 +- pi-extension/index.ts | 54 +++++++++++++--- 4 files changed, 125 insertions(+), 8 deletions(-) create mode 100644 __tests__/hooks/pi-extension-shim.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3abeeede..d190e13a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - 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). ### Fixes +- Pi integration: cache `sessionId` from the `session_start` event in the shim and fall back to it on every subsequent `tool_call`/`user_bash`/`input`/`tool_result`/`agent_end`/`session_shutdown` call. Pi only emits `sessionId` reliably on `session_start` (verified against pi-coding-agent v0.72.1 — the other event types in the d.ts mark it optional and Pi doesn't populate it on tool calls), so without the cache every Pi activity record after the first showed `Session ID: —` and `Transcript: —` on the dashboard. New unit test (`__tests__/hooks/pi-extension-shim.test.ts`) covers the happy path, the explicit-override path, every event type honoring the cache, and the cold-start path. - Cursor integration: surface sessions stored under cursor-agent's current on-disk layout. As of cursor-agent 2026-04+, transcripts live at `~/.cursor/projects//agent-transcripts//.jsonl` (with the JSONL records using the OpenAI-shape `{role, message: {content: [{type, text}]}}` rather than the legacy `{type, data, timestamp}` form). `lib/cursor-projects.ts` and `lib/cursor-sessions.ts` previously only probed the legacy `~/.cursor/{agent-sessions,conversations,sessions}/` paths so every recent Cursor session 404'd from the dashboard. Both modules now scan the new layout first (and decode the cwd from the encoded project-dir name, prepending `/` since Cursor's encoding drops the leading slash), then fall back to the legacy candidates for older installs. The transcript parser learned a branch for the new shape — strips the synthesized `` wrapper Cursor adds to user messages, preserves assistant text blocks, and synthesizes per-record sort timestamps since the new format omits them. Verified live on cursor-agent v2026.04.29 against a real session that the dashboard had been falsely tagging as "Claude Code" with "Session log file not found". `lib/gemini-projects.ts` now uses `encodeFolderName(cwd)` for `ProjectFolder.name` so cross-CLI merge in `mergeProjectFolders` unions on the same key and Gemini-only project links resolve through `getGeminiSessionsByEncodedName`. `policy-evaluator.ts` preserves the raw CLI `--hook` arg via a new `SessionMetadata.rawHookEventName` field captured in `handler.ts` before canonicalization, so Gemini's `hookSpecificOutput.hookEventName` round-trips correctly even when stdin omits `hook_event_name`; deny-message construction now branches on event type so non-tool events (UserPromptSubmit / SessionStart / SessionEnd / Stop) emit "Blocked prompt|session start|…" instead of the misleading "Blocked unknown tool". `lib/gemini-sessions.ts` loosens `SESSION_FILE_RE` to accept any timestamp shape (Gemini docs include seconds; the load-bearing safety check is the first-line `sessionId` validation) and replaces the whole-file `readFileSync` in `findGeminiTranscript` with a bounded 4 KB `readFirstLineSync` helper so large transcripts no longer blow memory just to inspect the metadata header. `__tests__/lib/projects.test.ts` adds three Gemini aggregation tests (Gemini-only inclusion, cross-CLI merge by encoded slug, reject-fallback) mirroring the existing Pi / Cursor / OpenCode patterns (#277). - `block-read-outside-cwd`: deny message now says "Reading agent settings file blocked" instead of "Reading Claude settings file blocked" — the policy has covered all 6 CLIs' settings files since #270 / #245 / #220 but the deny string was stale (#270). - `require-ci-green-before-stop`: stop reporting historical CI failures as still-failing after a fix commit lands. The policy now filters `gh run list` results to runs whose `headSha` matches the current local HEAD, and deduplicates by workflow name so GitHub's "Re-run all jobs" doesn't resurface old failed run records. Also bumps the gh-run-list `--limit` from 5 to 20 to avoid truncating the latest run on busy branches with many workflows or recent pushes. Third-party checks (CodeRabbit, SonarCloud, …) and commit statuses already query by SHA and are unchanged. Resolves a wedge where a green PR could not satisfy the Stop policy because an earlier failed run on the same branch was still in the top-5 window. (#266) diff --git a/__tests__/hooks/pi-extension-shim.test.ts b/__tests__/hooks/pi-extension-shim.test.ts new file mode 100644 index 00000000..a3558d15 --- /dev/null +++ b/__tests__/hooks/pi-extension-shim.test.ts @@ -0,0 +1,76 @@ +/** + * Tests for the pi-extension shim — focused on session-id continuity across + * Pi events. Pi only emits `sessionId` reliably on the `session_start` event; + * `tool_call`, `user_bash`, `input`, `tool_result`, and `agent_end` may omit + * it. Without the cache, every activity record after session_start has + * `sessionId: undefined` and the dashboard renders "—". + */ +// @vitest-environment node +import { describe, it, expect, beforeEach, vi } from "vitest"; + +interface CapturedCall { + payload: Record; + args: string[]; +} + +interface PiExtensionApi { + on(event: string, handler: (event: unknown) => unknown): void; +} + +const captured: CapturedCall[] = []; + +vi.mock("node:child_process", () => ({ + spawnSync: (_cmd: string, args: string[], opts: { input?: string }) => { + captured.push({ args: args ?? [], payload: JSON.parse(opts?.input ?? "{}") }); + return { pid: 0, output: [], status: 0, signal: null, stderr: "", stdout: "" }; + }, +})); + +describe("pi-extension shim — sessionId caching", () => { + let handlers: Record unknown> = {}; + let bridge: (pi: PiExtensionApi) => void; + + beforeEach(async () => { + captured.length = 0; + handlers = {}; + vi.resetModules(); + const mod = await import("../../pi-extension/index"); + bridge = mod.default; + bridge({ on: (name, fn) => { handlers[name] = fn; } }); + }); + + it("caches sessionId from session_start and forwards on subsequent tool_call", () => { + handlers.session_start({ type: "session_start", sessionId: "abc-123", cwd: "/proj" }); + expect(captured.at(-1)?.payload.session_id).toBe("abc-123"); + + handlers.tool_call({ type: "tool_call", toolName: "bash", input: { command: "ls" }, cwd: "/proj" }); + // tool_call event has no sessionId, but cached value should fill it. + expect(captured.at(-1)?.payload.session_id).toBe("abc-123"); + }); + + it("event-level sessionId overrides cache when present", () => { + handlers.session_start({ type: "session_start", sessionId: "first", cwd: "/proj" }); + handlers.tool_call({ type: "tool_call", toolName: "bash", input: {}, cwd: "/proj", sessionId: "explicit" }); + expect(captured.at(-1)?.payload.session_id).toBe("explicit"); + // Subsequent event without sessionId falls back to the most recent cached value (now "explicit"). + handlers.user_bash({ type: "user_bash", command: "ls", cwd: "/proj" }); + expect(captured.at(-1)?.payload.session_id).toBe("explicit"); + }); + + it("input, tool_result, agent_end, session_shutdown all use cached sessionId", () => { + handlers.session_start({ type: "session_start", sessionId: "S1", cwd: "/proj" }); + handlers.input({ type: "input", text: "hi", cwd: "/proj" }); + expect(captured.at(-1)?.payload.session_id).toBe("S1"); + handlers.tool_result({ type: "tool_result", toolName: "bash", input: {}, content: [], isError: false, cwd: "/proj" }); + expect(captured.at(-1)?.payload.session_id).toBe("S1"); + handlers.agent_end({ type: "agent_end", cwd: "/proj" }); + expect(captured.at(-1)?.payload.session_id).toBe("S1"); + handlers.session_shutdown({ type: "session_shutdown", reason: "quit", cwd: "/proj" }); + expect(captured.at(-1)?.payload.session_id).toBe("S1"); + }); + + it("returns undefined sessionId when never seen (cold start)", () => { + handlers.tool_call({ type: "tool_call", toolName: "bash", input: { command: "ls" }, cwd: "/proj" }); + expect(captured.at(-1)?.payload.session_id).toBeUndefined(); + }); +}); diff --git a/package.json b/package.json index fdc5c5d8..bc5f8e0f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "failproofai", - "version": "0.0.13-beta.1", + "version": "0.0.9-beta.3", "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" diff --git a/pi-extension/index.ts b/pi-extension/index.ts index 46b5845c..1ad06c74 100644 --- a/pi-extension/index.ts +++ b/pi-extension/index.ts @@ -118,6 +118,46 @@ function resolveCwd(eventCwd: string | undefined): string { return eventCwd ?? process.cwd(); } +/** + * Pi's `tool_call` / `user_bash` / `input` / `tool_result` / `agent_end` + * events don't reliably carry `sessionId` — only `session_start` does. We + * cache the most-recent session_start sessionId in module scope and fall + * back to it on every other event so activity records and dashboard + * session-link generation get a stable id (instead of recording "—" for + * every Pi row). + * + * pi-coding-agent v0.72.1 runs one Pi session per process, so the cached + * value is always the live session — no risk of leaking another session's + * id into the wrong record. If Pi ever multiplexes sessions, we'd need a + * keyed map, but a single slot is correct for the current contract. + */ +let cachedSessionId: string | undefined; +function resolveSessionId(eventSessionId: string | undefined): string | undefined { + if (eventSessionId) { + cachedSessionId = eventSessionId; + return eventSessionId; + } + return cachedSessionId; +} + +/** + * Best-effort transcript path for the current Pi session. Pi stores + * transcripts at `~/.pi/agent/sessions//_.jsonl` + * where encodedCwd = `----`. The timestamp + * prefix is the session-start ISO time, unknown to us without scanning the + * directory. Rather than paying readdir on every hook, we leave + * `transcript_path` undefined and rely on the dashboard's + * `getCachedPiSessionsByEncodedName` to resolve sessionId → transcript on + * demand. This function is exposed so a future change can flip to eager + * resolution if needed. + */ +function piEncodeCwd(cwd: string): string { + const inner = cwd.replace(/\//g, "-"); + return `--${inner}--`; +} +// Marker so eslint / tree-shake doesn't drop the helper before it's used. +void piEncodeCwd; + interface PiUserBashEvent { type?: string; command?: string; @@ -180,7 +220,7 @@ export default function failproofaiBridge(pi: PiExtensionApi) { const decision = callPolicy("tool_call", { tool_name: canonicalizeToolName(e.toolName), tool_input: e.input, - session_id: e.sessionId, + session_id: resolveSessionId(e.sessionId), cwd: resolveCwd(e.cwd), hook_event_name: "PreToolUse", }); @@ -194,7 +234,7 @@ export default function failproofaiBridge(pi: PiExtensionApi) { const decision = callPolicy("user_bash", { tool_name: "Bash", tool_input: { command: e.command }, - session_id: e.sessionId, + session_id: resolveSessionId(e.sessionId), cwd: resolveCwd(e.cwd), hook_event_name: "PreToolUse", }); @@ -208,7 +248,7 @@ export default function failproofaiBridge(pi: PiExtensionApi) { const e = event as PiInputEvent; const decision = callPolicy("input", { prompt: e.text, - session_id: e.sessionId, + session_id: resolveSessionId(e.sessionId), cwd: resolveCwd(e.cwd), hook_event_name: "UserPromptSubmit", }); @@ -222,7 +262,7 @@ export default function failproofaiBridge(pi: PiExtensionApi) { pi.on("session_start", (event: unknown): unknown => { const e = event as PiSessionStartEvent; callPolicy("session_start", { - session_id: e.sessionId, + session_id: resolveSessionId(e.sessionId), cwd: resolveCwd(e.cwd), reason: e.reason, hook_event_name: "SessionStart", @@ -242,7 +282,7 @@ export default function failproofaiBridge(pi: PiExtensionApi) { tool_name: canonicalizeToolName(e.toolName), tool_input: e.input ?? {}, tool_response: { content: e.content, isError: e.isError }, - session_id: e.sessionId, + session_id: resolveSessionId(e.sessionId), cwd: resolveCwd(e.cwd), hook_event_name: "PostToolUse", }); @@ -257,7 +297,7 @@ export default function failproofaiBridge(pi: PiExtensionApi) { pi.on("agent_end", (event: unknown): unknown => { const e = event as PiAgentEndEvent; callPolicy("agent_end", { - session_id: e.sessionId, + session_id: resolveSessionId(e.sessionId), cwd: resolveCwd(e.cwd), hook_event_name: "Stop", }); @@ -269,7 +309,7 @@ export default function failproofaiBridge(pi: PiExtensionApi) { pi.on("session_shutdown", (event: unknown): unknown => { const e = event as PiSessionShutdownEvent; callPolicy("session_shutdown", { - session_id: e.sessionId, + session_id: resolveSessionId(e.sessionId), cwd: resolveCwd(e.cwd), reason: e.reason, hook_event_name: "SessionEnd", From 613cba3c812d7743f1ef8d1896f60b86879d3e00 Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Mon, 4 May 2026 13:50:32 -0700 Subject: [PATCH 2/4] fix: discover Pi sessionId from on-disk transcript filename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pi v0.71.1 doesn't populate event.sessionId on any of its events (empirically verified — session_start through session_shutdown all leave it undefined). The shim now scans Pi's transcript dir for the newest matching file, extracts the sessionId from the filename, caches it for subsequent events. Pi's cwd encoding strips the leading slash before dash-replacing the rest (/home/u/repo -> --home-u-repo-- with two leading dashes, not three). With this fix PreToolUse, PostToolUse, Stop, and SessionEnd records all carry the sessionId so dashboard rows can deep-link to the session viewer. SessionStart and UserPromptSubmit miss it because Pi flushes the transcript lazily after those events fire — a Pi behavior we can't change client-side without an upstream fix. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 2 +- __tests__/hooks/pi-extension-shim.test.ts | 90 ++++++++++++++------- pi-extension/index.ts | 98 ++++++++++++++--------- 3 files changed, 123 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d190e13a..66e76d6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ - 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). ### Fixes -- Pi integration: cache `sessionId` from the `session_start` event in the shim and fall back to it on every subsequent `tool_call`/`user_bash`/`input`/`tool_result`/`agent_end`/`session_shutdown` call. Pi only emits `sessionId` reliably on `session_start` (verified against pi-coding-agent v0.72.1 — the other event types in the d.ts mark it optional and Pi doesn't populate it on tool calls), so without the cache every Pi activity record after the first showed `Session ID: —` and `Transcript: —` on the dashboard. New unit test (`__tests__/hooks/pi-extension-shim.test.ts`) covers the happy path, the explicit-override path, every event type honoring the cache, and the cold-start path. +- Pi integration: surface `sessionId` on activity records by discovering it from Pi's on-disk transcript filename. Pi (verified empirically against pi-coding-agent v0.71.1) does NOT populate `event.sessionId` on any of its events — `session_start`, `tool_call`, `user_bash`, `input`, `tool_result`, `agent_end`, `session_shutdown` all leave it undefined. The shim now scans `~/.pi/agent/sessions/----/` for the most-recent `_.jsonl` file and extracts the sessionId from the filename, then caches it for subsequent events in the same Pi process. With this fix, `PreToolUse` / `PostToolUse` / `Stop` / `SessionEnd` records now carry the sessionId so dashboard rows can deep-link to the session viewer. `SessionStart` and `UserPromptSubmit` remain unsessioned because Pi flushes the transcript file lazily, after those events fire — that's a Pi behavior we can't change client-side. Pi's encoding strips the leading `/` before replacing remaining slashes with `-`, so `/home/u/repo` → `--home-u-repo--` (NOT `---home-u-repo--`). New unit test (`__tests__/hooks/pi-extension-shim.test.ts`) covers happy path, multi-file mtime tie-breaker, missing-cwd fallback, and resolution from every event type. - Cursor integration: surface sessions stored under cursor-agent's current on-disk layout. As of cursor-agent 2026-04+, transcripts live at `~/.cursor/projects//agent-transcripts//.jsonl` (with the JSONL records using the OpenAI-shape `{role, message: {content: [{type, text}]}}` rather than the legacy `{type, data, timestamp}` form). `lib/cursor-projects.ts` and `lib/cursor-sessions.ts` previously only probed the legacy `~/.cursor/{agent-sessions,conversations,sessions}/` paths so every recent Cursor session 404'd from the dashboard. Both modules now scan the new layout first (and decode the cwd from the encoded project-dir name, prepending `/` since Cursor's encoding drops the leading slash), then fall back to the legacy candidates for older installs. The transcript parser learned a branch for the new shape — strips the synthesized `` wrapper Cursor adds to user messages, preserves assistant text blocks, and synthesizes per-record sort timestamps since the new format omits them. Verified live on cursor-agent v2026.04.29 against a real session that the dashboard had been falsely tagging as "Claude Code" with "Session log file not found". `lib/gemini-projects.ts` now uses `encodeFolderName(cwd)` for `ProjectFolder.name` so cross-CLI merge in `mergeProjectFolders` unions on the same key and Gemini-only project links resolve through `getGeminiSessionsByEncodedName`. `policy-evaluator.ts` preserves the raw CLI `--hook` arg via a new `SessionMetadata.rawHookEventName` field captured in `handler.ts` before canonicalization, so Gemini's `hookSpecificOutput.hookEventName` round-trips correctly even when stdin omits `hook_event_name`; deny-message construction now branches on event type so non-tool events (UserPromptSubmit / SessionStart / SessionEnd / Stop) emit "Blocked prompt|session start|…" instead of the misleading "Blocked unknown tool". `lib/gemini-sessions.ts` loosens `SESSION_FILE_RE` to accept any timestamp shape (Gemini docs include seconds; the load-bearing safety check is the first-line `sessionId` validation) and replaces the whole-file `readFileSync` in `findGeminiTranscript` with a bounded 4 KB `readFirstLineSync` helper so large transcripts no longer blow memory just to inspect the metadata header. `__tests__/lib/projects.test.ts` adds three Gemini aggregation tests (Gemini-only inclusion, cross-CLI merge by encoded slug, reject-fallback) mirroring the existing Pi / Cursor / OpenCode patterns (#277). - `block-read-outside-cwd`: deny message now says "Reading agent settings file blocked" instead of "Reading Claude settings file blocked" — the policy has covered all 6 CLIs' settings files since #270 / #245 / #220 but the deny string was stale (#270). - `require-ci-green-before-stop`: stop reporting historical CI failures as still-failing after a fix commit lands. The policy now filters `gh run list` results to runs whose `headSha` matches the current local HEAD, and deduplicates by workflow name so GitHub's "Re-run all jobs" doesn't resurface old failed run records. Also bumps the gh-run-list `--limit` from 5 to 20 to avoid truncating the latest run on busy branches with many workflows or recent pushes. Third-party checks (CodeRabbit, SonarCloud, …) and commit statuses already query by SHA and are unchanged. Resolves a wedge where a green PR could not satisfy the Stop policy because an earlier failed run on the same branch was still in the top-5 window. (#266) diff --git a/__tests__/hooks/pi-extension-shim.test.ts b/__tests__/hooks/pi-extension-shim.test.ts index a3558d15..19df31d8 100644 --- a/__tests__/hooks/pi-extension-shim.test.ts +++ b/__tests__/hooks/pi-extension-shim.test.ts @@ -1,12 +1,17 @@ /** - * Tests for the pi-extension shim — focused on session-id continuity across - * Pi events. Pi only emits `sessionId` reliably on the `session_start` event; - * `tool_call`, `user_bash`, `input`, `tool_result`, and `agent_end` may omit - * it. Without the cache, every activity record after session_start has - * `sessionId: undefined` and the dashboard renders "—". + * Tests for the pi-extension shim — focused on session-id resolution. + * + * Pi (verified empirically against pi-coding-agent v0.71.1) does NOT + * populate `event.sessionId` on any event. The shim recovers the + * sessionId by scanning the on-disk transcript at + * `~/.pi/agent/sessions//_.jsonl` and + * caches the result. */ // @vitest-environment node import { describe, it, expect, beforeEach, vi } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; interface CapturedCall { payload: Record; @@ -26,51 +31,82 @@ vi.mock("node:child_process", () => ({ }, })); -describe("pi-extension shim — sessionId caching", () => { +function piEncodeCwd(cwd: string): string { + return `--${cwd.replace(/^\/+/, "").replace(/\//g, "-")}--`; +} + +describe("pi-extension shim — sessionId resolution via on-disk discovery", () => { let handlers: Record unknown> = {}; let bridge: (pi: PiExtensionApi) => void; + let piRoot: string; + let originalEnv: string | undefined; beforeEach(async () => { captured.length = 0; handlers = {}; + piRoot = mkdtempSync(join(tmpdir(), "pi-shim-test-")); + originalEnv = process.env.PI_SESSIONS_DIR; + process.env.PI_SESSIONS_DIR = piRoot; vi.resetModules(); const mod = await import("../../pi-extension/index"); bridge = mod.default; bridge({ on: (name, fn) => { handlers[name] = fn; } }); }); - it("caches sessionId from session_start and forwards on subsequent tool_call", () => { - handlers.session_start({ type: "session_start", sessionId: "abc-123", cwd: "/proj" }); - expect(captured.at(-1)?.payload.session_id).toBe("abc-123"); + function writeSessionFile(cwd: string, sessionId: string, ts = "2026-05-04T20-00-00-000Z", mtime?: Date): void { + const dir = join(piRoot, piEncodeCwd(cwd)); + mkdirSync(dir, { recursive: true }); + const file = join(dir, `${ts}_${sessionId}.jsonl`); + writeFileSync(file, "{}\n"); + if (mtime) utimesSync(file, mtime, mtime); + } + it("uses event-level sessionId when present", () => { + writeSessionFile("/proj", "11111111-1111-1111-1111-111111111111"); + handlers.tool_call({ type: "tool_call", toolName: "bash", input: { command: "ls" }, cwd: "/proj", sessionId: "explicit" }); + expect(captured.at(-1)?.payload.session_id).toBe("explicit"); + }); + + it("discovers sessionId from the on-disk transcript when event omits it", () => { + const sid = "22222222-2222-2222-2222-222222222222"; + writeSessionFile("/proj", sid); handlers.tool_call({ type: "tool_call", toolName: "bash", input: { command: "ls" }, cwd: "/proj" }); - // tool_call event has no sessionId, but cached value should fill it. - expect(captured.at(-1)?.payload.session_id).toBe("abc-123"); + expect(captured.at(-1)?.payload.session_id).toBe(sid); }); - it("event-level sessionId overrides cache when present", () => { - handlers.session_start({ type: "session_start", sessionId: "first", cwd: "/proj" }); - handlers.tool_call({ type: "tool_call", toolName: "bash", input: {}, cwd: "/proj", sessionId: "explicit" }); - expect(captured.at(-1)?.payload.session_id).toBe("explicit"); - // Subsequent event without sessionId falls back to the most recent cached value (now "explicit"). - handlers.user_bash({ type: "user_bash", command: "ls", cwd: "/proj" }); - expect(captured.at(-1)?.payload.session_id).toBe("explicit"); + it("picks the newest file when multiple sessions exist for a cwd", () => { + writeSessionFile("/proj", "33333333-3333-3333-3333-333333333333", "2026-05-01T00-00-00-000Z", new Date("2026-05-01T00:00:00Z")); + writeSessionFile("/proj", "44444444-4444-4444-4444-444444444444", "2026-05-04T20-00-00-000Z", new Date("2026-05-04T20:00:00Z")); + handlers.tool_call({ type: "tool_call", toolName: "bash", input: {}, cwd: "/proj" }); + expect(captured.at(-1)?.payload.session_id).toBe("44444444-4444-4444-4444-444444444444"); }); - it("input, tool_result, agent_end, session_shutdown all use cached sessionId", () => { - handlers.session_start({ type: "session_start", sessionId: "S1", cwd: "/proj" }); + it("returns undefined when no transcript exists yet", () => { + handlers.tool_call({ type: "tool_call", toolName: "bash", input: {}, cwd: "/no-such-cwd" }); + expect(captured.at(-1)?.payload.session_id).toBeUndefined(); + }); + + it("session_start, input, tool_result, agent_end, session_shutdown all resolve", () => { + const sid = "55555555-5555-5555-5555-555555555555"; + writeSessionFile("/proj", sid); + handlers.session_start({ type: "session_start", cwd: "/proj" }); + expect(captured.at(-1)?.payload.session_id).toBe(sid); handlers.input({ type: "input", text: "hi", cwd: "/proj" }); - expect(captured.at(-1)?.payload.session_id).toBe("S1"); + expect(captured.at(-1)?.payload.session_id).toBe(sid); handlers.tool_result({ type: "tool_result", toolName: "bash", input: {}, content: [], isError: false, cwd: "/proj" }); - expect(captured.at(-1)?.payload.session_id).toBe("S1"); + expect(captured.at(-1)?.payload.session_id).toBe(sid); handlers.agent_end({ type: "agent_end", cwd: "/proj" }); - expect(captured.at(-1)?.payload.session_id).toBe("S1"); + expect(captured.at(-1)?.payload.session_id).toBe(sid); handlers.session_shutdown({ type: "session_shutdown", reason: "quit", cwd: "/proj" }); - expect(captured.at(-1)?.payload.session_id).toBe("S1"); + expect(captured.at(-1)?.payload.session_id).toBe(sid); }); - it("returns undefined sessionId when never seen (cold start)", () => { - handlers.tool_call({ type: "tool_call", toolName: "bash", input: { command: "ls" }, cwd: "/proj" }); - expect(captured.at(-1)?.payload.session_id).toBeUndefined(); + // Cleanup + afterEach(() => { + if (originalEnv === undefined) delete process.env.PI_SESSIONS_DIR; + else process.env.PI_SESSIONS_DIR = originalEnv; + rmSync(piRoot, { recursive: true, force: true }); }); }); + +import { afterEach } from "vitest"; diff --git a/pi-extension/index.ts b/pi-extension/index.ts index 1ad06c74..63b386a8 100644 --- a/pi-extension/index.ts +++ b/pi-extension/index.ts @@ -26,9 +26,10 @@ * relative to this file via `import.meta.url`, NOT process.cwd(). */ import { spawnSync } from "node:child_process"; -import { resolve, dirname } from "node:path"; +import { resolve, dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { existsSync } from "node:fs"; +import { existsSync, readdirSync, statSync } from "node:fs"; +import { homedir } from "node:os"; const HERE = dirname(fileURLToPath(import.meta.url)); const DIST_BIN = resolve(HERE, "..", "dist", "cli.mjs"); @@ -119,44 +120,63 @@ function resolveCwd(eventCwd: string | undefined): string { } /** - * Pi's `tool_call` / `user_bash` / `input` / `tool_result` / `agent_end` - * events don't reliably carry `sessionId` — only `session_start` does. We - * cache the most-recent session_start sessionId in module scope and fall - * back to it on every other event so activity records and dashboard - * session-link generation get a stable id (instead of recording "—" for - * every Pi row). + * Pi (verified empirically against pi-coding-agent v0.71.1) does NOT + * populate `event.sessionId` on any of its events — `session_start`, + * `tool_call`, `user_bash`, `input`, `tool_result`, `agent_end`, + * `session_shutdown` all leave it undefined. Without help the shim can't + * tag activity records with a session id, so the dashboard renders + * `Session ID: —` for every Pi row. * - * pi-coding-agent v0.72.1 runs one Pi session per process, so the cached - * value is always the live session — no risk of leaking another session's - * id into the wrong record. If Pi ever multiplexes sessions, we'd need a - * keyed map, but a single slot is correct for the current contract. + * What Pi DOES do: at session start it creates a JSONL transcript at + * `~/.pi/agent/sessions//_.jsonl` where + * the filename encodes the sessionId. We discover ours by scanning the + * encoded-cwd directory for the most-recently-modified matching file. + * + * Strategy: scan once and cache. Pi runs one session per process so the + * cache is per-process and lives for the session's lifetime. If Pi ever + * multiplexes, we'd need a keyed map. */ +const PI_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; + +/** Encode a cwd into Pi's on-disk session-dir name. Pi strips the leading + * `/` before replacing remaining slashes with `-`, e.g. + * `/home/u/repo` → `--home-u-repo--`. */ +function piEncodeCwd(cwd: string): string { + const inner = cwd.replace(/^\/+/, "").replace(/\//g, "-"); + return `--${inner}--`; +} + +/** Find the newest `_.jsonl` file under `~/.pi/agent/sessions//` + * and return its sessionId. Returns undefined when the dir doesn't exist or + * has no matching file. */ +function discoverPiSessionId(cwd: string): string | undefined { + const root = process.env.PI_SESSIONS_DIR || join(homedir(), ".pi", "agent", "sessions"); + const dir = join(root, piEncodeCwd(cwd)); + let entries: string[]; + try { entries = readdirSync(dir); } catch { return undefined; } + let best: { sessionId: string; mtime: number } | undefined; + for (const name of entries) { + const m = PI_FILE_RE.exec(name); + if (!m) continue; + let mtime: number; + try { mtime = statSync(join(dir, name)).mtimeMs; } catch { continue; } + if (!best || mtime > best.mtime) best = { sessionId: m[1], mtime }; + } + return best?.sessionId; +} + let cachedSessionId: string | undefined; -function resolveSessionId(eventSessionId: string | undefined): string | undefined { +function resolveSessionId(eventSessionId: string | undefined, cwd: string): string | undefined { if (eventSessionId) { cachedSessionId = eventSessionId; return eventSessionId; } - return cachedSessionId; -} - -/** - * Best-effort transcript path for the current Pi session. Pi stores - * transcripts at `~/.pi/agent/sessions//_.jsonl` - * where encodedCwd = `----`. The timestamp - * prefix is the session-start ISO time, unknown to us without scanning the - * directory. Rather than paying readdir on every hook, we leave - * `transcript_path` undefined and rely on the dashboard's - * `getCachedPiSessionsByEncodedName` to resolve sessionId → transcript on - * demand. This function is exposed so a future change can flip to eager - * resolution if needed. - */ -function piEncodeCwd(cwd: string): string { - const inner = cwd.replace(/\//g, "-"); - return `--${inner}--`; + if (cachedSessionId) return cachedSessionId; + // Pi v0.71.1 never sets sessionId — discover from disk. + const discovered = discoverPiSessionId(cwd); + if (discovered) cachedSessionId = discovered; + return discovered; } -// Marker so eslint / tree-shake doesn't drop the helper before it's used. -void piEncodeCwd; interface PiUserBashEvent { type?: string; @@ -220,7 +240,7 @@ export default function failproofaiBridge(pi: PiExtensionApi) { const decision = callPolicy("tool_call", { tool_name: canonicalizeToolName(e.toolName), tool_input: e.input, - session_id: resolveSessionId(e.sessionId), + session_id: resolveSessionId(e.sessionId, resolveCwd(e.cwd)), cwd: resolveCwd(e.cwd), hook_event_name: "PreToolUse", }); @@ -234,7 +254,7 @@ export default function failproofaiBridge(pi: PiExtensionApi) { const decision = callPolicy("user_bash", { tool_name: "Bash", tool_input: { command: e.command }, - session_id: resolveSessionId(e.sessionId), + session_id: resolveSessionId(e.sessionId, resolveCwd(e.cwd)), cwd: resolveCwd(e.cwd), hook_event_name: "PreToolUse", }); @@ -248,7 +268,7 @@ export default function failproofaiBridge(pi: PiExtensionApi) { const e = event as PiInputEvent; const decision = callPolicy("input", { prompt: e.text, - session_id: resolveSessionId(e.sessionId), + session_id: resolveSessionId(e.sessionId, resolveCwd(e.cwd)), cwd: resolveCwd(e.cwd), hook_event_name: "UserPromptSubmit", }); @@ -262,7 +282,7 @@ export default function failproofaiBridge(pi: PiExtensionApi) { pi.on("session_start", (event: unknown): unknown => { const e = event as PiSessionStartEvent; callPolicy("session_start", { - session_id: resolveSessionId(e.sessionId), + session_id: resolveSessionId(e.sessionId, resolveCwd(e.cwd)), cwd: resolveCwd(e.cwd), reason: e.reason, hook_event_name: "SessionStart", @@ -282,7 +302,7 @@ export default function failproofaiBridge(pi: PiExtensionApi) { tool_name: canonicalizeToolName(e.toolName), tool_input: e.input ?? {}, tool_response: { content: e.content, isError: e.isError }, - session_id: resolveSessionId(e.sessionId), + session_id: resolveSessionId(e.sessionId, resolveCwd(e.cwd)), cwd: resolveCwd(e.cwd), hook_event_name: "PostToolUse", }); @@ -297,7 +317,7 @@ export default function failproofaiBridge(pi: PiExtensionApi) { pi.on("agent_end", (event: unknown): unknown => { const e = event as PiAgentEndEvent; callPolicy("agent_end", { - session_id: resolveSessionId(e.sessionId), + session_id: resolveSessionId(e.sessionId, resolveCwd(e.cwd)), cwd: resolveCwd(e.cwd), hook_event_name: "Stop", }); @@ -309,7 +329,7 @@ export default function failproofaiBridge(pi: PiExtensionApi) { pi.on("session_shutdown", (event: unknown): unknown => { const e = event as PiSessionShutdownEvent; callPolicy("session_shutdown", { - session_id: resolveSessionId(e.sessionId), + session_id: resolveSessionId(e.sessionId, resolveCwd(e.cwd)), cwd: resolveCwd(e.cwd), reason: e.reason, hook_event_name: "SessionEnd", From c37af2506027d9242634cfb856a58fc9f9e03b50 Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Mon, 4 May 2026 14:02:16 -0700 Subject: [PATCH 3/4] fix: per-cwd Pi sessionId cache + reset on new/resume/fork shutdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit follow-up on #284: - Cache sessionId by cwd (Map) instead of a single module- level slot so an extension running across multiple workspace roots can't cross-attribute one cwd's session to another. - Clear the entry on session_shutdown reasons `new`, `resume`, `fork` — Pi reuses the same process across these and would otherwise inherit the prior session's id until the next disk-discovery refreshed it. - New unit tests cover the cache reset path and the per-cwd isolation, plus the quit-reason no-op. - Append (#284) to the CHANGELOG entry per the changelog format guideline. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 2 +- __tests__/hooks/pi-extension-shim.test.ts | 35 +++++++++++++++++++++++ pi-extension/index.ts | 33 ++++++++++++++++----- 3 files changed, 62 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66e76d6d..a0e7a00e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ - 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). ### Fixes -- Pi integration: surface `sessionId` on activity records by discovering it from Pi's on-disk transcript filename. Pi (verified empirically against pi-coding-agent v0.71.1) does NOT populate `event.sessionId` on any of its events — `session_start`, `tool_call`, `user_bash`, `input`, `tool_result`, `agent_end`, `session_shutdown` all leave it undefined. The shim now scans `~/.pi/agent/sessions/----/` for the most-recent `_.jsonl` file and extracts the sessionId from the filename, then caches it for subsequent events in the same Pi process. With this fix, `PreToolUse` / `PostToolUse` / `Stop` / `SessionEnd` records now carry the sessionId so dashboard rows can deep-link to the session viewer. `SessionStart` and `UserPromptSubmit` remain unsessioned because Pi flushes the transcript file lazily, after those events fire — that's a Pi behavior we can't change client-side. Pi's encoding strips the leading `/` before replacing remaining slashes with `-`, so `/home/u/repo` → `--home-u-repo--` (NOT `---home-u-repo--`). New unit test (`__tests__/hooks/pi-extension-shim.test.ts`) covers happy path, multi-file mtime tie-breaker, missing-cwd fallback, and resolution from every event type. +- Pi integration: surface `sessionId` on activity records by discovering it from Pi's on-disk transcript filename. Pi (verified empirically against pi-coding-agent v0.71.1) does NOT populate `event.sessionId` on any of its events — `session_start`, `tool_call`, `user_bash`, `input`, `tool_result`, `agent_end`, `session_shutdown` all leave it undefined. The shim now scans `~/.pi/agent/sessions/----/` for the most-recent `_.jsonl` file and extracts the sessionId from the filename, then caches it per cwd for subsequent events in the same Pi process and clears the entry on `session_shutdown` reasons `new`/`resume`/`fork` so cross-session misattribution can't happen. With this fix, `PreToolUse` / `PostToolUse` / `Stop` / `SessionEnd` records now carry the sessionId so dashboard rows can deep-link to the session viewer. `SessionStart` and `UserPromptSubmit` remain unsessioned because Pi flushes the transcript file lazily, after those events fire — that's a Pi behavior we can't change client-side. Pi's encoding strips the leading `/` before replacing remaining slashes with `-`, so `/home/u/repo` → `--home-u-repo--` (NOT `---home-u-repo--`). New unit test (`__tests__/hooks/pi-extension-shim.test.ts`) covers happy path, multi-file mtime tie-breaker, missing-cwd fallback, resolution from every event type, and the per-cwd cache reset on session_shutdown (#284). - Cursor integration: surface sessions stored under cursor-agent's current on-disk layout. As of cursor-agent 2026-04+, transcripts live at `~/.cursor/projects//agent-transcripts//.jsonl` (with the JSONL records using the OpenAI-shape `{role, message: {content: [{type, text}]}}` rather than the legacy `{type, data, timestamp}` form). `lib/cursor-projects.ts` and `lib/cursor-sessions.ts` previously only probed the legacy `~/.cursor/{agent-sessions,conversations,sessions}/` paths so every recent Cursor session 404'd from the dashboard. Both modules now scan the new layout first (and decode the cwd from the encoded project-dir name, prepending `/` since Cursor's encoding drops the leading slash), then fall back to the legacy candidates for older installs. The transcript parser learned a branch for the new shape — strips the synthesized `` wrapper Cursor adds to user messages, preserves assistant text blocks, and synthesizes per-record sort timestamps since the new format omits them. Verified live on cursor-agent v2026.04.29 against a real session that the dashboard had been falsely tagging as "Claude Code" with "Session log file not found". `lib/gemini-projects.ts` now uses `encodeFolderName(cwd)` for `ProjectFolder.name` so cross-CLI merge in `mergeProjectFolders` unions on the same key and Gemini-only project links resolve through `getGeminiSessionsByEncodedName`. `policy-evaluator.ts` preserves the raw CLI `--hook` arg via a new `SessionMetadata.rawHookEventName` field captured in `handler.ts` before canonicalization, so Gemini's `hookSpecificOutput.hookEventName` round-trips correctly even when stdin omits `hook_event_name`; deny-message construction now branches on event type so non-tool events (UserPromptSubmit / SessionStart / SessionEnd / Stop) emit "Blocked prompt|session start|…" instead of the misleading "Blocked unknown tool". `lib/gemini-sessions.ts` loosens `SESSION_FILE_RE` to accept any timestamp shape (Gemini docs include seconds; the load-bearing safety check is the first-line `sessionId` validation) and replaces the whole-file `readFileSync` in `findGeminiTranscript` with a bounded 4 KB `readFirstLineSync` helper so large transcripts no longer blow memory just to inspect the metadata header. `__tests__/lib/projects.test.ts` adds three Gemini aggregation tests (Gemini-only inclusion, cross-CLI merge by encoded slug, reject-fallback) mirroring the existing Pi / Cursor / OpenCode patterns (#277). - `block-read-outside-cwd`: deny message now says "Reading agent settings file blocked" instead of "Reading Claude settings file blocked" — the policy has covered all 6 CLIs' settings files since #270 / #245 / #220 but the deny string was stale (#270). - `require-ci-green-before-stop`: stop reporting historical CI failures as still-failing after a fix commit lands. The policy now filters `gh run list` results to runs whose `headSha` matches the current local HEAD, and deduplicates by workflow name so GitHub's "Re-run all jobs" doesn't resurface old failed run records. Also bumps the gh-run-list `--limit` from 5 to 20 to avoid truncating the latest run on busy branches with many workflows or recent pushes. Third-party checks (CodeRabbit, SonarCloud, …) and commit statuses already query by SHA and are unchanged. Resolves a wedge where a green PR could not satisfy the Stop policy because an earlier failed run on the same branch was still in the top-5 window. (#266) diff --git a/__tests__/hooks/pi-extension-shim.test.ts b/__tests__/hooks/pi-extension-shim.test.ts index 19df31d8..508facf3 100644 --- a/__tests__/hooks/pi-extension-shim.test.ts +++ b/__tests__/hooks/pi-extension-shim.test.ts @@ -101,6 +101,41 @@ describe("pi-extension shim — sessionId resolution via on-disk discovery", () expect(captured.at(-1)?.payload.session_id).toBe(sid); }); + it("clears the per-cwd cache on session_shutdown reason=new/resume/fork", () => { + const sid1 = "11111111-1111-1111-1111-111111111111"; + const sid2 = "22222222-2222-2222-2222-222222222222"; + writeSessionFile("/proj", sid1, "2026-05-01T00-00-00-000Z", new Date("2026-05-01T00:00:00Z")); + handlers.tool_call({ type: "tool_call", toolName: "bash", input: {}, cwd: "/proj" }); + expect(captured.at(-1)?.payload.session_id).toBe(sid1); + // Pi shuts down with reason="new" — next event in same process should + // re-discover (a newer file got added) instead of returning the cache. + handlers.session_shutdown({ type: "session_shutdown", reason: "new", cwd: "/proj" }); + writeSessionFile("/proj", sid2, "2026-05-04T00-00-00-000Z", new Date("2026-05-04T00:00:00Z")); + handlers.tool_call({ type: "tool_call", toolName: "bash", input: {}, cwd: "/proj" }); + expect(captured.at(-1)?.payload.session_id).toBe(sid2); + }); + + it("does NOT clear the cache on session_shutdown reason=quit", () => { + const sid = "33333333-3333-3333-3333-333333333333"; + writeSessionFile("/proj", sid); + handlers.tool_call({ type: "tool_call", toolName: "bash", input: {}, cwd: "/proj" }); + expect(captured.at(-1)?.payload.session_id).toBe(sid); + handlers.session_shutdown({ type: "session_shutdown", reason: "quit", cwd: "/proj" }); + // After quit, the SessionEnd record itself uses the cached id (good). + expect(captured.at(-1)?.payload.session_id).toBe(sid); + }); + + it("isolates caches per cwd — sessionId from /proj-A doesn't bleed into /proj-B", () => { + const a = "44444444-4444-4444-4444-444444444444"; + const b = "55555555-5555-5555-5555-555555555555"; + writeSessionFile("/proj-A", a); + writeSessionFile("/proj-B", b); + handlers.tool_call({ type: "tool_call", toolName: "bash", input: {}, cwd: "/proj-A" }); + expect(captured.at(-1)?.payload.session_id).toBe(a); + handlers.tool_call({ type: "tool_call", toolName: "bash", input: {}, cwd: "/proj-B" }); + expect(captured.at(-1)?.payload.session_id).toBe(b); + }); + // Cleanup afterEach(() => { if (originalEnv === undefined) delete process.env.PI_SESSIONS_DIR; diff --git a/pi-extension/index.ts b/pi-extension/index.ts index 63b386a8..c9830a81 100644 --- a/pi-extension/index.ts +++ b/pi-extension/index.ts @@ -165,18 +165,29 @@ function discoverPiSessionId(cwd: string): string | undefined { return best?.sessionId; } -let cachedSessionId: string | undefined; +/** sessionId cache, keyed by cwd. Per-cwd so a multi-cwd Pi (extension running + * across multiple workspace roots) can't cross-attribute. Cleared on + * session_shutdown reasons `new`/`resume`/`fork` (Pi reuses the process). */ +const cachedSessionIdByCwd = new Map(); function resolveSessionId(eventSessionId: string | undefined, cwd: string): string | undefined { if (eventSessionId) { - cachedSessionId = eventSessionId; + cachedSessionIdByCwd.set(cwd, eventSessionId); return eventSessionId; } - if (cachedSessionId) return cachedSessionId; + const cached = cachedSessionIdByCwd.get(cwd); + if (cached) return cached; // Pi v0.71.1 never sets sessionId — discover from disk. const discovered = discoverPiSessionId(cwd); - if (discovered) cachedSessionId = discovered; + if (discovered) cachedSessionIdByCwd.set(cwd, discovered); return discovered; } +/** Clear the cached sessionId for a cwd. Called on session_shutdown reasons + * that indicate a new session is starting in the same process (`new`, + * `resume`, `fork`). Without this, the next session would inherit the prior + * sessionId until disk discovery refreshed it. */ +function resetSessionIdCache(cwd: string): void { + cachedSessionIdByCwd.delete(cwd); +} interface PiUserBashEvent { type?: string; @@ -325,15 +336,23 @@ export default function failproofaiBridge(pi: PiExtensionApi) { }); // session_shutdown → SessionEnd. Observation-only; emits a SessionEnd - // record so per-session telemetry has a clean close. + // record so per-session telemetry has a clean close. Reset the per-cwd + // sessionId cache for shutdown reasons that mean "Pi is starting a new + // session in the same process" — without the reset, the next session's + // events would inherit the prior session's id until disk discovery + // refreshed it. pi.on("session_shutdown", (event: unknown): unknown => { const e = event as PiSessionShutdownEvent; + const cwd = resolveCwd(e.cwd); callPolicy("session_shutdown", { - session_id: resolveSessionId(e.sessionId, resolveCwd(e.cwd)), - cwd: resolveCwd(e.cwd), + session_id: resolveSessionId(e.sessionId, cwd), + cwd, reason: e.reason, hook_event_name: "SessionEnd", }); + if (e.reason === "new" || e.reason === "resume" || e.reason === "fork") { + resetSessionIdCache(cwd); + } return undefined; }); } From b3b55c607d21baf7bc29cf4cde6b0d6e01eb983a Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Mon, 4 May 2026 14:12:23 -0700 Subject: [PATCH 4/4] fix: ignore stale Pi transcripts in cold-start sessionId discovery CodeRabbit follow-up on #284: If the shim starts in a cwd that already has older Pi transcripts, the first event arriving before the new session's JSONL exists would select the prior session file and pin its UUID for the entire run. Now we capture PROCESS_START_MS at module load and only consider files whose mtime is at least (PROCESS_START_MS - 2s tolerance). New regression test confirms cold-start with an old transcript returns undefined until the current session's file appears. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 2 +- __tests__/hooks/pi-extension-shim.test.ts | 39 ++++++++++++++++++++--- pi-extension/index.ts | 19 +++++++++-- 3 files changed, 53 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0e7a00e..bde0c0a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ - 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). ### Fixes -- Pi integration: surface `sessionId` on activity records by discovering it from Pi's on-disk transcript filename. Pi (verified empirically against pi-coding-agent v0.71.1) does NOT populate `event.sessionId` on any of its events — `session_start`, `tool_call`, `user_bash`, `input`, `tool_result`, `agent_end`, `session_shutdown` all leave it undefined. The shim now scans `~/.pi/agent/sessions/----/` for the most-recent `_.jsonl` file and extracts the sessionId from the filename, then caches it per cwd for subsequent events in the same Pi process and clears the entry on `session_shutdown` reasons `new`/`resume`/`fork` so cross-session misattribution can't happen. With this fix, `PreToolUse` / `PostToolUse` / `Stop` / `SessionEnd` records now carry the sessionId so dashboard rows can deep-link to the session viewer. `SessionStart` and `UserPromptSubmit` remain unsessioned because Pi flushes the transcript file lazily, after those events fire — that's a Pi behavior we can't change client-side. Pi's encoding strips the leading `/` before replacing remaining slashes with `-`, so `/home/u/repo` → `--home-u-repo--` (NOT `---home-u-repo--`). New unit test (`__tests__/hooks/pi-extension-shim.test.ts`) covers happy path, multi-file mtime tie-breaker, missing-cwd fallback, resolution from every event type, and the per-cwd cache reset on session_shutdown (#284). +- Pi integration: surface `sessionId` on activity records by discovering it from Pi's on-disk transcript filename. Pi (verified empirically against pi-coding-agent v0.71.1) does NOT populate `event.sessionId` on any of its events — `session_start`, `tool_call`, `user_bash`, `input`, `tool_result`, `agent_end`, `session_shutdown` all leave it undefined. The shim now scans `~/.pi/agent/sessions/----/` for the most-recent `_.jsonl` file (filtering to files whose mtime ≥ process start so a stale transcript from a prior session in the same cwd can't pin a wrong UUID at cold start) and extracts the sessionId from the filename, then caches it per cwd for subsequent events in the same Pi process and clears the entry on `session_shutdown` reasons `new`/`resume`/`fork` so cross-session misattribution can't happen. With this fix, `PreToolUse` / `PostToolUse` / `Stop` / `SessionEnd` records now carry the sessionId so dashboard rows can deep-link to the session viewer. `SessionStart` and `UserPromptSubmit` remain unsessioned because Pi flushes the transcript file lazily, after those events fire — that's a Pi behavior we can't change client-side. Pi's encoding strips the leading `/` before replacing remaining slashes with `-`, so `/home/u/repo` → `--home-u-repo--` (NOT `---home-u-repo--`). New unit test (`__tests__/hooks/pi-extension-shim.test.ts`) covers happy path, multi-file mtime tie-breaker, missing-cwd fallback, resolution from every event type, and the per-cwd cache reset on session_shutdown (#284). - Cursor integration: surface sessions stored under cursor-agent's current on-disk layout. As of cursor-agent 2026-04+, transcripts live at `~/.cursor/projects//agent-transcripts//.jsonl` (with the JSONL records using the OpenAI-shape `{role, message: {content: [{type, text}]}}` rather than the legacy `{type, data, timestamp}` form). `lib/cursor-projects.ts` and `lib/cursor-sessions.ts` previously only probed the legacy `~/.cursor/{agent-sessions,conversations,sessions}/` paths so every recent Cursor session 404'd from the dashboard. Both modules now scan the new layout first (and decode the cwd from the encoded project-dir name, prepending `/` since Cursor's encoding drops the leading slash), then fall back to the legacy candidates for older installs. The transcript parser learned a branch for the new shape — strips the synthesized `` wrapper Cursor adds to user messages, preserves assistant text blocks, and synthesizes per-record sort timestamps since the new format omits them. Verified live on cursor-agent v2026.04.29 against a real session that the dashboard had been falsely tagging as "Claude Code" with "Session log file not found". `lib/gemini-projects.ts` now uses `encodeFolderName(cwd)` for `ProjectFolder.name` so cross-CLI merge in `mergeProjectFolders` unions on the same key and Gemini-only project links resolve through `getGeminiSessionsByEncodedName`. `policy-evaluator.ts` preserves the raw CLI `--hook` arg via a new `SessionMetadata.rawHookEventName` field captured in `handler.ts` before canonicalization, so Gemini's `hookSpecificOutput.hookEventName` round-trips correctly even when stdin omits `hook_event_name`; deny-message construction now branches on event type so non-tool events (UserPromptSubmit / SessionStart / SessionEnd / Stop) emit "Blocked prompt|session start|…" instead of the misleading "Blocked unknown tool". `lib/gemini-sessions.ts` loosens `SESSION_FILE_RE` to accept any timestamp shape (Gemini docs include seconds; the load-bearing safety check is the first-line `sessionId` validation) and replaces the whole-file `readFileSync` in `findGeminiTranscript` with a bounded 4 KB `readFirstLineSync` helper so large transcripts no longer blow memory just to inspect the metadata header. `__tests__/lib/projects.test.ts` adds three Gemini aggregation tests (Gemini-only inclusion, cross-CLI merge by encoded slug, reject-fallback) mirroring the existing Pi / Cursor / OpenCode patterns (#277). - `block-read-outside-cwd`: deny message now says "Reading agent settings file blocked" instead of "Reading Claude settings file blocked" — the policy has covered all 6 CLIs' settings files since #270 / #245 / #220 but the deny string was stale (#270). - `require-ci-green-before-stop`: stop reporting historical CI failures as still-failing after a fix commit lands. The policy now filters `gh run list` results to runs whose `headSha` matches the current local HEAD, and deduplicates by workflow name so GitHub's "Re-run all jobs" doesn't resurface old failed run records. Also bumps the gh-run-list `--limit` from 5 to 20 to avoid truncating the latest run on busy branches with many workflows or recent pushes. Third-party checks (CodeRabbit, SonarCloud, …) and commit statuses already query by SHA and are unchanged. Resolves a wedge where a green PR could not satisfy the Stop policy because an earlier failed run on the same branch was still in the top-5 window. (#266) diff --git a/__tests__/hooks/pi-extension-shim.test.ts b/__tests__/hooks/pi-extension-shim.test.ts index 508facf3..6bd6a96e 100644 --- a/__tests__/hooks/pi-extension-shim.test.ts +++ b/__tests__/hooks/pi-extension-shim.test.ts @@ -75,8 +75,11 @@ describe("pi-extension shim — sessionId resolution via on-disk discovery", () }); it("picks the newest file when multiple sessions exist for a cwd", () => { - writeSessionFile("/proj", "33333333-3333-3333-3333-333333333333", "2026-05-01T00-00-00-000Z", new Date("2026-05-01T00:00:00Z")); - writeSessionFile("/proj", "44444444-4444-4444-4444-444444444444", "2026-05-04T20-00-00-000Z", new Date("2026-05-04T20:00:00Z")); + // Both files must be after PROCESS_START_MS (- 2s tolerance) to be + // considered current; we still want one to be older than the other. + const now = Date.now(); + writeSessionFile("/proj", "33333333-3333-3333-3333-333333333333", "2026-05-01T00-00-00-000Z", new Date(now)); + writeSessionFile("/proj", "44444444-4444-4444-4444-444444444444", "2026-05-04T20-00-00-000Z", new Date(now + 5_000)); handlers.tool_call({ type: "tool_call", toolName: "bash", input: {}, cwd: "/proj" }); expect(captured.at(-1)?.payload.session_id).toBe("44444444-4444-4444-4444-444444444444"); }); @@ -104,13 +107,14 @@ describe("pi-extension shim — sessionId resolution via on-disk discovery", () it("clears the per-cwd cache on session_shutdown reason=new/resume/fork", () => { const sid1 = "11111111-1111-1111-1111-111111111111"; const sid2 = "22222222-2222-2222-2222-222222222222"; - writeSessionFile("/proj", sid1, "2026-05-01T00-00-00-000Z", new Date("2026-05-01T00:00:00Z")); + const now = Date.now(); + writeSessionFile("/proj", sid1, "2026-05-01T00-00-00-000Z", new Date(now)); handlers.tool_call({ type: "tool_call", toolName: "bash", input: {}, cwd: "/proj" }); expect(captured.at(-1)?.payload.session_id).toBe(sid1); // Pi shuts down with reason="new" — next event in same process should // re-discover (a newer file got added) instead of returning the cache. handlers.session_shutdown({ type: "session_shutdown", reason: "new", cwd: "/proj" }); - writeSessionFile("/proj", sid2, "2026-05-04T00-00-00-000Z", new Date("2026-05-04T00:00:00Z")); + writeSessionFile("/proj", sid2, "2026-05-04T00-00-00-000Z", new Date(now + 10_000)); handlers.tool_call({ type: "tool_call", toolName: "bash", input: {}, cwd: "/proj" }); expect(captured.at(-1)?.payload.session_id).toBe(sid2); }); @@ -125,6 +129,33 @@ describe("pi-extension shim — sessionId resolution via on-disk discovery", () expect(captured.at(-1)?.payload.session_id).toBe(sid); }); + it("ignores stale pre-existing transcripts on cold start (CodeRabbit follow-up)", async () => { + // Set up: transcript dir already has an OLD session file, mtime well + // before this test's module-load time. The shim must NOT cache that + // UUID — every event before the current session's file appears should + // return undefined, and only the new file should populate the cache. + const oldSid = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + const newSid = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"; + // Old file: mtime ~1 hour before now (well past STALE_TOLERANCE_MS=2s). + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); + writeSessionFile("/cold-start-cwd", oldSid, "2026-05-01T00-00-00-000Z", oneHourAgo); + + // Re-import the shim so PROCESS_START_MS is "now" relative to the old file. + vi.resetModules(); + const mod = await import("../../pi-extension/index"); + const handlers2: Record unknown> = {}; + mod.default({ on: (name, fn) => { handlers2[name] = fn; } }); + captured.length = 0; + + handlers2.tool_call({ type: "tool_call", toolName: "bash", input: {}, cwd: "/cold-start-cwd" }); + expect(captured.at(-1)?.payload.session_id).toBeUndefined(); + + // Now Pi creates the current session's file (mtime "now"). + writeSessionFile("/cold-start-cwd", newSid, "2026-05-04T20-00-00-000Z", new Date()); + handlers2.tool_call({ type: "tool_call", toolName: "bash", input: {}, cwd: "/cold-start-cwd" }); + expect(captured.at(-1)?.payload.session_id).toBe(newSid); + }); + it("isolates caches per cwd — sessionId from /proj-A doesn't bleed into /proj-B", () => { const a = "44444444-4444-4444-4444-444444444444"; const b = "55555555-5555-5555-5555-555555555555"; diff --git a/pi-extension/index.ts b/pi-extension/index.ts index c9830a81..e70df837 100644 --- a/pi-extension/index.ts +++ b/pi-extension/index.ts @@ -146,20 +146,35 @@ function piEncodeCwd(cwd: string): string { return `--${inner}--`; } +/** Process start boundary — files older than this aren't from the current + * Pi session. Captured at module load so cold-start in a cwd with stale + * transcripts doesn't pin a previous session's UUID. We allow a small + * tolerance below `processStartMs` because mtime resolution and clock + * skew can put a "current" file's mtime a few hundred ms before module + * load on slow startup. */ +const PROCESS_START_MS = Date.now(); +const STALE_TOLERANCE_MS = 2_000; + /** Find the newest `_.jsonl` file under `~/.pi/agent/sessions//` - * and return its sessionId. Returns undefined when the dir doesn't exist or - * has no matching file. */ + * whose mtime indicates it belongs to the CURRENT Pi process (≥ process + * start, with a small tolerance). Files older than that are stale + * transcripts from prior sessions in the same cwd — caching their UUID + * would cross-attribute every event of the new session. + * Returns undefined when the dir doesn't exist, has no matching file, or + * every matching file is stale. */ function discoverPiSessionId(cwd: string): string | undefined { const root = process.env.PI_SESSIONS_DIR || join(homedir(), ".pi", "agent", "sessions"); const dir = join(root, piEncodeCwd(cwd)); let entries: string[]; try { entries = readdirSync(dir); } catch { return undefined; } + const boundary = PROCESS_START_MS - STALE_TOLERANCE_MS; let best: { sessionId: string; mtime: number } | undefined; for (const name of entries) { const m = PI_FILE_RE.exec(name); if (!m) continue; let mtime: number; try { mtime = statSync(join(dir, name)).mtimeMs; } catch { continue; } + if (mtime < boundary) continue; if (!best || mtime > best.mtime) best = { sessionId: m[1], mtime }; } return best?.sessionId;