Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -3,6 +3,7 @@
## Unreleased

### Fixes
- Activity dashboard: populate the `Transcript:` field across every harness, not just Claude. Only Claude's hook stdin reliably carries `transcript_path`; Codex/Copilot/Cursor don't include one, the OpenCode and Pi shims don't forward one, and Gemini's coverage is uneven across versions, so before this fix the `/policies` activity detail panel rendered `Transcript: —` for nearly every non-Claude row even though every harness *except* OpenCode has the transcript on disk and the repo already shipped per-CLI `find*Transcript(sessionId)` helpers (`lib/codex-sessions.ts`, `lib/copilot-sessions.ts`, `lib/cursor-sessions.ts`, `lib/pi-sessions.ts`, `lib/gemini-sessions.ts`). New `src/hooks/resolve-transcript-path.ts` mirrors the existing `resolve-permission-mode.ts` dispatch pattern: trust `parsed.transcript_path` from stdin first, then fall back to the per-CLI helper when sessionId is known. OpenCode (transcripts in `~/.local/share/opencode/opencode.db`, no on-disk file) gets a synthetic `opencode-db://<sessionId>` marker so the field is non-empty, distinguishable from a genuine miss, and parseable by tooling — both detail panels (`app/components/session-hooks-panel.tsx`, `app/policies/hooks-client.tsx`) render an extra muted "(stored in opencode DB)" suffix when the value carries that scheme. The handler-side fallback covers OpenCode and Pi without touching their shims (no duplicated disk-walk; the Pi shim already discovers session IDs from disk for related reasons in `pi-extension/index.ts:discoverPiSessionId`). New `__tests__/hooks/resolve-transcript-path.test.ts` is a 23-case matrix: stdin-passthrough for all 7 CLIs, missing-sessionId returns undefined for all 7, per-CLI fallback dispatch, and stdin-precedence-beats-fallback (#296).
- Canonicalize tool names across all agent CLIs so builtin Bash/Read/Write/Edit policies fire under Copilot, OpenCode, and Pi (verified for Codex/Cursor/Gemini). Builtin policies match tool names in PascalCase (`["Bash"]`, `["Read","Glob","Grep","Bash"]`, …) via case-sensitive `Array.includes`, but Copilot's tool registry emits lowercase IDs (`bash`, `read`, …) and OpenCode's plugin SDK exposes the same. Without canonicalization every Bash/Read/Write/Edit builtin silently no-ops under those CLIs. Adds `COPILOT_TOOL_MAP` (handler-side) and `OPENCODE_TOOL_MAP` / `PI_TOOL_MAP` (shim-side, embedded inline in the self-contained plugin shims). User-reported regression: under Copilot CLI, `ls -la --almost-all $HOME | sed -n '1,200p'` ran successfully despite an enabled `block-read-outside-cwd` policy. Also fixes the Pi shim's naive `charAt(0).toUpperCase()` heuristic which only worked for single-word tool IDs (`bash` → `Bash`) but would have mis-canonicalized future multi-word tools (`todo_write` → `Todo_write`, not `TodoWrite`). E2e fixture for Copilot now uses the real lowercase shape so the suite catches future regressions in this layer (#293).
- Session log viewer: stop rendering log entries at the wrong y-offset (which exposed the page background and looked like the page "going blue" while scrolling). `app/components/raw-log-viewer.tsx` was capturing the virtualizer's `scrollMargin` once via a callback ref on the list wrapper's `offsetTop`. That ref fires only on mount, so any layout shift above the list — most commonly the async `searchHookActivityAction` resolving and mounting the `<SessionHooksPanel>` panel above the Logs section, but also Subagents-section / per-subagent collapse-expand, and window resizes that re-flow the StatsBar / ToolStatsGrid — left `scrollMargin` stale. The `useWindowVirtualizer` then computed the wrong visible window in list-local coordinates and positioned each item at `transform: translateY(virtualRow.start - staleScrollMargin)`, so items appeared shifted by the layout-delta (typically tens to hundreds of pixels). Replace the callback ref with a stable `useRef<HTMLDivElement>` and a `useLayoutEffect` that reads `getBoundingClientRect().top + window.scrollY` (more robust than `offsetTop` against future positioned ancestors) and re-reads it from a `ResizeObserver` watching `document.body` plus a `window` resize listener. Functional `setScrollMargin(prev => prev === top ? prev : top)` short-circuits same-value updates so the body-resize that the state-update itself causes can't loop. (#292)
- Detect when `failproofai` on the user's PATH is shadowed by a different, older install (classic cause: a leftover `bun link` from a prior dev session, or a previously-installed `bun install -g failproofai` whose `~/.bun/bin` prefix sorts ahead of npm's). New `scripts/install-diagnosis.mjs` helper resolves the PATH-first install via `command -v` (POSIX) / `where` (Win32), compares its package root + version against the running install, and surfaces a copy-pasteable cleanup command (`rm -f ~/.bun/bin/failproofai && rm -rf ~/.bun/install/global/node_modules/failproofai` for bun-side shadows, `npm rm -g failproofai` for npm-side ones). Wired into two places: (1) `scripts/postinstall.mjs` warns at install time when the just-installed copy is being shadowed, before the customer ever sees the runtime error, (2) `scripts/launch.ts` rewrites the existing "Cannot find server.js at" error to point at the actual stale install (with both versions and the cleanup command) when the missing build output is caused by a PATH shadow rather than a genuinely broken build. Replaces the previous misleading recommendation (`npm install -g failproofai@latest`) which doesn't help when the new install is itself being shadowed (#286).
Expand Down
137 changes: 137 additions & 0 deletions __tests__/hooks/resolve-transcript-path.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// @vitest-environment node
import { describe, it, expect, vi, beforeEach } from "vitest";

vi.mock("../../lib/codex-sessions", () => ({
findCodexTranscript: vi.fn(),
}));
vi.mock("../../lib/copilot-sessions", () => ({
findCopilotTranscript: vi.fn(),
}));
vi.mock("../../lib/cursor-sessions", () => ({
findCursorTranscript: vi.fn(),
}));
vi.mock("../../lib/pi-sessions", () => ({
findPiTranscript: vi.fn(),
}));
vi.mock("../../lib/gemini-sessions", () => ({
findGeminiTranscript: vi.fn(),
}));

import { resolveTranscriptPath } from "../../src/hooks/resolve-transcript-path";
import { findCodexTranscript } from "../../lib/codex-sessions";
import { findCopilotTranscript } from "../../lib/copilot-sessions";
import { findCursorTranscript } from "../../lib/cursor-sessions";
import { findPiTranscript } from "../../lib/pi-sessions";
import { findGeminiTranscript } from "../../lib/gemini-sessions";
import type { IntegrationType } from "../../src/hooks/types";

describe("resolveTranscriptPath", () => {
beforeEach(() => {
vi.clearAllMocks();
});

describe("stdin passthrough (every CLI)", () => {
const cases: IntegrationType[] = [
"claude",
"codex",
"copilot",
"cursor",
"pi",
"gemini",
"opencode",
];
it.each(cases)(
"returns parsed.transcript_path verbatim when set (%s)",
(cli) => {
const out = resolveTranscriptPath(
cli,
{ transcript_path: "/explicit/path.jsonl" },
"sess-1",
);
expect(out).toBe("/explicit/path.jsonl");
},
);
});

describe("missing sessionId", () => {
const cases: IntegrationType[] = [
"claude",
"codex",
"copilot",
"cursor",
"pi",
"gemini",
"opencode",
];
it.each(cases)("returns undefined when no stdin path AND no sessionId (%s)", (cli) => {
const out = resolveTranscriptPath(cli, {}, undefined);
expect(out).toBeUndefined();
});
});

describe("per-CLI fallback discovery (no stdin transcript_path)", () => {
it("claude → undefined (Claude already provides it on stdin; no disk fallback)", () => {
const out = resolveTranscriptPath("claude", {}, "sess-claude");
expect(out).toBeUndefined();
});

it("codex → findCodexTranscript(sessionId)", () => {
vi.mocked(findCodexTranscript).mockReturnValue("/home/u/.codex/sessions/2026/05/05/x.jsonl");
const out = resolveTranscriptPath("codex", {}, "sess-codex");
expect(findCodexTranscript).toHaveBeenCalledWith("sess-codex");
expect(out).toBe("/home/u/.codex/sessions/2026/05/05/x.jsonl");
});

it("codex → undefined when helper returns null", () => {
vi.mocked(findCodexTranscript).mockReturnValue(null);
const out = resolveTranscriptPath("codex", {}, "sess-codex");
expect(out).toBeUndefined();
});

it("copilot → findCopilotTranscript(sessionId)", () => {
vi.mocked(findCopilotTranscript).mockReturnValue("/home/u/.copilot/session-state/sess-copilot/events.jsonl");
const out = resolveTranscriptPath("copilot", {}, "sess-copilot");
expect(findCopilotTranscript).toHaveBeenCalledWith("sess-copilot");
expect(out).toBe("/home/u/.copilot/session-state/sess-copilot/events.jsonl");
});

it("cursor → findCursorTranscript(sessionId)", () => {
vi.mocked(findCursorTranscript).mockReturnValue("/home/u/.cursor/projects/x/agent-transcripts/sess-cursor/sess-cursor.jsonl");
const out = resolveTranscriptPath("cursor", {}, "sess-cursor");
expect(findCursorTranscript).toHaveBeenCalledWith("sess-cursor");
expect(out).toBe("/home/u/.cursor/projects/x/agent-transcripts/sess-cursor/sess-cursor.jsonl");
});

it("pi → findPiTranscript(sessionId)", () => {
vi.mocked(findPiTranscript).mockReturnValue("/home/u/.pi/agent/sessions/encoded/2026-05-05_sess-pi.jsonl");
const out = resolveTranscriptPath("pi", {}, "sess-pi");
expect(findPiTranscript).toHaveBeenCalledWith("sess-pi");
expect(out).toBe("/home/u/.pi/agent/sessions/encoded/2026-05-05_sess-pi.jsonl");
});

it("gemini → findGeminiTranscript(sessionId)", () => {
vi.mocked(findGeminiTranscript).mockReturnValue("/home/u/.gemini/tmp/projhash/chats/sess-gemini.json");
const out = resolveTranscriptPath("gemini", {}, "sess-gemini");
expect(findGeminiTranscript).toHaveBeenCalledWith("sess-gemini");
expect(out).toBe("/home/u/.gemini/tmp/projhash/chats/sess-gemini.json");
});

it("opencode → synthetic opencode-db://<sessionId> marker (transcripts live in SQLite)", () => {
const out = resolveTranscriptPath("opencode", {}, "ses_test_opencode001");
expect(out).toBe("opencode-db://ses_test_opencode001");
});
});

describe("stdin precedence beats fallback", () => {
it("trusts stdin even when discovery would have returned a different path", () => {
vi.mocked(findCopilotTranscript).mockReturnValue("/from/disk.jsonl");
const out = resolveTranscriptPath(
"copilot",
{ transcript_path: "/from/stdin.jsonl" },
"sess-x",
);
expect(out).toBe("/from/stdin.jsonl");
expect(findCopilotTranscript).not.toHaveBeenCalled();
});
});
});
3 changes: 3 additions & 0 deletions app/components/session-hooks-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,9 @@ function SessionHooksDetailPanel({
<span className="font-mono text-foreground break-all">
{item.transcriptPath ?? "\u2014"}
</span>
{item.transcriptPath?.startsWith("opencode-db://") && (
<span className="text-muted-foreground ml-1">(stored in opencode DB)</span>
)}
</div>
</div>
{item.policyNames && item.policyNames.length > 1 && (
Expand Down
3 changes: 3 additions & 0 deletions app/policies/hooks-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,9 @@ function DetailPanel({
<span className="font-mono text-foreground break-all">
{item.transcriptPath ?? "\u2014"}
</span>
{item.transcriptPath?.startsWith("opencode-db://") && (
<span className="text-muted-foreground ml-1">(stored in opencode DB)</span>
)}
</div>
</div>
{item.policyNames && item.policyNames.length > 1 && (
Expand Down
3 changes: 2 additions & 1 deletion src/hooks/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type { CustomHook } from "./policy-types";
import { persistHookActivity } from "./hook-activity-store";
import { trackHookEvent } from "./hook-telemetry";
import { resolvePermissionMode } from "./resolve-permission-mode";
import { resolveTranscriptPath } from "./resolve-transcript-path";
import { getInstanceId } from "../../lib/telemetry-id";
import { hookLogInfo, hookLogWarn } from "./hook-logger";

Expand Down Expand Up @@ -153,7 +154,7 @@ export async function handleHookEvent(
const sessionId = parsed.session_id as string | undefined;
const session: SessionMetadata = {
sessionId,
transcriptPath: parsed.transcript_path as string | undefined,
transcriptPath: resolveTranscriptPath(cli, parsed, sessionId),
cwd: parsed.cwd as string | undefined,
permissionMode: resolvePermissionMode(cli, parsed, sessionId),
hookEventName: parsed.hook_event_name as string | undefined,
Expand Down
68 changes: 68 additions & 0 deletions src/hooks/resolve-transcript-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* Per-CLI transcript-path resolver.
*
* • Claude Code: `transcript_path` arrives on the hook stdin payload —
* passthrough.
*
* • Codex: stdin doesn't carry transcript_path. Discover via
* ~/.codex/sessions/<YYYY>/<MM>/<DD>/<file containing sessionId>.jsonl.
*
* • Copilot: stdin doesn't carry transcript_path. Discover at
* ~/.copilot/session-state/<sessionId>/events.jsonl.
*
* • Cursor: stdin doesn't carry transcript_path. Discover under
* ~/.cursor/projects/<encoded-cwd>/agent-transcripts/<sessionId>/<sessionId>.jsonl
* (with legacy fallbacks).
*
* • OpenCode: transcripts live in SQLite at
* ~/.local/share/opencode/opencode.db, not on disk. Synthesize an
* `opencode-db://<sessionId>` marker so the dashboard renders something
* meaningful and the value is distinguishable from a genuine miss.
*
* • Pi: shim doesn't forward transcript_path. Discover at
* ~/.pi/agent/sessions/<encodedCwd>/<isoTimestamp>_<sessionId>.jsonl.
*
* • Gemini: docs say stdin carries transcript_path, but coverage is uneven
* across versions. Trust stdin first; fall back to discovery under
* ~/.gemini/tmp/<projectHash>/chats/<sessionId>.json.
*
* Mirrors the dispatch pattern of `resolve-permission-mode.ts`. Each
* `find*Transcript` helper performs its own existsSync + path-traversal
* containment check, so passing in a malformed sessionId is safe (returns
* null → undefined).
*/
import { findCodexTranscript } from "../../lib/codex-sessions";
import { findCopilotTranscript } from "../../lib/copilot-sessions";
import { findCursorTranscript } from "../../lib/cursor-sessions";
import { findPiTranscript } from "../../lib/pi-sessions";
import { findGeminiTranscript } from "../../lib/gemini-sessions";
import type { IntegrationType } from "./types";

export function resolveTranscriptPath(
integration: IntegrationType,
parsed: Record<string, unknown>,
sessionId: string | undefined,
): string | undefined {
const stdinPath = parsed.transcript_path as string | undefined;
if (stdinPath) return stdinPath;
if (!sessionId) return undefined;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

switch (integration) {
case "claude":
return undefined;
case "codex":
return findCodexTranscript(sessionId) ?? undefined;
case "copilot":
return findCopilotTranscript(sessionId) ?? undefined;
case "cursor":
return findCursorTranscript(sessionId) ?? undefined;
case "pi":
return findPiTranscript(sessionId) ?? undefined;
case "gemini":
return findGeminiTranscript(sessionId) ?? undefined;
case "opencode":
return `opencode-db://${sessionId}`;
default:
return undefined;
}
}