Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: 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/--<encodedCwd>--/` for the most-recent `<isoTimestamp>_<uuid>.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/<encoded-cwd>/agent-transcripts/<sessionId>/<sessionId>.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 `<timestamp>…</timestamp><user_query>…</user_query>` 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)
Expand Down
147 changes: 147 additions & 0 deletions __tests__/hooks/pi-extension-shim.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/**
* 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/<encodedCwd>/<isoTimestamp>_<uuid>.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<string, unknown>;
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: "" };
},
}));

function piEncodeCwd(cwd: string): string {
return `--${cwd.replace(/^\/+/, "").replace(/\//g, "-")}--`;
}

describe("pi-extension shim — sessionId resolution via on-disk discovery", () => {
let handlers: Record<string, (event: unknown) => 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; } });
});

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" });
expect(captured.at(-1)?.payload.session_id).toBe(sid);
});

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("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(sid);
handlers.tool_result({ type: "tool_result", toolName: "bash", input: {}, content: [], isError: false, cwd: "/proj" });
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(sid);
handlers.session_shutdown({ type: "session_shutdown", reason: "quit", cwd: "/proj" });
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;
else process.env.PI_SESSIONS_DIR = originalEnv;
rmSync(piRoot, { recursive: true, force: true });
});
});

import { afterEach } from "vitest";
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
101 changes: 90 additions & 11 deletions pi-extension/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -118,6 +119,76 @@ function resolveCwd(eventCwd: string | undefined): string {
return eventCwd ?? process.cwd();
}

/**
* 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.
*
* What Pi DOES do: at session start it creates a JSONL transcript at
* `~/.pi/agent/sessions/<encodedCwd>/<isoTimestamp>_<uuid>.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 `<ts>_<uuid>.jsonl` file under `~/.pi/agent/sessions/<encodedCwd>/`
* 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;
}

/** 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<string, string>();
function resolveSessionId(eventSessionId: string | undefined, cwd: string): string | undefined {
if (eventSessionId) {
cachedSessionIdByCwd.set(cwd, eventSessionId);
return eventSessionId;
}
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) cachedSessionIdByCwd.set(cwd, discovered);
return discovered;
Comment thread
NiveditJain marked this conversation as resolved.
Resolved
}
/** 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;
command?: string;
Expand Down Expand Up @@ -180,7 +251,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, resolveCwd(e.cwd)),
cwd: resolveCwd(e.cwd),
hook_event_name: "PreToolUse",
});
Expand All @@ -194,7 +265,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, resolveCwd(e.cwd)),
cwd: resolveCwd(e.cwd),
hook_event_name: "PreToolUse",
});
Expand All @@ -208,7 +279,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, resolveCwd(e.cwd)),
cwd: resolveCwd(e.cwd),
hook_event_name: "UserPromptSubmit",
});
Expand All @@ -222,7 +293,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, resolveCwd(e.cwd)),
cwd: resolveCwd(e.cwd),
reason: e.reason,
hook_event_name: "SessionStart",
Expand All @@ -242,7 +313,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, resolveCwd(e.cwd)),
cwd: resolveCwd(e.cwd),
hook_event_name: "PostToolUse",
});
Expand All @@ -257,23 +328,31 @@ 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, resolveCwd(e.cwd)),
cwd: resolveCwd(e.cwd),
hook_event_name: "Stop",
});
return undefined;
});

// 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: e.sessionId,
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;
});
}