From 382572e03560840588223d7c0b3d78f1732c6c9a Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Tue, 28 Apr 2026 19:07:52 -0700 Subject: [PATCH 1/6] [projects] surface OpenAI Codex projects on /projects Codex stores transcripts under ~/.codex/sessions////*.jsonl keyed by date, not cwd, so the listing showed only Claude Code projects. Scan each transcript's first-line session_meta for its cwd, group by cwd, and merge with the Claude side by encoded folder name (union cli, max mtime). Each row now carries a cli badge (Claude/Codex); cwds present in both stores render as a single row with both badges. The /project/[name] detail page lists Codex sessions for the project (recovering the canonical cwd from the transcript, since decodeFolderName is lossy when paths contain '-'); session click-through reuses the existing Codex-aware viewer. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 3 + __tests__/components/project-list.test.tsx | 41 ++++ __tests__/lib/codex-projects.test.ts | 174 ++++++++++++++ __tests__/lib/projects.test.ts | 71 +++++- app/components/project-list.tsx | 36 ++- app/project/[name]/page.tsx | 69 ++++-- lib/codex-projects.ts | 249 +++++++++++++++++++++ lib/projects.ts | 54 ++++- 8 files changed, 666 insertions(+), 31 deletions(-) create mode 100644 __tests__/lib/codex-projects.test.ts create mode 100644 lib/codex-projects.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 74499e90..cbe44df1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Features +- Show OpenAI Codex projects on the `/projects` page alongside Claude Code projects. Codex stores transcripts under `~/.codex/sessions///
/*.jsonl` keyed by date, so we scan each transcript's `session_meta` record for its `cwd` and surface one row per unique cwd. A CLI badge ("Claude Code" / "OpenAI Codex") appears beside each agent root; cwds that exist in both stores render as a single row with both badges. The `/project/[name]` detail page now lists Codex sessions for the project (recovering the canonical cwd from the transcript, since `decodeFolderName` is lossy when paths contain `-`); session click-through reuses the existing Codex-aware viewer. + ### Fixes - Fix `mintlify validate` parse error in `docs/de/dashboard.mdx` caused by inner quotes inside `` attributes (#229) - Fix `block-read-outside-cwd` falsely denying Bash commands with unquoted glob patterns or `-v host:/path` argv tokens (#230) diff --git a/__tests__/components/project-list.test.tsx b/__tests__/components/project-list.test.tsx index 9a224f9e..770fc63f 100644 --- a/__tests__/components/project-list.test.tsx +++ b/__tests__/components/project-list.test.tsx @@ -38,6 +38,7 @@ function makeFolders(count: number): ProjectFolder[] { isDirectory: true, lastModified: new Date(Date.now() - i * 86400000), lastModifiedFormatted: `Jun ${15 - i}, 2024`, + cli: ["claude"], })); } @@ -59,12 +60,52 @@ describe("ProjectList", () => { isDirectory: true, lastModified: new Date(), lastModifiedFormatted: "Jun 15, 2024", + cli: ["claude"], }, ]; render(); expect(screen.getByText("C:/code/myapp")).toBeInTheDocument(); }); + it("renders a Claude Code badge for cli=['claude']", () => { + const folders = makeFolders(1); + render(); + expect(screen.getByText("Claude Code")).toBeInTheDocument(); + expect(screen.queryByText("OpenAI Codex")).not.toBeInTheDocument(); + }); + + it("renders an OpenAI Codex badge for cli=['codex']", () => { + const folders: ProjectFolder[] = [ + { + name: "-home-u-codex", + path: "/home/u/codex", + isDirectory: true, + lastModified: new Date(), + lastModifiedFormatted: "Jun 15, 2024", + cli: ["codex"], + }, + ]; + render(); + expect(screen.getByText("OpenAI Codex")).toBeInTheDocument(); + expect(screen.queryByText("Claude Code")).not.toBeInTheDocument(); + }); + + it("renders both badges when cli=['claude','codex']", () => { + const folders: ProjectFolder[] = [ + { + name: "-home-u-shared", + path: "/mock/.claude/projects/-home-u-shared", + isDirectory: true, + lastModified: new Date(), + lastModifiedFormatted: "Jun 15, 2024", + cli: ["claude", "codex"], + }, + ]; + render(); + expect(screen.getByText("Claude Code")).toBeInTheDocument(); + expect(screen.getByText("OpenAI Codex")).toBeInTheDocument(); + }); + it("links to /project/[name]", () => { const folders = makeFolders(1); render(); diff --git a/__tests__/lib/codex-projects.test.ts b/__tests__/lib/codex-projects.test.ts new file mode 100644 index 00000000..35ff8ce5 --- /dev/null +++ b/__tests__/lib/codex-projects.test.ts @@ -0,0 +1,174 @@ +// @vitest-environment node +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, utimesSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +const meta = (cwd: string, sessionId: string, ts = "2026-04-28T00:00:00.000Z"): string => + JSON.stringify({ + timestamp: ts, + type: "session_meta", + payload: { id: sessionId, cwd, originator: "codex-tui" }, + }); + +describe("lib/codex-projects", () => { + let originalHome: string | undefined; + let fakeHome: string; + let getCodexProjects: typeof import("@/lib/codex-projects").getCodexProjects; + let getCodexSessionsForCwd: typeof import("@/lib/codex-projects").getCodexSessionsForCwd; + let getCodexSessionsByEncodedName: typeof import("@/lib/codex-projects").getCodexSessionsByEncodedName; + + function writeRollout(date: { y: string; m: string; d: string }, sessionId: string, content: string, mtime?: Date) { + const dir = join(fakeHome, ".codex", "sessions", date.y, date.m, date.d); + mkdirSync(dir, { recursive: true }); + const file = join(dir, `rollout-${date.y}-${date.m}-${date.d}T00-00-00-${sessionId}.jsonl`); + writeFileSync(file, content); + if (mtime) utimesSync(file, mtime, mtime); + return file; + } + + beforeEach(async () => { + originalHome = process.env.HOME; + fakeHome = mkdtempSync(join(tmpdir(), "codex-projects-")); + process.env.HOME = fakeHome; + vi.resetModules(); + vi.doMock("node:os", async () => { + const actual = await vi.importActual("node:os"); + return { ...actual, homedir: () => fakeHome }; + }); + vi.doMock("os", async () => { + const actual = await vi.importActual("os"); + return { ...actual, homedir: () => fakeHome }; + }); + ({ getCodexProjects, getCodexSessionsForCwd, getCodexSessionsByEncodedName } = await import( + "@/lib/codex-projects" + )); + }); + + afterEach(() => { + if (originalHome !== undefined) process.env.HOME = originalHome; + rmSync(fakeHome, { recursive: true, force: true }); + vi.doUnmock("node:os"); + vi.doUnmock("os"); + vi.resetModules(); + }); + + it("returns [] when ~/.codex/sessions does not exist", async () => { + const result = await getCodexProjects(); + expect(result).toEqual([]); + }); + + it("groups sessions by cwd into one ProjectFolder each", async () => { + const sid1 = "11111111-1111-1111-1111-111111111111"; + const sid2 = "22222222-2222-2222-2222-222222222222"; + writeRollout({ y: "2026", m: "04", d: "28" }, sid1, meta("/home/u/proj-a", sid1)); + writeRollout({ y: "2026", m: "04", d: "28" }, sid2, meta("/home/u/proj-a", sid2)); + + const result = await getCodexProjects(); + expect(result).toHaveLength(1); + expect(result[0].name).toBe("-home-u-proj-a"); + expect(result[0].path).toBe("/home/u/proj-a"); + expect(result[0].cli).toEqual(["codex"]); + expect(result[0].isDirectory).toBe(true); + }); + + it("returns one entry per distinct cwd, sorted newest-first", async () => { + const old = new Date("2024-01-01T00:00:00Z"); + const recent = new Date("2026-06-15T00:00:00Z"); + writeRollout( + { y: "2024", m: "01", d: "01" }, + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + meta("/home/u/old", "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + old, + ); + writeRollout( + { y: "2026", m: "06", d: "15" }, + "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + meta("/home/u/new", "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), + recent, + ); + + const result = await getCodexProjects(); + expect(result.map((p) => p.path)).toEqual(["/home/u/new", "/home/u/old"]); + }); + + it("skips files with malformed first line", async () => { + writeRollout({ y: "2026", m: "04", d: "28" }, "33333333-3333-3333-3333-333333333333", "not json\n"); + const sid = "44444444-4444-4444-4444-444444444444"; + writeRollout({ y: "2026", m: "04", d: "28" }, sid, meta("/home/u/ok", sid)); + + const result = await getCodexProjects(); + expect(result).toHaveLength(1); + expect(result[0].path).toBe("/home/u/ok"); + }); + + it("skips files whose first record is not session_meta", async () => { + const sid = "55555555-5555-5555-5555-555555555555"; + writeRollout( + { y: "2026", m: "04", d: "28" }, + sid, + JSON.stringify({ timestamp: "2026-04-28T00:00:00.000Z", type: "turn_context", payload: {} }) + "\n", + ); + const result = await getCodexProjects(); + expect(result).toEqual([]); + }); + + it("skips files whose session_meta lacks a string cwd", async () => { + const sid = "66666666-6666-6666-6666-666666666666"; + writeRollout( + { y: "2026", m: "04", d: "28" }, + sid, + JSON.stringify({ timestamp: "2026-04-28T00:00:00.000Z", type: "session_meta", payload: { id: sid } }) + "\n", + ); + const result = await getCodexProjects(); + expect(result).toEqual([]); + }); + + it("skips files whose name does not contain a UUID-like sessionId", async () => { + const dir = join(fakeHome, ".codex", "sessions", "2026", "04", "28"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "no-uuid.jsonl"), meta("/home/u/proj", "abc")); + const result = await getCodexProjects(); + expect(result).toEqual([]); + }); + + it("getCodexSessionsForCwd filters by cwd and extracts sessionId", async () => { + const sid1 = "77777777-7777-7777-7777-777777777777"; + const sid2 = "88888888-8888-8888-8888-888888888888"; + const sid3 = "99999999-9999-9999-9999-999999999999"; + writeRollout({ y: "2026", m: "04", d: "28" }, sid1, meta("/home/u/proj-a", sid1)); + writeRollout({ y: "2026", m: "04", d: "28" }, sid2, meta("/home/u/proj-a", sid2)); + writeRollout({ y: "2026", m: "04", d: "28" }, sid3, meta("/home/u/proj-b", sid3)); + + const aSessions = await getCodexSessionsForCwd("/home/u/proj-a"); + expect(aSessions).toHaveLength(2); + expect(aSessions.map((s) => s.sessionId).sort()).toEqual([sid1, sid2].sort()); + expect(aSessions[0].name).toMatch(/^rollout-/); + expect(aSessions[0].path).toContain(".codex/sessions/2026/04/28/"); + }); + + it("getCodexSessionsForCwd returns [] for an unknown cwd", async () => { + const sid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; + writeRollout({ y: "2026", m: "04", d: "28" }, sid, meta("/home/u/known", sid)); + const result = await getCodexSessionsForCwd("/home/u/unknown"); + expect(result).toEqual([]); + }); + + it("getCodexSessionsByEncodedName recovers cwd with dashes that decodeFolderName loses", async () => { + // /home/u/agentic-test encodes to -home-u-agentic-test, which decodeFolderName would + // erroneously turn into /home/u/agentic/test. Re-encoding-from-cwd preserves the dash. + const sid = "11112222-3333-4444-5555-666677778888"; + writeRollout({ y: "2026", m: "04", d: "28" }, sid, meta("/home/u/agentic-test", sid)); + + const result = await getCodexSessionsByEncodedName("-home-u-agentic-test"); + expect(result.cwd).toBe("/home/u/agentic-test"); + expect(result.sessions).toHaveLength(1); + expect(result.sessions[0].sessionId).toBe(sid); + }); + + it("getCodexSessionsByEncodedName returns empty result when no session matches", async () => { + const result = await getCodexSessionsByEncodedName("-home-u-nothing-here"); + expect(result.cwd).toBeNull(); + expect(result.sessions).toEqual([]); + }); +}); diff --git a/__tests__/lib/projects.test.ts b/__tests__/lib/projects.test.ts index a713821f..82beb358 100644 --- a/__tests__/lib/projects.test.ts +++ b/__tests__/lib/projects.test.ts @@ -19,8 +19,16 @@ vi.mock("@/lib/runtime-cache", () => ({ runtimeCache: vi.fn((fn: (...args: unknown[]) => unknown) => fn), })); +// Default Codex stub returns no projects — individual tests override via mockResolvedValueOnce. +vi.mock("@/lib/codex-projects", () => ({ + getCodexProjects: vi.fn(async () => []), +})); + import { readdir, stat } from "fs/promises"; -import { extractSessionId, getProjectFolders, getSessionFiles } from "@/lib/projects"; +import { extractSessionId, getProjectFolders, getSessionFiles, type ProjectFolder } from "@/lib/projects"; +import { getCodexProjects } from "@/lib/codex-projects"; + +const mockGetCodexProjects = vi.mocked(getCodexProjects); const mockReaddir = vi.mocked(readdir); const mockStat = vi.mocked(stat); @@ -106,6 +114,67 @@ describe("getProjectFolders", () => { expect(result).toHaveLength(1); expect(result[0].lastModified.getTime()).toBe(0); }); + + it("tags Claude folders with cli=['claude']", async () => { + mockStat.mockResolvedValueOnce({ isDirectory: () => true } as any); + mockReaddir.mockResolvedValueOnce([ + { name: "-home-u-proj", isDirectory: () => true, isFile: () => false } as any, + ] as any); + mockStat.mockResolvedValueOnce({ mtime: new Date("2024-06-15T00:00:00Z") } as any); + + const result = await getProjectFolders(); + expect(result).toHaveLength(1); + expect(result[0].cli).toEqual(["claude"]); + }); + + it("merges a Codex project with the same encoded name into one row with both badges", async () => { + const claudeMtime = new Date("2024-01-01T00:00:00Z"); + const codexMtime = new Date("2026-06-15T00:00:00Z"); + mockStat.mockResolvedValueOnce({ isDirectory: () => true } as any); + mockReaddir.mockResolvedValueOnce([ + { name: "-home-u-proj", isDirectory: () => true, isFile: () => false } as any, + ] as any); + mockStat.mockResolvedValueOnce({ mtime: claudeMtime } as any); + mockGetCodexProjects.mockResolvedValueOnce([ + { + name: "-home-u-proj", + path: "/home/u/proj", + isDirectory: true, + lastModified: codexMtime, + lastModifiedFormatted: codexMtime.toISOString(), + cli: ["codex"], + } satisfies ProjectFolder, + ]); + + const result = await getProjectFolders(); + expect(result).toHaveLength(1); + expect(result[0].name).toBe("-home-u-proj"); + expect(result[0].cli).toEqual(["claude", "codex"]); + // Newer mtime wins + expect(result[0].lastModified.getTime()).toBe(codexMtime.getTime()); + // Claude's path is preserved + expect(result[0].path).toBe("/mock/.claude/projects/-home-u-proj"); + }); + + it("includes Codex-only projects (no matching Claude folder)", async () => { + mockStat.mockResolvedValueOnce({ isDirectory: () => true } as any); + mockReaddir.mockResolvedValueOnce([] as any); + mockGetCodexProjects.mockResolvedValueOnce([ + { + name: "-home-u-codex-only", + path: "/home/u/codex-only", + isDirectory: true, + lastModified: new Date("2026-06-15T00:00:00Z"), + lastModifiedFormatted: "2026-06-15T00:00:00.000Z", + cli: ["codex"], + } satisfies ProjectFolder, + ]); + + const result = await getProjectFolders(); + expect(result).toHaveLength(1); + expect(result[0].cli).toEqual(["codex"]); + expect(result[0].path).toBe("/home/u/codex-only"); + }); }); describe("getSessionFiles", () => { diff --git a/app/components/project-list.tsx b/app/components/project-list.tsx index fdba22e7..984c7599 100644 --- a/app/components/project-list.tsx +++ b/app/components/project-list.tsx @@ -5,7 +5,7 @@ "use client"; import { useState, useMemo, useEffect, useRef } from "react"; -import { ProjectFolder } from "@/lib/projects"; +import { ProjectFolder, ProjectCli } from "@/lib/projects"; import { decodeFolderName } from "@/lib/paths"; import { formatDate } from "@/lib/format-date"; import { @@ -36,6 +36,23 @@ function DateDisplay({ date, formatted }: { date: Date; formatted?: string }) { return {formatted || formatDate(date)}; } +function CliBadge({ cli }: { cli: ProjectCli }) { + const isCodex = cli === "codex"; + const label = isCodex ? "OpenAI Codex" : "Claude Code"; + return ( + + {label} + + ); +} + // Replace `/` with `-` so users can search by filesystem path (e.g. "/home/user") // and still match the encoded folder name (e.g. "-home-user"). function normalizeKeywordForSearch(keyword: string): string { @@ -296,12 +313,17 @@ export default function ProjectList({ folders }: ProjectListProps) { - - {decodeFolderName(folder.name)} - +
+ + {decodeFolderName(folder.name)} + + {folder.cli.map((c) => ( + + ))} +
{folder.path} diff --git a/app/project/[name]/page.tsx b/app/project/[name]/page.tsx index 6e0085a1..a48ebbf3 100644 --- a/app/project/[name]/page.tsx +++ b/app/project/[name]/page.tsx @@ -1,6 +1,7 @@ /** Project page — shows metadata and a filterable sessions list for a single project. */ import { Suspense } from "react"; -import { resolveProjectPath, getCachedSessionFiles } from "@/lib/projects"; +import { resolveProjectPath, getCachedSessionFiles, type SessionFile } from "@/lib/projects"; +import { getCachedCodexSessionsByEncodedName } from "@/lib/codex-projects"; import { logWarn } from "@/lib/logger"; import { decodeFolderName } from "@/lib/paths"; import { notFound } from "next/navigation"; @@ -21,34 +22,61 @@ interface ProjectPageProps { export default async function ProjectPage({ params }: ProjectPageProps) { const { name } = await params; - // Next.js already decodes route params once; resolveProjectPath validates and - // canonicalizes, throwing RangeError if the path escapes the projects root. - let projectPath: string; + // Resolve under ~/.claude/projects/. Validation may throw RangeError; on bad input + // we still want to try Codex, since a Codex-only cwd never escapes this check. + let claudeProjectPath: string | null = null; try { - projectPath = resolveProjectPath(name); + claudeProjectPath = resolveProjectPath(name); } catch { - notFound(); + claudeProjectPath = null; } const decodedName = decodeFolderName(name); - // Check if project exists - if (!existsSync(projectPath)) { + const claudeExists = claudeProjectPath ? existsSync(claudeProjectPath) : false; + + let claudeSessions: SessionFile[] = []; + if (claudeExists && claudeProjectPath) { + claudeSessions = await getCachedSessionFiles(claudeProjectPath); + } + // Note: decodeFolderName is lossy when cwds contain `-` (every `-` becomes `/`), + // so we look up Codex sessions by re-encoding each session's cwd and matching the slug. + const codex = await getCachedCodexSessionsByEncodedName(name); + const codexSessions = codex.sessions; + + if (!claudeExists && codexSessions.length === 0) { notFound(); } - // Get project stats for last modified date + // Prefer the canonical Codex cwd when available — `decodeFolderName(name)` is + // ambiguous for cwds containing `-` (every `-` becomes `/`). Codex transcripts + // record the literal cwd, so they round-trip correctly. + const canonicalRoot = codex.cwd ?? decodedName; + + // Project header metadata let lastModified: Date | null = null; let lastModifiedFormatted: string | null = null; - try { - const stats = await stat(projectPath); - lastModified = stats.mtime; - lastModifiedFormatted = formatDate(stats.mtime); - } catch (error) { - logWarn(`Failed to get stats for project ${decodedName}:`, error); + if (claudeExists && claudeProjectPath) { + try { + const stats = await stat(claudeProjectPath); + lastModified = stats.mtime; + lastModifiedFormatted = formatDate(stats.mtime); + } catch (error) { + logWarn(`Failed to get stats for project ${decodedName}:`, error); + } } + const newestCodex = codexSessions[0]?.lastModified ?? null; + if (newestCodex && (!lastModified || newestCodex.getTime() > lastModified.getTime())) { + lastModified = newestCodex; + lastModifiedFormatted = formatDate(newestCodex); + } + + const sessionFiles: SessionFile[] = [...claudeSessions, ...codexSessions].sort( + (a, b) => b.lastModified.getTime() - a.lastModified.getTime(), + ); - // Get session files - const sessionFiles = await getCachedSessionFiles(projectPath); + // Path line: prefer the Claude storage dir if present (matches existing UX); + // otherwise show the canonical Codex cwd. + const displayPath = claudeExists && claudeProjectPath ? claudeProjectPath : canonicalRoot; return (
@@ -63,11 +91,11 @@ export default async function ProjectPage({ params }: ProjectPageProps) {

- {decodedName} + {canonicalRoot}

- Path: {projectPath} + Path: {displayPath}

{lastModifiedFormatted && (

@@ -80,7 +108,7 @@ export default async function ProjectPage({ params }: ProjectPageProps) { {/* Sessions Section */}

Sessions

- + {sessionFiles.length === 0 ? (

@@ -98,4 +126,3 @@ export default async function ProjectPage({ params }: ProjectPageProps) {

); } - diff --git a/lib/codex-projects.ts b/lib/codex-projects.ts new file mode 100644 index 00000000..db09655e --- /dev/null +++ b/lib/codex-projects.ts @@ -0,0 +1,249 @@ +/** + * Codex (OpenAI) project discovery. + * + * Codex transcripts are stored at `~/.codex/sessions///
/rollout-...jsonl`, + * keyed by date — not by working directory. To list "projects" we scan every transcript, + * read only the first record (`session_meta`, which carries `payload.cwd`), and group by cwd. + * + * The encoded cwd doubles as the URL slug for `/project/[name]`, matching Claude Code's + * convention (see `encodeFolderName` in `lib/paths.ts`), so a cwd present in both stores + * naturally produces the same `name` and can be merged on the Claude side. + */ +import { open, readdir } from "fs/promises"; +import { homedir } from "os"; +import { join } from "path"; +import { encodeFolderName } from "./paths"; +import type { ProjectFolder, SessionFile } from "./projects"; +import { runtimeCache } from "./runtime-cache"; +import { batchAll } from "./concurrency"; +import { formatDate } from "./format-date"; +import { logWarn } from "./logger"; + +const CODEX_SESSIONS_ROOT = join(homedir(), ".codex", "sessions"); +const SESSION_ID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i; +// session_meta records can be large (base instructions inlined), but a single record +// is unlikely to exceed a few hundred KB. 256 KB comfortably covers the first line +// without slurping a full multi-MB transcript. +const FIRST_LINE_CHUNK_BYTES = 256 * 1024; + +interface CodexSessionMeta { + filePath: string; + fileName: string; + cwd: string; + sessionId: string; + fileMtime: Date; +} + +async function safeReaddir(dir: string) { + try { + return await readdir(dir, { withFileTypes: true }); + } catch { + return null; + } +} + +/** Read the first line of a file without loading the rest. */ +async function readFirstLine(filePath: string): Promise { + let fh: Awaited> | null = null; + try { + fh = await open(filePath, "r"); + const buf = Buffer.alloc(FIRST_LINE_CHUNK_BYTES); + const { bytesRead } = await fh.read(buf, 0, FIRST_LINE_CHUNK_BYTES, 0); + if (bytesRead === 0) return null; + const slice = buf.subarray(0, bytesRead); + const nl = slice.indexOf(0x0a); // '\n' + const end = nl === -1 ? bytesRead : nl; + return slice.subarray(0, end).toString("utf-8"); + } catch { + return null; + } finally { + if (fh) await fh.close().catch(() => {}); + } +} + +function extractSessionMeta(line: string): { cwd?: string } { + try { + const obj = JSON.parse(line) as { type?: string; payload?: { cwd?: unknown } }; + if (obj.type !== "session_meta") return {}; + const cwd = obj.payload?.cwd; + if (typeof cwd !== "string" || cwd.length === 0) return {}; + return { cwd }; + } catch { + return {}; + } +} + +function extractSessionId(filename: string): string | null { + const m = filename.match(SESSION_ID_RE); + return m ? m[0] : null; +} + +async function listJsonlFiles(dir: string): Promise { + const entries = await safeReaddir(dir); + if (!entries) return []; + return entries + .filter((e) => e.isFile() && e.name.endsWith(".jsonl")) + .map((e) => join(dir, e.name)); +} + +/** + * Walk `~/.codex/sessions////` for every `*.jsonl` transcript and read each + * file's first record to extract `cwd`. Files lacking a parsable `session_meta` or a + * UUID-looking sessionId in the filename are skipped. + */ +async function scanCodexSessions(): Promise { + const yearDirs = await safeReaddir(CODEX_SESSIONS_ROOT); + if (!yearDirs) return []; + + const filePaths: string[] = []; + for (const y of yearDirs) { + if (!y.isDirectory()) continue; + const monthDirs = await safeReaddir(join(CODEX_SESSIONS_ROOT, y.name)); + if (!monthDirs) continue; + for (const m of monthDirs) { + if (!m.isDirectory()) continue; + const dayDirs = await safeReaddir(join(CODEX_SESSIONS_ROOT, y.name, m.name)); + if (!dayDirs) continue; + for (const d of dayDirs) { + if (!d.isDirectory()) continue; + const dayPath = join(CODEX_SESSIONS_ROOT, y.name, m.name, d.name); + filePaths.push(...(await listJsonlFiles(dayPath))); + } + } + } + + if (filePaths.length === 0) return []; + + const settled = await batchAll( + filePaths.map((filePath) => async (): Promise => { + const sessionId = extractSessionId(filePath.split("/").pop() ?? ""); + if (!sessionId) return null; + const line = await readFirstLine(filePath); + if (!line) return null; + const { cwd } = extractSessionMeta(line); + if (!cwd) return null; + let fileMtime: Date; + try { + const fh = await open(filePath, "r"); + try { + const stat = await fh.stat(); + fileMtime = stat.mtime; + } finally { + await fh.close().catch(() => {}); + } + } catch { + fileMtime = new Date(0); + } + return { + filePath, + fileName: filePath.split("/").pop() ?? "", + cwd, + sessionId, + fileMtime, + }; + }), + 16, + ); + return settled + .filter((r): r is PromiseFulfilledResult => r.status === "fulfilled") + .map((r) => r.value) + .filter((v): v is CodexSessionMeta => v !== null); +} + +const cachedScan = runtimeCache(scanCodexSessions, 30); + +/** Returns one ProjectFolder per unique cwd discovered in Codex transcripts. */ +export async function getCodexProjects(): Promise { + let metas: CodexSessionMeta[]; + try { + metas = await cachedScan(); + } catch (error) { + logWarn("Failed to scan Codex sessions:", error); + return []; + } + + const byCwd = new Map(); + for (const m of metas) { + const existing = byCwd.get(m.cwd); + if (!existing || m.fileMtime.getTime() > existing.latest.getTime()) { + byCwd.set(m.cwd, { latest: m.fileMtime, cwd: m.cwd }); + } + } + + const folders: ProjectFolder[] = []; + for (const { cwd, latest } of byCwd.values()) { + folders.push({ + name: encodeFolderName(cwd), + path: cwd, + isDirectory: true, + lastModified: latest, + lastModifiedFormatted: formatDate(latest), + cli: ["codex"], + }); + } + folders.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); + return folders; +} + +function metasToSessionFiles(metas: CodexSessionMeta[]): SessionFile[] { + const files: SessionFile[] = metas.map((m) => ({ + name: m.fileName.replace(/\.jsonl$/, ""), + path: m.filePath, + lastModified: m.fileMtime, + lastModifiedFormatted: formatDate(m.fileMtime), + sessionId: m.sessionId, + })); + files.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); + return files; +} + +/** Returns SessionFile entries for every Codex transcript whose cwd matches `cwd` exactly. */ +export async function getCodexSessionsForCwd(cwd: string): Promise { + let metas: CodexSessionMeta[]; + try { + metas = await cachedScan(); + } catch (error) { + logWarn("Failed to scan Codex sessions:", error); + return []; + } + return metasToSessionFiles(metas.filter((m) => m.cwd === cwd)); +} + +export interface CodexProjectByName { + /** Original cwd recovered from the Codex transcripts (canonical, not the lossy decode). */ + cwd: string | null; + sessions: SessionFile[]; +} + +/** + * Looks up Codex sessions for a project URL slug. `decodeFolderName` is lossy + * (every `-` becomes `/`), so we cannot recover the original cwd from the slug — + * instead we re-encode each session's cwd and match in that direction. Returns + * both the canonical cwd (first match wins) and the matching sessions. + */ +export async function getCodexSessionsByEncodedName(name: string): Promise { + let metas: CodexSessionMeta[]; + try { + metas = await cachedScan(); + } catch (error) { + logWarn("Failed to scan Codex sessions:", error); + return { cwd: null, sessions: [] }; + } + const matches = metas.filter((m) => encodeFolderName(m.cwd) === name); + return { + cwd: matches[0]?.cwd ?? null, + sessions: metasToSessionFiles(matches), + }; +} + +export const getCachedCodexProjects = runtimeCache(getCodexProjects, 30); +export const getCachedCodexSessionsForCwd = runtimeCache( + (cwd: string) => getCodexSessionsForCwd(cwd), + 30, + { maxSize: 50 }, +); +export const getCachedCodexSessionsByEncodedName = runtimeCache( + (name: string) => getCodexSessionsByEncodedName(name), + 30, + { maxSize: 50 }, +); diff --git a/lib/projects.ts b/lib/projects.ts index bd7f0ccf..f2b87e95 100644 --- a/lib/projects.ts +++ b/lib/projects.ts @@ -16,12 +16,19 @@ import { formatDate } from "./format-date"; export const UUID_RE = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/; export const PATH_TRAVERSAL_RE = /(^|[\\/])\.\.($|[\\/])/; +export type ProjectCli = "claude" | "codex"; + export interface ProjectFolder { name: string; path: string; isDirectory: boolean; lastModified: Date; lastModifiedFormatted?: string; // Pre-formatted date string to avoid hydration issues + /** + * Which agent CLIs this project's data was found in. Multiple entries when + * the same cwd has both Claude and Codex transcripts; rendered as badges. + */ + cli: ProjectCli[]; } export interface SessionFile { @@ -54,7 +61,7 @@ async function safeReaddir(dirPath: string) { } } -export async function getProjectFolders(): Promise { +async function getClaudeProjectFolders(): Promise { try { const projectsPath = getClaudeProjectsPath(); const entries = await safeReaddir(projectsPath); @@ -72,6 +79,7 @@ export async function getProjectFolders(): Promise { isDirectory: true, lastModified: mtime, lastModifiedFormatted: formatDate(mtime), + cli: ["claude"], } as ProjectFolder; }), 16, @@ -83,11 +91,53 @@ export async function getProjectFolders(): Promise { folders.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); return folders; } catch (error) { - logError("Error reading project folders:", error); + logError("Error reading Claude project folders:", error); return []; } } +/** Merges Claude + Codex project lists by encoded folder name. When both sources have + * the same name, keeps the Claude entry's `path` (so the Path column still points at + * `~/.claude/projects/`), unions the `cli` arrays in [claude, codex] order, + * and takes the newer `lastModified`. */ +function mergeProjectFolders(claude: ProjectFolder[], codex: ProjectFolder[]): ProjectFolder[] { + const byName = new Map(); + for (const f of claude) byName.set(f.name, { ...f, cli: [...f.cli] }); + for (const f of codex) { + const existing = byName.get(f.name); + if (!existing) { + byName.set(f.name, { ...f, cli: [...f.cli] }); + continue; + } + const mergedCli: ProjectCli[] = [...existing.cli]; + for (const c of f.cli) if (!mergedCli.includes(c)) mergedCli.push(c); + const newer = f.lastModified.getTime() > existing.lastModified.getTime() ? f : existing; + byName.set(f.name, { + ...existing, + cli: mergedCli, + lastModified: newer.lastModified, + lastModifiedFormatted: newer.lastModifiedFormatted, + }); + } + const merged = Array.from(byName.values()); + merged.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); + return merged; +} + +export async function getProjectFolders(): Promise { + // Lazy import to keep `lib/codex-projects.ts` out of the dep graph for callers that + // only need Claude helpers (e.g. CLI codepaths). + const { getCodexProjects } = await import("./codex-projects"); + const [claude, codex] = await Promise.all([ + getClaudeProjectFolders(), + getCodexProjects().catch((error) => { + logError("Error reading Codex projects:", error); + return [] as ProjectFolder[]; + }), + ]); + return mergeProjectFolders(claude, codex); +} + /** * Gets the full path to a specific project folder * @param projectName - Name of the project folder From 84c45c09b9254cb32463be535d9eed24ba7f3b3b Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Tue, 28 Apr 2026 19:08:23 -0700 Subject: [PATCH 2/6] [docs] note Codex projects on the dashboard's Projects section Match the new behavior where ~/.codex/sessions transcripts surface as projects alongside ~/.claude/projects, with a CLI badge per row. Co-Authored-By: Claude Opus 4.7 --- docs/dashboard.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/dashboard.mdx b/docs/dashboard.mdx index 9575d31c..986bb158 100644 --- a/docs/dashboard.mdx +++ b/docs/dashboard.mdx @@ -24,11 +24,11 @@ The dashboard reads directly from the filesystem - your Claude Code project fold ### Projects -Lists all Claude Code projects found on your machine. Projects are discovered from `~/.claude/projects/` (or the path set by `CLAUDE_PROJECTS_PATH`). +Lists all Claude Code and OpenAI Codex projects found on your machine. Claude projects are discovered from `~/.claude/projects/` (or the path set by `CLAUDE_PROJECTS_PATH`); Codex projects are discovered by scanning every transcript under `~/.codex/sessions///
/*.jsonl` and grouping by the `cwd` recorded in each session's first record. A project that has been used by both CLIs renders as a single row. Each project shows: - Project name (derived from the folder path) -- Number of sessions +- A CLI badge — `Claude Code` (orange) and/or `OpenAI Codex` (purple) - Date of most recent session activity Click a project to see its sessions. From 76ee988f15f9ee931e89d26a65d2a061ed7c2a2a Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Tue, 28 Apr 2026 19:13:27 -0700 Subject: [PATCH 3/6] [projects] show CLI badge on the session page Extract the CLI badge from project-list.tsx into a shared app/components/cli-badge.tsx and reuse it on the session viewer header (beside the "Session Log" h1) so the originating CLI is visible at a glance for both Claude and Codex sessions. Co-Authored-By: Claude Opus 4.7 --- .gitignore | 1 + app/components/cli-badge.tsx | 24 +++++++++++++++++++ app/components/project-list.tsx | 20 ++-------------- .../[name]/session/[sessionId]/page.tsx | 10 +++++--- 4 files changed, 34 insertions(+), 21 deletions(-) create mode 100644 app/components/cli-badge.tsx diff --git a/.gitignore b/.gitignore index 6cd352d5..06052771 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,4 @@ packages/*/assets/ # WSL/Windows alternate data streams *:Zone.Identifier +.dev.log diff --git a/app/components/cli-badge.tsx b/app/components/cli-badge.tsx new file mode 100644 index 00000000..a8928e08 --- /dev/null +++ b/app/components/cli-badge.tsx @@ -0,0 +1,24 @@ +/** + * Tiny CLI-origin badge — orange for Claude Code, purple for OpenAI Codex. + * Mirrors the IntegrationBadge styling in `app/policies/hooks-client.tsx`, + * extracted here for reuse across the projects listing, project detail page, + * and session viewer. + */ +import type { ProjectCli } from "@/lib/projects"; + +export function CliBadge({ cli }: { cli: ProjectCli }) { + const isCodex = cli === "codex"; + const label = isCodex ? "OpenAI Codex" : "Claude Code"; + return ( + + {label} + + ); +} diff --git a/app/components/project-list.tsx b/app/components/project-list.tsx index 984c7599..133ff7ab 100644 --- a/app/components/project-list.tsx +++ b/app/components/project-list.tsx @@ -5,7 +5,8 @@ "use client"; import { useState, useMemo, useEffect, useRef } from "react"; -import { ProjectFolder, ProjectCli } from "@/lib/projects"; +import { ProjectFolder } from "@/lib/projects"; +import { CliBadge } from "./cli-badge"; import { decodeFolderName } from "@/lib/paths"; import { formatDate } from "@/lib/format-date"; import { @@ -36,23 +37,6 @@ function DateDisplay({ date, formatted }: { date: Date; formatted?: string }) { return {formatted || formatDate(date)}; } -function CliBadge({ cli }: { cli: ProjectCli }) { - const isCodex = cli === "codex"; - const label = isCodex ? "OpenAI Codex" : "Claude Code"; - return ( - - {label} - - ); -} - // Replace `/` with `-` so users can search by filesystem path (e.g. "/home/user") // and still match the encoded folder name (e.g. "-home-user"). function normalizeKeywordForSearch(keyword: string): string { diff --git a/app/project/[name]/session/[sessionId]/page.tsx b/app/project/[name]/session/[sessionId]/page.tsx index 3c7008df..a91b1e17 100644 --- a/app/project/[name]/session/[sessionId]/page.tsx +++ b/app/project/[name]/session/[sessionId]/page.tsx @@ -9,6 +9,7 @@ import { baseSessionId } from "@/lib/utils/session-id"; import { resolveProjectPath, UUID_RE } from "@/lib/projects"; import LazyLogViewer from "@/app/components/lazy-log-viewer"; import { CopyButton } from "@/app/components/copy-button"; +import { CliBadge } from "@/app/components/cli-badge"; export const dynamic = "force-dynamic"; @@ -78,9 +79,12 @@ export default async function SessionPage({ params }: SessionPageProps) {
-

- Session Log -

+
+

+ Session Log +

+ +

{headerLabel}:{" "} From 0f400c9bb22b760d1eefedb9ca9ce4908baf4812 Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Tue, 28 Apr 2026 19:13:53 -0700 Subject: [PATCH 4/6] [docs] mention CLI badge on session viewer; update CHANGELOG Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 2 +- docs/dashboard.mdx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbe44df1..1b403bf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## Unreleased ### Features -- Show OpenAI Codex projects on the `/projects` page alongside Claude Code projects. Codex stores transcripts under `~/.codex/sessions///

/*.jsonl` keyed by date, so we scan each transcript's `session_meta` record for its `cwd` and surface one row per unique cwd. A CLI badge ("Claude Code" / "OpenAI Codex") appears beside each agent root; cwds that exist in both stores render as a single row with both badges. The `/project/[name]` detail page now lists Codex sessions for the project (recovering the canonical cwd from the transcript, since `decodeFolderName` is lossy when paths contain `-`); session click-through reuses the existing Codex-aware viewer. +- Show OpenAI Codex projects on the `/projects` page alongside Claude Code projects. Codex stores transcripts under `~/.codex/sessions///
/*.jsonl` keyed by date, so we scan each transcript's `session_meta` record for its `cwd` and surface one row per unique cwd. A CLI badge ("Claude Code" / "OpenAI Codex") appears beside each agent root; cwds that exist in both stores render as a single row with both badges. The `/project/[name]` detail page now lists Codex sessions for the project (recovering the canonical cwd from the transcript, since `decodeFolderName` is lossy when paths contain `-`); session click-through reuses the existing Codex-aware viewer, which now also renders the CLI badge beside the Session Log header. ### Fixes - Fix `mintlify validate` parse error in `docs/de/dashboard.mdx` caused by inner quotes inside `` attributes (#229) diff --git a/docs/dashboard.mdx b/docs/dashboard.mdx index 986bb158..7d40359e 100644 --- a/docs/dashboard.mdx +++ b/docs/dashboard.mdx @@ -47,7 +47,7 @@ Click a session to open the session viewer. ### Session viewer -The session viewer answers the key question for autonomous agents: what did the agent do, and did it stay on track? It shows a timeline of everything that happened in a session: +The session viewer answers the key question for autonomous agents: what did the agent do, and did it stay on track? A CLI badge beside the header indicates whether the session is a Claude Code or OpenAI Codex transcript. It shows a timeline of everything that happened in a session: - **Messages** - Claude's text responses and user prompts - **Tool calls** - Every tool Claude invoked, with its input and output From 2e2e090da3303973b000fcc1737d98210473b837 Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Tue, 28 Apr 2026 19:17:07 -0700 Subject: [PATCH 5/6] [projects] tag each session in the project page with a CLI badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sessions on /project/[name] now show a per-row Claude/Codex badge so mixed-CLI projects (e.g. a cwd used from both Claude Code and OpenAI Codex) can be told apart at a glance. Tags the cli on SessionFile at the source — getSessionFiles emits cli="claude", the Codex enumerator emits cli="codex" — and the existing CliBadge component renders. Co-Authored-By: Claude Opus 4.7 --- app/components/sessions-list.tsx | 4 +++- lib/codex-projects.ts | 1 + lib/projects.ts | 4 ++++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/components/sessions-list.tsx b/app/components/sessions-list.tsx index 9d17737b..f65160ae 100644 --- a/app/components/sessions-list.tsx +++ b/app/components/sessions-list.tsx @@ -25,6 +25,7 @@ import Link from "next/link"; import PaginationControls from "./pagination-controls"; import DatePickerInput from "./date-picker-input"; import { CopyButton } from "./copy-button"; +import { CliBadge } from "./cli-badge"; interface SessionsListProps { @@ -190,7 +191,7 @@ export default function SessionsList({ files, projectName }: SessionsListProps) -
+
{file.sessionId ? ( <> )} + {file.cli && }
diff --git a/lib/codex-projects.ts b/lib/codex-projects.ts index db09655e..70678648 100644 --- a/lib/codex-projects.ts +++ b/lib/codex-projects.ts @@ -192,6 +192,7 @@ function metasToSessionFiles(metas: CodexSessionMeta[]): SessionFile[] { lastModified: m.fileMtime, lastModifiedFormatted: formatDate(m.fileMtime), sessionId: m.sessionId, + cli: "codex", })); files.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); return files; diff --git a/lib/projects.ts b/lib/projects.ts index f2b87e95..dac5e560 100644 --- a/lib/projects.ts +++ b/lib/projects.ts @@ -37,6 +37,9 @@ export interface SessionFile { lastModified: Date; lastModifiedFormatted?: string; sessionId?: string; + /** Originating agent CLI. Set when the session list mixes Claude + Codex sources + * so the table can render a per-row CLI badge. */ + cli?: ProjectCli; } /** Stats a path and returns mtime. Falls back to epoch (1970-01-01) on error @@ -207,6 +210,7 @@ export async function getSessionFiles(projectPath: string): Promise Date: Tue, 28 Apr 2026 19:17:21 -0700 Subject: [PATCH 6/6] [changelog] note per-session CLI tags on the project sessions list Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b403bf7..3031bbb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## Unreleased ### Features -- Show OpenAI Codex projects on the `/projects` page alongside Claude Code projects. Codex stores transcripts under `~/.codex/sessions///
/*.jsonl` keyed by date, so we scan each transcript's `session_meta` record for its `cwd` and surface one row per unique cwd. A CLI badge ("Claude Code" / "OpenAI Codex") appears beside each agent root; cwds that exist in both stores render as a single row with both badges. The `/project/[name]` detail page now lists Codex sessions for the project (recovering the canonical cwd from the transcript, since `decodeFolderName` is lossy when paths contain `-`); session click-through reuses the existing Codex-aware viewer, which now also renders the CLI badge beside the Session Log header. +- Show OpenAI Codex projects on the `/projects` page alongside Claude Code projects. Codex stores transcripts under `~/.codex/sessions///
/*.jsonl` keyed by date, so we scan each transcript's `session_meta` record for its `cwd` and surface one row per unique cwd. A CLI badge ("Claude Code" / "OpenAI Codex") appears beside each agent root; cwds that exist in both stores render as a single row with both badges. The `/project/[name]` detail page now lists Codex sessions for the project (recovering the canonical cwd from the transcript, since `decodeFolderName` is lossy when paths contain `-`) and tags each session row with its originating CLI; session click-through reuses the existing Codex-aware viewer, which now also renders the CLI badge beside the Session Log header. ### Fixes - Fix `mintlify validate` parse error in `docs/de/dashboard.mdx` caused by inner quotes inside `` attributes (#229)