From 94206b82e7571a9de24db273428e419336f35b2f Mon Sep 17 00:00:00 2001 From: SEUNGWOO LEE <69357689+lifrary@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:15:20 +0900 Subject: [PATCH 1/3] refactor: introduce pluggable session sources --- src/cli/pipeline.ts | 7 +- src/reader/jsonl.ts | 19 +--- src/sources/claude.ts | 53 ++++++++++ src/sources/files.ts | 41 ++++++++ src/sources/index.ts | 49 +++++++++ src/sources/types.ts | 44 ++++++++ src/utils/picker.ts | 10 +- src/utils/session_path.ts | 202 +++++-------------------------------- tests/cli.test.ts | 8 +- tests/mcp.test.ts | 10 +- tests/picker.test.ts | 11 +- tests/pipeline.test.ts | 4 +- tests/session-path.test.ts | 147 ++++++++++++++++++++------- 13 files changed, 358 insertions(+), 247 deletions(-) create mode 100644 src/sources/claude.ts create mode 100644 src/sources/files.ts create mode 100644 src/sources/index.ts create mode 100644 src/sources/types.ts diff --git a/src/cli/pipeline.ts b/src/cli/pipeline.ts index 7e0c188..d815dec 100644 --- a/src/cli/pipeline.ts +++ b/src/cli/pipeline.ts @@ -15,13 +15,13 @@ import { graphToDump } from '../reader/graph.js'; import { createAnthropicClient } from '../llm/anthropic.js'; import { labelMindMap } from '../llm/labeler.js'; import { buildGraph } from '../reader/graph.js'; -import { readJsonl } from '../reader/jsonl.js'; +import { getSessionSource } from '../sources/index.js'; import { buildMindMap } from '../tree/builder.js'; import type { MindMap, SessionGraph, TopicSegment } from '../types.js'; import type { Logger } from '../utils/logger.js'; import { countRedactions, defaultRedactor, redactDeep, type Redactor } from '../utils/redact.js'; import type { SessionMatch } from '../utils/session_path.js'; -import { isFullUuid } from '../utils/session_path.js'; +import { isFullUuid } from '../sources/files.js'; import type { ClaudeMapConfig } from '../config/schema.js'; import { expandPath } from '../config/loader.js'; @@ -63,6 +63,7 @@ export async function runPipeline(deps: PipelineDeps): Promise { const cacheHash = await computeInputHash({ jsonlPath: match.jsonlPath, configJson: JSON.stringify({ + source: match.source, config, llm: opts.llm !== false, model: opts.model, @@ -75,7 +76,7 @@ export async function runPipeline(deps: PipelineDeps): Promise { // [1/5] Parsing JSONL progress('[1/5] Parsing JSONL... '); - const { meta, events, malformedCount, skippedMetaCount } = await readJsonl(match.jsonlPath, { + const { meta, events, malformedCount, skippedMetaCount } = await getSessionSource(match.source).read(match.jsonlPath, { logger, strict: opts.strict, }); diff --git a/src/reader/jsonl.ts b/src/reader/jsonl.ts index ca4550d..c7857b7 100644 --- a/src/reader/jsonl.ts +++ b/src/reader/jsonl.ts @@ -28,25 +28,12 @@ import type { ToolResultPayload, ToolUsePayload, } from '../types.js'; -import type { Logger } from '../utils/logger.js'; - -export interface ReadJsonlResult { - meta: SessionMeta; - events: RawEvent[]; - malformedCount: number; - skippedMetaCount: number; -} - -export interface ReadJsonlOptions { - logger?: Logger; - /** Reject malformed JSON, envelopes and payloads instead of recovering. Default false. */ - strict?: boolean; -} +import type { ReadSessionOptions, ReadSessionResult } from '../sources/types.js'; export async function readJsonl( path: string, - opts: ReadJsonlOptions = {}, -): Promise { + opts: ReadSessionOptions = {}, +): Promise { const { logger, strict = false } = opts; const stream = createReadStream(path, { encoding: 'utf8' }); diff --git a/src/sources/claude.ts b/src/sources/claude.ts new file mode 100644 index 0000000..623b5b4 --- /dev/null +++ b/src/sources/claude.ts @@ -0,0 +1,53 @@ +import { homedir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { readJsonl } from '../reader/jsonl.js'; +import { isFullUuid, isSameDirectory, lstatIfPresent, readDirectory } from './files.js'; +import type { DiscoverOptions, SessionEntry, SessionSource } from './types.js'; + +export function getProjectsRoot(): string { + return resolve(process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), 'projects'); +} + +/** Claude's project encoding is lossy; it is a hint, not a reversible cwd. */ +export function encodeProjectPath(absPath: string): string { + return absPath.replace(/[^a-zA-Z0-9]/g, '-'); +} + +async function discover(opts: DiscoverOptions): Promise { + const root = resolve(opts.root ?? getProjectsRoot()); + const rootInfo = await lstatIfPresent(root); + if (!rootInfo?.isDirectory()) return []; + const projectDirs = opts.projectCwd === undefined + ? (await readDirectory(root)).filter((entry) => entry.isDirectory()).map((entry) => entry.name) + : [encodeProjectPath(opts.projectCwd)]; + const sessions: SessionEntry[] = []; + for (const projectDir of projectDirs) { + if (!projectDir) continue; + const projectPath = join(root, projectDir); + const projectInfo = await lstatIfPresent(projectPath); + if (!projectInfo?.isDirectory()) continue; + const projectSessions: SessionEntry[] = []; + for (const file of await readDirectory(projectPath)) { + if (!file.isFile() || !file.name.endsWith('.jsonl')) continue; + const sessionId = file.name.slice(0, -'.jsonl'.length); + if (!isFullUuid(sessionId)) continue; + const jsonlPath = join(projectPath, file.name); + const info = await lstatIfPresent(jsonlPath); + if (!info?.isFile()) continue; + projectSessions.push({ source: 'claude', sessionId, projectDir, jsonlPath, + mtimeMs: info.mtimeMs, sizeBytes: info.size }); + } + if (await isSameDirectory(projectPath, projectInfo)) sessions.push(...projectSessions); + } + return await isSameDirectory(root, rootInfo) ? sessions : []; +} + +export const claudeSource: SessionSource = { + id: 'claude', + discover, + accepts: (record) => + (typeof record.uuid === 'string' && typeof record.type === 'string') || + record.type === 'permission-mode' || + (typeof record.sessionId === 'string' && typeof record.type === 'string'), + read: readJsonl, +}; diff --git a/src/sources/files.ts b/src/sources/files.ts new file mode 100644 index 0000000..cbbf06a --- /dev/null +++ b/src/sources/files.ts @@ -0,0 +1,41 @@ +import type { Dirent, Stats } from 'node:fs'; +import { lstat, readdir } from 'node:fs/promises'; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export function isFullUuid(value: string): boolean { + return UUID_RE.test(value); +} + +export function isUuidPrefix(value: string): boolean { + return /^[0-9a-f-]+$/i.test(value) && value.length >= 4 && value.length <= 36; +} + +export function isMissingPath(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | null)?.code; + return code === 'ENOENT' || code === 'ENOTDIR'; +} + +export async function lstatIfPresent(path: string): Promise { + try { + return await lstat(path); + } catch (error) { + if (isMissingPath(error)) return null; + throw error; + } +} + +export async function readDirectory(path: string): Promise { + try { + return await readdir(path, { withFileTypes: true }); + } catch (error) { + if (isMissingPath(error)) return []; + throw error; + } +} + +export async function isSameDirectory(path: string, original: Stats): Promise { + const current = await lstatIfPresent(path); + return current !== null && current.isDirectory() && + current.dev === original.dev && current.ino === original.ino; +} diff --git a/src/sources/index.ts b/src/sources/index.ts new file mode 100644 index 0000000..b9658a7 --- /dev/null +++ b/src/sources/index.ts @@ -0,0 +1,49 @@ +import { open } from 'node:fs/promises'; +import { claudeSource } from './claude.js'; +import type { SessionSource, SessionSourceId } from './types.js'; + +const sources: readonly SessionSource[] = [claudeSource]; + +export function getSessionSource(id: SessionSourceId = 'claude'): SessionSource { + const source = sources.find((candidate) => candidate.id === id); + if (!source) throw new Error('Unsupported session source.'); + return source; +} + +/** Inspect only a bounded prefix. Explicit source selection also handles headerless exports. */ +export async function detectSessionSource( + path: string, + selected?: SessionSourceId, +): Promise { + const file = await open(path, 'r'); + const buffer = Buffer.alloc(1024 * 1024); + let bytes = 0; + try { + while (bytes < buffer.length) { + const chunk = await file.read(buffer, bytes, buffer.length - bytes, bytes); + if (chunk.bytesRead === 0) break; + bytes += chunk.bytesRead; + } + } finally { + await file.close(); + } + const text = buffer.toString('utf8', 0, bytes); + const lines = text.split('\n'); + // A bounded read may end in the middle of a record. + if (bytes === buffer.length) lines.pop(); + for (const line of lines) { + if (!line.trim()) continue; + let record: unknown; + try { record = JSON.parse(line); } catch { continue; } + if (!record || typeof record !== 'object' || Array.isArray(record)) continue; + const source = sources.find((candidate) => candidate.accepts(record as Record)); + if (!source) continue; + if (selected && source.id !== selected) { + throw new Error('Session file does not match the selected source.'); + } + return source.id; + } + if (selected) return getSessionSource(selected).id; + if (bytes < buffer.length && !text.trim()) return 'claude'; + throw new Error('Cannot identify session format; select --source explicitly.'); +} diff --git a/src/sources/types.ts b/src/sources/types.ts new file mode 100644 index 0000000..10ec464 --- /dev/null +++ b/src/sources/types.ts @@ -0,0 +1,44 @@ +import type { RawEvent, SessionMeta } from '../types.js'; +import type { Logger } from '../utils/logger.js'; + +export type SessionSourceId = 'claude' | 'codex'; + +export interface SessionMatch { + source: SessionSourceId; + sessionId: string; + /** Encoded project directory for Claude; working directory for Codex. */ + projectDir: string; + jsonlPath: string; +} + +export interface SessionEntry extends SessionMatch { + mtimeMs: number; + sizeBytes: number; +} + +export interface DiscoverOptions { + /** Override the adapter's session root, primarily for isolated callers/tests. */ + root?: string; + projectCwd?: string; +} + +export interface ReadSessionOptions { + logger?: Logger; + strict?: boolean; +} + +export interface ReadSessionResult { + meta: SessionMeta; + events: RawEvent[]; + malformedCount: number; + skippedMetaCount: number; +} + +/** Adapters normalize source logs; analysis and output remain source-independent. */ +export interface SessionSource { + id: SessionSourceId; + discover(options: DiscoverOptions): Promise; + /** Positive identification of one parsed JSONL record; never guess unknown formats. */ + accepts(record: Record): boolean; + read(path: string, options?: ReadSessionOptions): Promise; +} diff --git a/src/utils/picker.ts b/src/utils/picker.ts index a91d4e5..24c0d5e 100644 --- a/src/utils/picker.ts +++ b/src/utils/picker.ts @@ -8,18 +8,19 @@ import { createInterface } from 'node:readline/promises'; -import { listSessions, type SessionMatch } from './session_path.js'; +import { listSessions, type SessionMatch, type SessionSelection } from './session_path.js'; -export interface PickOptions { +export interface PickOptions extends SessionSelection { limit?: number; // default 10 - projectsRoot?: string; input?: NodeJS.ReadableStream; output?: NodeJS.WritableStream; } export async function pickSession(opts: PickOptions = {}): Promise { const candidates = await listSessions({ - projectsRoot: opts.projectsRoot, + root: opts.root, + source: opts.source, + projectCwd: opts.projectCwd, limit: opts.limit ?? 10, }); if (candidates.length === 0) return null; @@ -55,6 +56,7 @@ export async function pickSession(opts: PickOptions = {}): Promise/projects//.jsonl - * - * Encoding: absolute path → replace every non-ASCII-alphanumeric character - * with `-` (e.g. `/Users/x/Code/my_project` → `-Users-x-Code-my-project`). - * - * Encoding is not reversible, so the encoded directory name is only a project - * hint, not a way to recover the original working directory. - */ +/** Source-independent discovery, selection and portable imports. */ +import { lstat, realpath } from 'node:fs/promises'; +import { basename, dirname, extname, resolve } from 'node:path'; +import { detectSessionSource, getSessionSource } from '../sources/index.js'; +import { isFullUuid, isUuidPrefix } from '../sources/files.js'; +import type { DiscoverOptions, SessionEntry, SessionMatch, SessionSourceId } from '../sources/types.js'; -import type { Dirent, Stats } from 'node:fs'; -import { lstat, readdir, realpath } from 'node:fs/promises'; -import { homedir } from 'node:os'; -import { basename, dirname, extname, join, resolve } from 'node:path'; +export type { SessionEntry, SessionMatch } from '../sources/types.js'; -export function getProjectsRoot(): string { - return resolve(process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), 'projects'); +export interface SessionSelection extends DiscoverOptions { + source?: SessionSourceId; } -export function encodeProjectPath(absPath: string): string { - return absPath.replace(/[^a-zA-Z0-9]/g, '-'); -} - -const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -const UUID_LOOSE = /^[0-9a-f-]+$/i; - -export function isFullUuid(s: string): boolean { - return UUID_RE.test(s); -} - -export function isUuidPrefix(s: string): boolean { - return UUID_LOOSE.test(s) && s.length >= 4 && s.length <= 36; -} - -export interface SessionMatch { - sessionId: string; - projectDir: string; // encoded project dir name - jsonlPath: string; // absolute path -} - -export interface SessionEntry extends SessionMatch { - mtimeMs: number; - sizeBytes: number; -} - -/** - * Discover regular UUID-named session files without following symlinks. - * Missing paths and paths replaced by non-directories are normal scan races; - * other filesystem failures must remain visible to callers. - */ export async function listSessions( - opts: { projectsRoot?: string; projectCwd?: string; limit?: number } = {}, + opts: SessionSelection & { limit?: number } = {}, ): Promise { if (opts.limit !== undefined && (!Number.isSafeInteger(opts.limit) || opts.limit < 0)) { throw new RangeError('session limit must be a nonnegative safe integer'); } if (opts.limit === 0) return []; - - const root = resolve(opts.projectsRoot ?? getProjectsRoot()); - const rootInfo = await lstatIfPresent(root); - if (!rootInfo?.isDirectory()) return []; - - const projectDirs = - opts.projectCwd === undefined - ? (await readDirectory(root)) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name) - : [encodeProjectPath(opts.projectCwd)]; - const sessions: SessionEntry[] = []; - - for (const projectDir of projectDirs) { - if (!projectDir) continue; - const projectPath = join(root, projectDir); - const projectInfo = await lstatIfPresent(projectPath); - if (!projectInfo?.isDirectory()) continue; - const projectSessions: SessionEntry[] = []; - - for (const file of await readDirectory(projectPath)) { - if (!file.isFile() || !file.name.endsWith('.jsonl')) continue; - const sessionId = file.name.slice(0, -'.jsonl'.length); - if (!isFullUuid(sessionId)) continue; - - const jsonlPath = join(projectPath, file.name); - const info = await lstatIfPresent(jsonlPath); - if (!info?.isFile()) continue; - projectSessions.push({ - sessionId, - projectDir, - jsonlPath, - mtimeMs: info.mtimeMs, - sizeBytes: info.size, - }); - } - - // Discard files from a directory removed, replaced, or turned into a - // symlink while the scan was in progress. - if (await isSameDirectory(projectPath, projectInfo)) { - for (const session of projectSessions) sessions.push(session); - } - } - - if (!(await isSameDirectory(root, rootInfo))) return []; + const sessions = await getSessionSource(opts.source).discover(opts); sessions.sort(compareSessions); return opts.limit === undefined ? sessions : sessions.slice(0, opts.limit); } -/** - * Import an explicitly selected JSONL file, including portable exports whose - * names are not UUIDs. The reader can replace the provisional id with the - * session id recorded in the JSONL envelope. - */ -export async function sessionFromFile(filePath: string): Promise { +export async function sessionFromFile(filePath: string, source?: SessionSourceId): Promise { const jsonlPath = await realpath(resolve(filePath)); - if (extname(jsonlPath) !== '.jsonl') { - throw new Error('session file must have a .jsonl extension'); - } + if (extname(jsonlPath) !== '.jsonl') throw new Error('session file must have a .jsonl extension'); const info = await lstat(jsonlPath); - if (!info.isFile()) { - throw new Error('session file must be a regular file'); - } + if (!info.isFile()) throw new Error('session file must be a regular file'); return { + source: await detectSessionSource(jsonlPath, source), sessionId: basename(jsonlPath, '.jsonl'), projectDir: basename(dirname(jsonlPath)), jsonlPath, }; } -/** - * Locate session JSONL files matching either a full UUID or a prefix. - * - * Preference order: - * 1. Same `projectHint` (encoded cwd) first when provided - * 2. Most recently modified jsonl wins on ties - */ export async function locateSession( sessionIdOrPrefix: string, - opts: { projectHint?: string; projectsRoot?: string } = {}, + opts: SessionSelection & { projectHint?: string } = {}, ): Promise { const prefix = sessionIdOrPrefix.toLowerCase(); - if (!isUuidPrefix(prefix) && !isFullUuid(prefix)) { - throw new Error(`session id "${sessionIdOrPrefix}" is not a valid UUID or prefix`); + throw new Error('session id is not a valid UUID or prefix'); } - - const matches = (await listSessions({ projectsRoot: opts.projectsRoot })).filter((session) => + const matches = (await listSessions(opts)).filter((session) => session.sessionId.toLowerCase().startsWith(prefix), ); - matches.sort((a, b) => { if (opts.projectHint) { const aHit = a.projectDir === opts.projectHint ? 1 : 0; @@ -157,77 +55,25 @@ export async function locateSession( } return compareSessions(a, b); }); - return matches.map(toSessionMatch); } -/** - * Find the most recently modified session across all projects — powers --latest. - */ -export async function findLatestSession( - projectsRoot: string = getProjectsRoot(), -): Promise { - const [latest] = await listSessions({ projectsRoot, limit: 1 }); +export async function findLatestSession(opts: SessionSelection = {}): Promise { + const [latest] = await listSessions({ ...opts, limit: 1 }); return latest ? toSessionMatch(latest) : null; } -/** - * Find the most recently modified session within the current cwd's encoded - * project directory — used as the smart default when `agent-tree` is invoked - * without `--latest` / `--pick` / a session id. Returns null when no session - * exists for this project (caller should fall back to `findLatestSession`). - */ export async function findLatestSessionInProject( cwd: string, - projectsRoot: string = getProjectsRoot(), + opts: SessionSelection = {}, ): Promise { - const [latest] = await listSessions({ projectsRoot, projectCwd: cwd, limit: 1 }); - return latest ? toSessionMatch(latest) : null; + return findLatestSession({ ...opts, projectCwd: cwd }); } function compareSessions(a: SessionEntry, b: SessionEntry): number { - return ( - b.mtimeMs - a.mtimeMs || (a.jsonlPath < b.jsonlPath ? -1 : a.jsonlPath > b.jsonlPath ? 1 : 0) - ); + return b.mtimeMs - a.mtimeMs || (a.jsonlPath < b.jsonlPath ? -1 : a.jsonlPath > b.jsonlPath ? 1 : 0); } function toSessionMatch(entry: SessionEntry): SessionMatch { - return { - sessionId: entry.sessionId, - projectDir: entry.projectDir, - jsonlPath: entry.jsonlPath, - }; -} - -function isMissingPath(error: unknown): boolean { - const code = (error as NodeJS.ErrnoException | null)?.code; - return code === 'ENOENT' || code === 'ENOTDIR'; -} - -async function lstatIfPresent(path: string): Promise { - try { - return await lstat(path); - } catch (error) { - if (isMissingPath(error)) return null; - throw error; - } -} - -async function readDirectory(path: string): Promise { - try { - return await readdir(path, { withFileTypes: true }); - } catch (error) { - if (isMissingPath(error)) return []; - throw error; - } -} - -async function isSameDirectory(path: string, original: Stats): Promise { - const current = await lstatIfPresent(path); - return ( - current !== null && - current.isDirectory() && - current.dev === original.dev && - current.ino === original.ino - ); + return { source: entry.source, sessionId: entry.sessionId, projectDir: entry.projectDir, jsonlPath: entry.jsonlPath }; } diff --git a/tests/cli.test.ts b/tests/cli.test.ts index f59978a..ad2a4a8 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { promisify } from 'node:util'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { encodeProjectPath } from '../src/utils/session_path.js'; +import { encodeProjectPath } from '../src/sources/claude.js'; const exec = promisify(execFile); const entry = resolve('src/cli.ts'); @@ -71,7 +71,11 @@ describe('portable CLI workflows', () => { const { stdout } = await cli('--sessions', `--cwd=${project}`, '--limit', '1', '--json'); const { sessions } = JSON.parse(stdout); expect(sessions).toHaveLength(1); - expect(sessions[0]).toMatchObject({ sessionId: id, projectDir: encodeProjectPath(project) }); + expect(sessions[0]).toMatchObject({ + source: 'claude', + sessionId: id, + projectDir: encodeProjectPath(project), + }); expect(sessions[0].sizeBytes).toBeGreaterThan(0); const all = await cli('--sessions', '--json'); expect(JSON.parse(all.stdout).sessions).toHaveLength(2); diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 5b24fd0..b586680 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -6,7 +6,7 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createServer } from '../src/mcp/server.js'; -import { encodeProjectPath } from '../src/utils/session_path.js'; +import { encodeProjectPath } from '../src/sources/claude.js'; import { recordPick } from '../src/utils/picks.js'; const testState = vi.hoisted(() => ({ userConfigPath: '' })); @@ -122,10 +122,14 @@ describe('MCP protocol tools', () => { await mkdir(dir, { recursive: true }); await writeFile(join(dir, `${id}.jsonl`), await readFile(file)); const result = await call('agent_tree_sessions', { cwd: project, limit: 1 }); - expect(result.structuredContent).toMatchObject({ sessions: [{ sessionId: id }] }); + expect(result.structuredContent).toMatchObject({ + sessions: [{ source: 'claude', sessionId: id }], + }); const relativeCwd = relative(process.cwd(), project); const relativeCatalog = await call('agent_tree_sessions', { cwd: relativeCwd }); - expect(relativeCatalog.structuredContent).toMatchObject({ sessions: [{ sessionId: id }] }); + expect(relativeCatalog.structuredContent).toMatchObject({ + sessions: [{ source: 'claude', sessionId: id }], + }); const relativeTree = await call('agent_tree_list', { cwd: relativeCwd, format: 'json' }); expect(relativeTree.structuredContent).toMatchObject({ mindmap: { session_id: id } }); expect((await call('agent_tree_sessions', { limit: 0 })).isError).toBe(true); diff --git a/tests/picker.test.ts b/tests/picker.test.ts index 8616556..b719c1b 100644 --- a/tests/picker.test.ts +++ b/tests/picker.test.ts @@ -15,6 +15,7 @@ vi.mock('node:readline/promises', async (importOriginal) => ({ const candidates: SessionEntry[] = [ { + source: 'claude', sessionId: 'aaaaaaaa-0000-4000-8000-000000000001', projectDir: '-work-first', jsonlPath: '/sessions/first.jsonl', @@ -22,6 +23,7 @@ const candidates: SessionEntry[] = [ sizeBytes: 20, }, { + source: 'claude', sessionId: 'bbbbbbbb-0000-4000-8000-000000000002', projectDir: '-work-second', jsonlPath: '/sessions/second.jsonl', @@ -60,7 +62,9 @@ async function startPicker() { }); const createSpy = vi.spyOn(readline, 'createInterface'); const result = pickSession({ - projectsRoot: '/chosen/root', + root: '/chosen/root', + source: 'claude', + projectCwd: '/work/project', limit: 2, input, output, @@ -76,7 +80,9 @@ describe('pickSession', () => { it('uses shared discovery and returns the selected session without metadata', async () => { const picker = await startPicker(); expect(listSessions).toHaveBeenCalledExactlyOnceWith({ - projectsRoot: '/chosen/root', + root: '/chosen/root', + source: 'claude', + projectCwd: '/work/project', limit: 2, }); expect(picker.text).toContain('aaaaaaaa'); @@ -84,6 +90,7 @@ describe('pickSession', () => { picker.input.write(' 2 \n'); await expect(picker.result).resolves.toEqual({ + source: candidates[1].source, sessionId: candidates[1].sessionId, projectDir: candidates[1].projectDir, jsonlPath: candidates[1].jsonlPath, diff --git a/tests/pipeline.test.ts b/tests/pipeline.test.ts index af4fa4c..c9ebd22 100644 --- a/tests/pipeline.test.ts +++ b/tests/pipeline.test.ts @@ -4,6 +4,7 @@ import { join, resolve } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { runPipeline } from '../src/cli/pipeline.js'; import { mergeConfig, DEFAULT_CONFIG } from '../src/config/schema.js'; +import type { SessionMatch } from '../src/sources/types.js'; import { createLoggerSync } from '../src/utils/logger.js'; const mocks = vi.hoisted(() => ({ @@ -29,7 +30,8 @@ vi.mock('../src/llm/anthropic.js', async (importOriginal) => { return { ...actual, createAnthropicClient: vi.fn(async () => ({ messages: mocks })) }; }); let root: string; -const match = { +const match: SessionMatch = { + source: 'claude', sessionId: 'aaaa1111-2222-3333-4444-555566667777', projectDir: '-fixture', jsonlPath: resolve('tests/fixtures/minimal-session.jsonl'), diff --git a/tests/session-path.test.ts b/tests/session-path.test.ts index 60d36df..cf68fce 100644 --- a/tests/session-path.test.ts +++ b/tests/session-path.test.ts @@ -14,11 +14,10 @@ import * as fs from 'node:fs/promises'; import { homedir, tmpdir } from 'node:os'; import { join, relative } from 'node:path'; +import { encodeProjectPath, getProjectsRoot } from '../src/sources/claude.js'; import { - encodeProjectPath, findLatestSession, findLatestSessionInProject, - getProjectsRoot, listSessions, locateSession, sessionFromFile, @@ -88,7 +87,7 @@ describe('project paths', () => { const cwd = '/work/project_with spaces'; const projectDir = encodeProjectPath(cwd); const jsonlPath = makeSession(projectDir); - const expected = { sessionId: ID_A, projectDir, jsonlPath }; + const expected = { source: 'claude', sessionId: ID_A, projectDir, jsonlPath }; vi.stubEnv('CLAUDE_CONFIG_DIR', tmpRoot); expect(await listSessions()).toEqual([{ ...expected, mtimeMs: TIME, sizeBytes: 3 }]); @@ -122,7 +121,7 @@ describe('listSessions', () => { writeFileSync(join(projectsRoot, `${ID_C}.jsonl`), '{}\n'); writeFileSync(join(projectsRoot, 'not-a-project'), '{}\n'); - const sessions = await listSessions({ projectsRoot }); + const sessions = await listSessions({ root: projectsRoot }); expect(sessions.map((entry) => entry.jsonlPath)).toEqual([b, a]); expect(sessions.map((entry) => entry.sessionId)).toEqual([ID_B, ID_A]); }); @@ -133,7 +132,7 @@ describe('listSessions', () => { const a = makeSession('-a', ID_A, TIME); const newest = makeSession('-z', ID_B, TIME + 1000); - const sessions = await listSessions({ projectsRoot }); + const sessions = await listSessions({ root: projectsRoot }); expect(sessions.map((entry) => entry.jsonlPath)).toEqual([newest, a, c, z]); expect(sessions.map((entry) => entry.mtimeMs)).toEqual([TIME + 1000, TIME, TIME, TIME]); expect(sessions[3].sizeBytes).toBe(Buffer.byteLength('한글\n')); @@ -144,14 +143,14 @@ describe('listSessions', () => { makeSession('-project', ID_A, TIME); makeSession('-project', ID_C, TIME + 1000); makeSession('-project', ID_B, TIME + 2000); - const all = await listSessions({ projectsRoot }); - expect(await listSessions({ projectsRoot, limit })).toEqual(all.slice(0, limit)); + const all = await listSessions({ root: projectsRoot }); + expect(await listSessions({ root: projectsRoot, limit })).toEqual(all.slice(0, limit)); }); it.each([-1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1])( 'rejects invalid limit %s', async (limit) => { - await expect(listSessions({ projectsRoot, limit })).rejects.toThrow( + await expect(listSessions({ root: projectsRoot, limit })).rejects.toThrow( 'session limit must be a nonnegative safe integer', ); }, @@ -164,28 +163,29 @@ describe('listSessions', () => { makeSession('-other', ID_B, TIME + 1000); const spy = vi.spyOn(fs, 'readdir'); - const sessions = await listSessions({ projectsRoot, projectCwd: cwd }); + const sessions = await listSessions({ root: projectsRoot, projectCwd: cwd }); expect(sessions.map((entry) => entry.jsonlPath)).toEqual([matching]); expect(spy).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledWith(join(projectsRoot, projectDir), { withFileTypes: true, }); - expect(await findLatestSessionInProject(cwd, projectsRoot)).toEqual({ + expect(await findLatestSessionInProject(cwd, { root: projectsRoot })).toEqual({ + source: 'claude', sessionId: ID_A, projectDir, jsonlPath: matching, }); - expect(await findLatestSessionInProject('/missing/project', projectsRoot)).toBeNull(); + expect(await findLatestSessionInProject('/missing/project', { root: projectsRoot })).toBeNull(); }); it('returns no sessions for missing roots and roots that are not directories', async () => { const file = join(tmpRoot, 'file'); writeFileSync(file, 'not a directory'); for (const root of [join(tmpRoot, 'missing'), file, join(file, 'projects')]) { - expect(await listSessions({ projectsRoot: root })).toEqual([]); - expect(await findLatestSession(root)).toBeNull(); - expect(await findLatestSessionInProject('/work', root)).toBeNull(); - expect(await locateSession('aaaa', { projectsRoot: root })).toEqual([]); + expect(await listSessions({ root })).toEqual([]); + expect(await findLatestSession({ root })).toBeNull(); + expect(await findLatestSessionInProject('/work', { root })).toBeNull(); + expect(await locateSession('aaaa', { root })).toEqual([]); } }); @@ -201,11 +201,11 @@ describe('listSessions', () => { const rootAlias = join(tmpRoot, 'root-alias'); symlinkSync(projectsRoot, rootAlias, 'dir'); - expect((await listSessions({ projectsRoot })).map((entry) => entry.jsonlPath)).toEqual([ + expect((await listSessions({ root: projectsRoot })).map((entry) => entry.jsonlPath)).toEqual([ legitimate, ]); - expect(await listSessions({ projectsRoot: rootAlias })).toEqual([]); - expect(await listSessions({ projectsRoot, projectCwd: '/linked/project' })).toEqual([]); + expect(await listSessions({ root: rootAlias })).toEqual([]); + expect(await listSessions({ root: projectsRoot, projectCwd: '/linked/project' })).toEqual([]); }); it.each(['ENOENT', 'ENOTDIR'])( @@ -217,7 +217,7 @@ describe('listSessions', () => { .mockImplementationOnce(original) .mockImplementationOnce(original) .mockRejectedValueOnce(ioError(code)); - expect(await listSessions({ projectsRoot })).toEqual([]); + expect(await listSessions({ root: projectsRoot })).toEqual([]); }, ); @@ -227,7 +227,7 @@ describe('listSessions', () => { makeSession('-project'); const original = fs.readdir; vi.spyOn(fs, 'readdir').mockImplementationOnce(original).mockRejectedValueOnce(ioError(code)); - expect(await listSessions({ projectsRoot })).toEqual([]); + expect(await listSessions({ root: projectsRoot })).toEqual([]); }, ); @@ -245,7 +245,7 @@ describe('listSessions', () => { return original(file); }); - expect(await listSessions({ projectsRoot })).toEqual([]); + expect(await listSessions({ root: projectsRoot })).toEqual([]); }); it('discards a project replaced by a symlink while its files are scanned', async () => { @@ -264,7 +264,7 @@ describe('listSessions', () => { return entries; }); - expect(await listSessions({ projectsRoot })).toEqual([]); + expect(await listSessions({ root: projectsRoot })).toEqual([]); }); it('discards a root replaced by a symlink while projects are scanned', async () => { @@ -281,7 +281,7 @@ describe('listSessions', () => { return original(join(projectsRoot, '-project')); }); - expect(await listSessions({ projectsRoot })).toEqual([]); + expect(await listSessions({ root: projectsRoot })).toEqual([]); }); it.each(['EACCES', 'EPERM', 'EIO', 'ELOOP'])( @@ -289,14 +289,14 @@ describe('listSessions', () => { async (code) => { const error = ioError(code); vi.spyOn(fs, 'lstat').mockRejectedValueOnce(error); - await expect(listSessions({ projectsRoot })).rejects.toBe(error); + await expect(listSessions({ root: projectsRoot })).rejects.toBe(error); }, ); it.each(['EACCES', 'EIO'])('surfaces root directory listing failures (%s)', async (code) => { const error = ioError(code); vi.spyOn(fs, 'readdir').mockRejectedValueOnce(error); - await expect(listSessions({ projectsRoot })).rejects.toBe(error); + await expect(listSessions({ root: projectsRoot })).rejects.toBe(error); }); it.each(['EACCES', 'EIO'])('surfaces project directory listing failures (%s)', async (code) => { @@ -304,7 +304,7 @@ describe('listSessions', () => { const error = ioError(code); const original = fs.readdir; vi.spyOn(fs, 'readdir').mockImplementationOnce(original).mockRejectedValueOnce(error); - await expect(listSessions({ projectsRoot })).rejects.toBe(error); + await expect(listSessions({ root: projectsRoot })).rejects.toBe(error); }); it.each(['EACCES', 'EIO'])('surfaces individual session metadata failures (%s)', async (code) => { @@ -315,39 +315,40 @@ describe('listSessions', () => { .mockImplementationOnce(original) .mockImplementationOnce(original) .mockRejectedValueOnce(error); - await expect(listSessions({ projectsRoot })).rejects.toBe(error); + await expect(listSessions({ root: projectsRoot })).rejects.toBe(error); }); }); -describe('legacy lookup entry points', () => { +describe('lookup entry points', () => { it('prefers the hinted project, then mtime and deterministic path order', async () => { const preferred = makeSession('-preferred', ID_A, TIME); const newest = makeSession('-other', ID_C, TIME + 1000); const tied = makeSession('-other', ID_A, TIME + 1000); expect( await locateSession('AAAA', { - projectsRoot, + root: projectsRoot, projectHint: '-preferred', }), ).toEqual([ - { sessionId: ID_A, projectDir: '-preferred', jsonlPath: preferred }, - { sessionId: ID_A, projectDir: '-other', jsonlPath: tied }, - { sessionId: ID_C, projectDir: '-other', jsonlPath: newest }, + { source: 'claude', sessionId: ID_A, projectDir: '-preferred', jsonlPath: preferred }, + { source: 'claude', sessionId: ID_A, projectDir: '-other', jsonlPath: tied }, + { source: 'claude', sessionId: ID_C, projectDir: '-other', jsonlPath: newest }, ]); - expect(await findLatestSession(projectsRoot)).toEqual({ + expect(await findLatestSession({ root: projectsRoot })).toEqual({ + source: 'claude', sessionId: ID_A, projectDir: '-other', jsonlPath: tied, }); - expect(await locateSession(ID_C.toUpperCase(), { projectsRoot })).toEqual([ - { sessionId: ID_C, projectDir: '-other', jsonlPath: newest }, + expect(await locateSession(ID_C.toUpperCase(), { root: projectsRoot })).toEqual([ + { source: 'claude', sessionId: ID_C, projectDir: '-other', jsonlPath: newest }, ]); }); it('rejects invalid ids before touching the filesystem', async () => { const spy = vi.spyOn(fs, 'lstat'); for (const id of ['../escape', 'abc', 'not-a-uuid', '']) { - await expect(locateSession(id, { projectsRoot })).rejects.toThrow( + await expect(locateSession(id, { root: projectsRoot })).rejects.toThrow( 'is not a valid UUID or prefix', ); } @@ -360,20 +361,90 @@ describe('sessionFromFile', () => { const directory = join(tmpRoot, 'portable exports'); mkdirSync(directory); const file = join(directory, 'my saved conversation.jsonl'); - writeFileSync(file, '{"sessionId":"envelope-id"}\n'); + writeFileSync( + file, + '{"type":"permission-mode","sessionId":"envelope-id","permissionMode":"default"}\n', + ); expect(await sessionFromFile(relative(process.cwd(), file))).toEqual({ + source: 'claude', sessionId: 'my saved conversation', projectDir: 'portable exports', jsonlPath: file, }); }); + it.each(['{}\n', '{"sessionId":"envelope-id"}\n', 'not valid JSON\n', 'null\n[]\n42\n'])( + 'rejects unrecognized nonempty formats: %j', + async (body) => { + const file = makeSession('-project', ID_A, TIME, body); + await expect(sessionFromFile(file)).rejects.toThrow( + 'Cannot identify session format; select --source explicitly.', + ); + }, + ); + + it.each(['{}\n', '{"sessionId":"envelope-id"}\n', '{"type":\nnot valid JSON\n'])( + 'accepts headerless or malformed files with an explicit source: %j', + async (body) => { + const file = makeSession('-project', ID_A, TIME, body); + expect(await sessionFromFile(file, 'claude')).toEqual({ + source: 'claude', + sessionId: ID_A, + projectDir: '-project', + jsonlPath: file, + }); + }, + ); + + it.each(['', ' \n\t\r\n'])('imports empty exports as Claude sessions: %j', async (body) => { + const file = makeSession('-project', ID_A, TIME, body); + expect(await sessionFromFile(file)).toEqual({ + source: 'claude', + sessionId: ID_A, + projectDir: '-project', + jsonlPath: file, + }); + }); + + it.each(['user', 'assistant'])( + 'detects Claude from a %s event after blank, invalid and unrecognized lines', + async (role) => { + const record = { + type: role, + uuid: 'message-001', + parentUuid: null, + sessionId: ID_A, + timestamp: '2026-04-20T10:00:00.000Z', + cwd: '/work/project', + message: { role, content: [{ type: 'text', text: 'Hello' }] }, + }; + const file = makeSession( + '-project', + ID_A, + TIME, + `\n \t\nnot valid JSON\nnull\n[]\n{}\n${JSON.stringify(record)}\n`, + ); + expect(await sessionFromFile(file)).toEqual({ + source: 'claude', + sessionId: ID_A, + projectDir: '-project', + jsonlPath: file, + }); + }, + ); + it('resolves an explicitly selected symlink to the real JSONL file', async () => { - const file = makeSession('-project'); + const file = makeSession( + '-project', + ID_A, + TIME, + '{"type":"permission-mode","permissionMode":"default"}\n', + ); const alias = join(tmpRoot, 'export-alias.jsonl'); symlinkSync(file, alias); expect(await sessionFromFile(alias)).toEqual({ + source: 'claude', sessionId: ID_A, projectDir: '-project', jsonlPath: file, From 0c22ea940a8b5753da7eb279d1bcf0b62a6240b4 Mon Sep 17 00:00:00 2001 From: SEUNGWOO LEE <69357689+lifrary@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:49:43 +0900 Subject: [PATCH 2/3] feat: support Codex sessions across CLI and MCP --- scripts/smoke-release.mjs | 178 +++- src/cli.ts | 42 +- src/cli/modes.ts | 23 +- src/cli/options.ts | 12 +- src/cli/pipeline.ts | 6 +- src/mcp/server.ts | 52 +- src/reader/codex.ts | 470 +++++++++++ src/sources/claude.ts | 25 +- src/sources/codex.ts | 143 ++++ src/sources/files.ts | 8 +- src/sources/index.ts | 13 +- src/tree/builder.ts | 16 +- src/types.ts | 20 +- src/utils/picker.ts | 9 +- src/utils/picks.ts | 112 ++- src/utils/session_path.ts | 23 +- tests/cli.test.ts | 252 +++++- tests/codex.test.ts | 1219 ++++++++++++++++++++++++++++ tests/fixtures/codex-session.jsonl | 17 + tests/mcp.test.ts | 333 +++++++- tests/options.test.ts | 28 + tests/picks.test.ts | 290 +++++++ tests/security-hardening.test.ts | 2 +- tests/session-path.test.ts | 10 + tests/source-redaction.test.ts | 67 ++ 25 files changed, 3225 insertions(+), 145 deletions(-) create mode 100644 src/reader/codex.ts create mode 100644 src/sources/codex.ts create mode 100644 tests/codex.test.ts create mode 100644 tests/fixtures/codex-session.jsonl create mode 100644 tests/picks.test.ts create mode 100644 tests/source-redaction.test.ts diff --git a/scripts/smoke-release.mjs b/scripts/smoke-release.mjs index f96aff3..801e28d 100644 --- a/scripts/smoke-release.mjs +++ b/scripts/smoke-release.mjs @@ -1,14 +1,16 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; -import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, rm, utimes, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const fixture = resolve(root, 'tests/fixtures/minimal-session.jsonl'); +const codexFixture = resolve(root, 'tests/fixtures/codex-session.jsonl'); const expectedSessionId = 'aaaa1111-2222-3333-4444-555566667777'; +const otherSessionId = 'bbbb1111-2222-3333-4444-555566667777'; const expectedTools = [ 'agent_tree_diff', 'agent_tree_list', @@ -37,19 +39,76 @@ async function main() { ? join(tempDir, 'node_modules/.bin/agent-tree.cmd') : join(tempDir, 'node_modules/.bin/agent-tree'); assert.equal((await runOut(bin, ['--version'], tempDir)).trim(), version); - assert.match(await runOut(bin, ['--help'], tempDir), /Navigate a Claude Code session/); + const help = await runOut(bin, ['--help'], tempDir); + assert.match(help, /Claude Code or Codex sessions/); + assert.match(help, /--source/); const invalid = await run(bin, ['--file', fixture, '--json', '--filter', 'foo'], tempDir, [2]); assert.match(invalid.stderr, /--json exports the complete tree/); const json = await runOut(bin, ['--file', fixture, '--no-llm', '--strict', '--json'], tempDir); const parsed = JSON.parse(json); + assert.equal(parsed.source, 'claude'); assert.equal(parsed.session_id, expectedSessionId); assert.ok(parsed.root.children.length > 0, 'strict JSON fixture should produce child nodes'); assert.ok(parsed.stats.total_nodes > 0, 'strict JSON fixture should count nodes'); + const codex = JSON.parse( + await runOut(bin, ['--file', codexFixture, '--no-llm', '--strict', '--json'], tempDir), + ); + assertCodex(codex); + const selected = JSON.parse( + await runOut(bin, ['--file', codexFixture, '--source', 'codex', '--json'], tempDir), + ); + assertCodex(selected); + for (const [file, source] of [ + [codexFixture, 'claude'], + [fixture, 'codex'], + ]) { + const mismatch = await run(bin, ['--file', file, '--source', source, '--json'], tempDir, [1]); + assert.equal(mismatch.stdout, ''); + assert.match(mismatch.stderr, /does not match the selected source/); + assert.doesNotMatch(mismatch.stderr, /SYNTHETIC_PRIVATE|owner@example\.com/); + } + const badSource = await run( + bin, + ['--source', 'invalid', '--file', codexFixture, '--json'], + tempDir, + [2], + ); + assert.equal(badSource.stdout, ''); + assert.match(badSource.stderr, /source/); + + const rolloutPath = await prepareCodexDiscovery(); + const catalog = JSON.parse( + await runOut( + bin, + ['--source', 'codex', '--sessions', '--cwd', tempDir, '--limit', '1', '--json'], + tempDir, + ), + ); + assertCatalog(catalog, rolloutPath); + const all = JSON.parse( + await runOut(bin, ['--source', 'codex', '--sessions', '--json'], tempDir), + ); + assert.deepEqual( + all.sessions.map((entry) => entry.sessionId), + [otherSessionId, expectedSessionId], + ); + assert.deepEqual(JSON.parse(await runOut(bin, ['--sessions', '--json'], tempDir)).sessions, []); + const byId = JSON.parse( + await runOut(bin, ['--source', 'codex', expectedSessionId.slice(0, 8), '--json'], tempDir), + ); + assertCodex(byId); + assert.equal( + JSON.parse(await runOut(bin, ['--source', 'codex', '--latest', '--json'], tempDir)) + .session_id, + otherSessionId, + ); + await smokeMcp( join(tempDir, 'node_modules/@seungwoolee/agent-tree/dist/mcp-server.js'), version, + rolloutPath, ); console.log(`PASS smoke-release: packed, installed and exercised ${version}`); } finally { @@ -57,6 +116,68 @@ async function main() { } } +async function prepareCodexDiscovery() { + const directory = join(tempDir, 'home', '.codex', 'sessions', '2026', '01', '02'); + const otherProject = join(tempDir, 'other-project'); + await Promise.all([mkdir(directory, { recursive: true }), mkdir(otherProject)]); + const raw = await readFile(codexFixture, 'utf8'); + const paths = []; + for (const [sessionId, cwd, modified] of [ + [expectedSessionId, tempDir, 1_700_000_000], + [otherSessionId, otherProject, 1_700_000_100], + ]) { + const records = raw + .trim() + .split('\n') + .map((line) => { + const record = JSON.parse(line); + if (record.type === 'session_meta') record.payload.id = sessionId; + if (record.payload.cwd) record.payload.cwd = cwd; + return JSON.stringify(record); + }); + const path = join(directory, `rollout-2026-01-02T03-04-05-${sessionId}.jsonl`); + await writeFile(path, records.join('\n') + '\n'); + await utimes(path, modified, modified); + paths.push(path); + } + return paths[0]; +} + +function assertCodex(map) { + assert.equal(map.source, 'codex'); + assert.equal(map.session_id, expectedSessionId); + assert.equal(map.stats.total_events, 10); + assert.equal(map.stats.total_turns, 4); + assert.equal(map.stats.total_tool_calls, 2); + assert.ok(map.root.children.length > 0); + assert.ok(map.root.files_touched.includes('src/app.ts')); + assert.ok(map.root.tools_used.includes('apply_patch')); + assert.ok(map.root.tools_used.includes('read_file')); + assert.doesNotMatch( + JSON.stringify(map), + /SYNTHETIC_PRIVATE_INSTRUCTIONS|SYNTHETIC_PRIVATE_REASONING|dddd1111-2222-3333-4444-555566667777/, + ); +} + +function assertCatalog(catalog, rolloutPath) { + assert.equal(catalog.sessions.length, 1); + const entry = catalog.sessions[0]; + assert.equal(entry.source, 'codex'); + assert.equal(entry.sessionId, expectedSessionId); + assert.equal(entry.projectDir, tempDir); + assert.equal(entry.jsonlPath, rolloutPath); + assert.ok(entry.sizeBytes > 0); + assert.ok(Number.isFinite(entry.mtimeMs)); + assert.deepEqual( + Object.keys(entry).sort(), + ['source', 'sessionId', 'projectDir', 'jsonlPath', 'mtimeMs', 'sizeBytes'].sort(), + ); + assert.doesNotMatch( + JSON.stringify(catalog), + /SYNTHETIC_PRIVATE|owner@example\.com|Please update/, + ); +} + async function pack(destination) { const stdout = await runOut('npm', ['pack', '--json', '--pack-destination', destination], root); const parsed = JSON.parse(stdout); @@ -65,7 +186,7 @@ async function pack(destination) { return join(destination, item.filename); } -async function smokeMcp(serverPath, version) { +async function smokeMcp(serverPath, version, rolloutPath) { const child = spawn(process.execPath, [serverPath], { cwd: tempDir, env: childEnv(), @@ -115,7 +236,15 @@ async function smokeMcp(serverPath, version) { assert.equal(schemas.agent_tree_sessions.properties.limit.default, 20); assert.equal(schemas.agent_tree_sessions.properties.limit.minimum, 1); assert.equal(schemas.agent_tree_sessions.properties.limit.maximum, 1000); - assert.deepEqual(schemas.agent_tree_picks.properties ?? {}, {}); + assert.deepEqual(Object.keys(schemas.agent_tree_picks.properties ?? {}), ['source']); + for (const name of expectedTools) { + assert.equal(schemas[name].properties.source.type, 'string'); + assert.deepEqual(schemas[name].properties.source.enum, ['claude', 'codex']); + assert.ok( + !(schemas[name].required ?? []).includes('source'), + `${name} source should be optional`, + ); + } for (const name of [ 'agent_tree_list', 'agent_tree_snapshot', @@ -130,11 +259,50 @@ async function smokeMcp(serverPath, version) { name: 'agent_tree_list', arguments: { cwd: tempDir, file: fixture, format: 'json' }, }); + assert.notEqual(result.isError, true); + assert.equal(result.structuredContent?.mindmap?.source, 'claude'); assert.equal(result.structuredContent?.mindmap?.session_id, expectedSessionId); assert.match( result.content?.[0]?.text ?? '', new RegExp(`"session_id": "${expectedSessionId}"`), ); + for (const selection of [ + { file: codexFixture }, + { file: codexFixture, source: 'codex' }, + { sessionId: expectedSessionId.slice(0, 8), source: 'codex' }, + { source: 'codex' }, + ]) { + const codex = await rpc('tools/call', { + name: 'agent_tree_list', + arguments: { cwd: tempDir, format: 'json', ...selection }, + }); + assert.notEqual(codex.isError, true); + assertCodex(codex.structuredContent?.mindmap); + assert.deepEqual(JSON.parse(codex.content[0].text), codex.structuredContent.mindmap); + } + const catalog = await rpc('tools/call', { + name: 'agent_tree_sessions', + arguments: { source: 'codex', cwd: tempDir, limit: 1 }, + }); + assert.notEqual(catalog.isError, true); + assertCatalog(catalog.structuredContent, rolloutPath); + assert.deepEqual(JSON.parse(catalog.content[0].text), catalog.structuredContent); + const defaultCatalog = await rpc('tools/call', { name: 'agent_tree_sessions', arguments: {} }); + assert.notEqual(defaultCatalog.isError, true); + assert.deepEqual(defaultCatalog.structuredContent?.sessions, []); + for (const [file, source] of [ + [codexFixture, 'claude'], + [fixture, 'codex'], + ]) { + const mismatch = await rpc('tools/call', { + name: 'agent_tree_list', + arguments: { cwd: tempDir, file, source, format: 'json' }, + }); + assert.equal(mismatch.isError, true); + assert.equal(mismatch.structuredContent, undefined); + assert.match(mismatch.content[0].text, /does not match the selected source/); + assert.doesNotMatch(mismatch.content[0].text, /SYNTHETIC_PRIVATE|owner@example\.com/); + } } finally { clearTimeout(timer); stopping = true; @@ -225,10 +393,12 @@ function childEnv() { XDG_CONFIG_HOME: join(home, '.config'), XDG_CACHE_HOME: join(home, '.cache'), CLAUDE_CONFIG_DIR: join(home, '.claude'), + CODEX_HOME: join(home, '.codex'), NPM_CONFIG_CACHE: cache, NPM_CONFIG_USERCONFIG: join(tempDir, '.npmrc'), npm_config_cache: cache, AGENT_TREE_NO_LLM: 'true', + ANTHROPIC_API_KEY: '', NO_COLOR: '1', }; } diff --git a/src/cli.ts b/src/cli.ts index b56f916..2ab0994 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -29,7 +29,8 @@ import { import { loadConfig } from './config/loader.js'; import { createLoggerSync, type LogLevel } from './utils/logger.js'; import { pickSession } from './utils/picker.js'; -import { defaultRedactor, redactDeep } from './utils/redact.js'; +import { defaultRedactor, redactDeep, type Redactor } from './utils/redact.js'; +import type { SessionSourceId } from './sources/types.js'; import { findLatestSession, findLatestSessionInProject, @@ -62,6 +63,7 @@ export async function main(argv: string[] = process.argv): Promise { if (opts.sessions) { const entries = await listSessions({ + source: opts.source, projectCwd, limit: opts.limit ?? 20, }); @@ -73,7 +75,7 @@ export async function main(argv: string[] = process.argv): Promise { : safeEntries .map( (entry) => - `${entry.sessionId} ${new Date(entry.mtimeMs).toISOString()} ${entry.sizeBytes} bytes ${entry.projectDir}`, + `${entry.source} ${entry.sessionId} ${new Date(entry.mtimeMs).toISOString()} ${entry.sizeBytes} bytes ${entry.projectDir}`, ) .join('\n') + '\n', ); @@ -83,12 +85,18 @@ export async function main(argv: string[] = process.argv): Promise { // --picks is session-independent (lists picks across every session); handle // before the session-resolution step. if (opts.picks) { - return runPicksMode(buildRedactor(opts, config, logger)); + return runPicksMode(buildRedactor(opts, config, logger), opts.source); } // No explicit selector → smart default: latest session in current project, // falling back to globally latest if this project has no sessions yet. - const resolved = await resolveSession(sessionArg, opts, logger, cwd); + const resolved = await resolveSession( + sessionArg, + opts, + logger, + cwd, + buildRedactor(opts, config, logger), + ); if (!resolved.ok) { // 130 = SIGINT-style "user cancelled" (conventional for interactive pick // aborts). 2 = POSIX "misuse / not found". Discriminated union makes @@ -176,19 +184,21 @@ type SessionResolution = async function resolveSession( sessionArg: string | undefined, - opts: { pick?: boolean; latest?: boolean; file?: string }, + opts: { pick?: boolean; latest?: boolean; file?: string; source?: SessionSourceId }, logger: { info(msg: string, extra?: unknown): void; debug(msg: string, extra?: unknown): void }, cwd: string, + redactor: Redactor, ): Promise { + const selection = { source: opts.source }; if (opts.file) { - return { ok: true, match: await sessionFromFile(opts.file) }; + return { ok: true, match: await sessionFromFile(opts.file, opts.source) }; } if (opts.pick) { if (!process.stdin.isTTY) { console.error('error: --pick requires an interactive terminal; use --sessions instead'); return { ok: false, reason: 'not_found' }; } - const picked = await pickSession(); + const picked = await pickSession({ ...selection, redactor }); if (!picked) { console.error('Selection cancelled.'); return { ok: false, reason: 'cancelled' }; @@ -196,7 +206,7 @@ async function resolveSession( return { ok: true, match: picked }; } if (opts.latest) { - const latest = await findLatestSession(); + const latest = await findLatestSession(selection); if (!latest) { console.error('error: no sessions found; use --file to open an exported JSONL'); return { ok: false, reason: 'not_found' }; @@ -205,7 +215,7 @@ async function resolveSession( } if (!sessionArg) { // Smart default — try this project's latest first - const inProject = await findLatestSessionInProject(cwd); + const inProject = await findLatestSessionInProject(cwd, selection); if (inProject) { logger.debug("smart default → this project's latest session", { sessionId: inProject.sessionId, @@ -213,17 +223,19 @@ async function resolveSession( return { ok: true, match: inProject }; } // Fall back to globally latest with a brief notice - const global = await findLatestSession(); + const global = await findLatestSession(selection); if (global) { console.error( - `(no session in this project — falling back to globally latest: ${global.sessionId.slice(0, 8)} from ${global.projectDir})`, + redactor.apply( + `(no session in this project — falling back to globally latest: ${global.sessionId.slice(0, 8)} from ${global.projectDir})`, + ), ); return { ok: true, match: global }; } console.error('error: no sessions found; use --file to open an exported JSONL'); return { ok: false, reason: 'not_found' }; } - const matches = await locateSession(sessionArg); + const matches = await locateSession(sessionArg, selection); if (matches.length === 0) { console.error( `error: no session matched "${sessionArg}"; use --sessions to list available sessions`, @@ -236,8 +248,10 @@ async function resolveSession( .map((m) => ` • ${m.sessionId} (${m.projectDir})`) .join('\n'); console.error( - `error: "${sessionArg}" is ambiguous; ${matches.length} matches:\n${lines}\n` + - `\nRe-run with a longer prefix.`, + redactor.apply( + `error: "${sessionArg}" is ambiguous; ${matches.length} matches:\n${lines}\n` + + `\nRe-run with a longer prefix.`, + ), ); return { ok: false, reason: 'not_found' }; } diff --git a/src/cli/modes.ts b/src/cli/modes.ts index 078bed2..5129c54 100644 --- a/src/cli/modes.ts +++ b/src/cli/modes.ts @@ -12,6 +12,7 @@ import type { MindMap, SessionGraph, TopicSegment } from '../types.js'; import type { Logger } from '../utils/logger.js'; import { redactDeep, type Redactor } from '../utils/redact.js'; import type { SessionMatch } from '../utils/session_path.js'; +import type { SessionSourceId } from '../sources/types.js'; import type { CliOptions } from './options.js'; import type { ResolvedConfig } from './pipeline.js'; @@ -42,7 +43,7 @@ export async function runListMode(ctx: ModeContext): Promise { process.stdout.write(JSON.stringify(redactDeep(ctx.mindmap, ctx.redactor), null, 2) + '\n'); return 0; } - const picks = await readPicks(ctx.match.sessionId); + const picks = await readPicks(ctx.match.sessionId, { source: ctx.match.source }); const tree = renderTextTree(ctx.mindmap, { filter: ctx.opts.filter, groupConsecutive: ctx.opts.group !== false, @@ -57,7 +58,7 @@ export async function runListMode(ctx: ModeContext): Promise { process.stdout.write( ctx.redactor.apply( `Pick a node by number. Re-run as:\n` + - ` agent-tree ${selector} --snapshot --mode continue|fork\n`, + ` agent-tree --source ${ctx.match.source} ${selector} --snapshot --mode continue|fork\n`, ), ); if (picks.total > 0) { @@ -101,9 +102,9 @@ export async function runSnapshotMode(ctx: ModeContext): Promise { // Record this pick so future --list / --tui can mark visited nodes. // Best-effort — swallow errors so a busted cache dir never blocks output. - await recordPick(ctx.match.sessionId, node.id, wantFork ? 'fork' : 'continue').catch((err) => - ctx.logger.warn?.('pick history write failed', { error: String(err) }), - ); + await recordPick(ctx.match.sessionId, node.id, wantFork ? 'fork' : 'continue', { + source: ctx.match.source, + }).catch((err) => ctx.logger.warn?.('pick history write failed', { error: String(err) })); // TTY-only: also push to system clipboard so the user can immediately paste // into a new `claude` session without remembering `| pbcopy`. When piped or @@ -142,8 +143,8 @@ function appendGitSection(snapshotMd: string, gitMd: string): string { // --picks — list every pick across every session (no session arg needed) // --------------------------------------------------------------------------- -export async function runPicksMode(redactor: Redactor): Promise { - const all = await listAllPicks(); +export async function runPicksMode(redactor: Redactor, source?: SessionSourceId): Promise { + const all = await listAllPicks({ source }); if (all.length === 0) { process.stderr.write('No picks recorded yet.\n'); return 0; @@ -152,7 +153,7 @@ export async function runPicksMode(redactor: Redactor): Promise { let totalPicks = 0; for (const session of all) { lines.push( - `session ${session.sessionId.slice(0, 8)} (${session.picks.length} pick${session.picks.length === 1 ? '' : 's'})`, + `${session.source} session ${session.sessionId.slice(0, 8)} (${session.picks.length} pick${session.picks.length === 1 ? '' : 's'})`, ); for (const p of session.picks) { const when = p.ts.replace('T', ' ').slice(0, 19); @@ -180,7 +181,9 @@ export async function runUnstarMode(ctx: ModeContext): Promise { console.error(`error: no node matches "${ctx.opts.unstar}". Run --list to see numbers.`); return 2; } - const removed = await removePicksForNode(ctx.match.sessionId, node.id); + const removed = await removePicksForNode(ctx.match.sessionId, node.id, { + source: ctx.match.source, + }); if (removed === 0) { process.stderr.write(`No picks recorded for ${node.id} — nothing to unstar.\n`); return 0; @@ -251,7 +254,7 @@ export function runDiffMode(ctx: ModeContext): number { // --------------------------------------------------------------------------- export async function runTuiMode(ctx: ModeContext): Promise { - const picks = await readPicks(ctx.match.sessionId); + const picks = await readPicks(ctx.match.sessionId, { source: ctx.match.source }); const result = await runTui(ctx.mindmap, { render: { filter: ctx.opts.filter, diff --git a/src/cli/options.ts b/src/cli/options.ts index 38f8332..7aa1ade 100644 --- a/src/cli/options.ts +++ b/src/cli/options.ts @@ -6,11 +6,13 @@ import { Command, InvalidArgumentError, Option } from 'commander'; import { VERSION } from '../version.js'; +import type { SessionSourceId } from '../sources/types.js'; export interface CliOptions { latest?: boolean; pick?: boolean; file?: string; + source?: SessionSourceId; cwd?: string; sessions?: boolean; limit?: number; @@ -50,13 +52,19 @@ export function parseCliArgs(argv: string[]): ParsedArgs { program .name('agent-tree') .description( - 'Navigate a Claude Code session as a numbered file-tree in your terminal and resume from any node.', + 'Navigate Claude Code or Codex sessions as numbered trees and generate resume prompts.', ) .version(VERSION, '-V, --version', 'print agent-tree version') .argument('[session-id]', 'session UUID or short prefix (e.g. 69c2f35e)') .option('--latest', 'use the most recently modified session') .option('--pick', 'interactive picker over recent sessions') - .option('--file ', 'read an exported Claude Code JSONL file directly') + .option('--file ', 'read a Claude Code or Codex JSONL file (auto-detected)') + .addOption( + new Option('--source ', 'session source (discovery: claude; file: auto)').choices([ + 'claude', + 'codex', + ]), + ) .option('--cwd ', 'project directory for session discovery and configuration') .option('--sessions', 'list recent sessions without analyzing their contents') .option('--limit ', 'maximum sessions to list (default: 20)', positiveInteger) diff --git a/src/cli/pipeline.ts b/src/cli/pipeline.ts index d815dec..6a41d0a 100644 --- a/src/cli/pipeline.ts +++ b/src/cli/pipeline.ts @@ -76,7 +76,9 @@ export async function runPipeline(deps: PipelineDeps): Promise { // [1/5] Parsing JSONL progress('[1/5] Parsing JSONL... '); - const { meta, events, malformedCount, skippedMetaCount } = await getSessionSource(match.source).read(match.jsonlPath, { + const { meta, events, malformedCount, skippedMetaCount } = await getSessionSource( + match.source, + ).read(match.jsonlPath, { logger, strict: opts.strict, }); @@ -97,6 +99,7 @@ export async function runPipeline(deps: PipelineDeps): Promise { if (events.length === 0) { // Caller decides how to surface "empty session" — just return a stub. const emptyMindmap: MindMap = { + source: match.source, session_id: meta.sessionId, project_path: '', generated_at: new Date().toISOString(), @@ -153,6 +156,7 @@ export async function runPipeline(deps: PipelineDeps): Promise { // [4/5] Mindmap (heuristic) progress('[4/5] Building mindmap... '); const mindmap = buildMindMap(graph, segments, { + source: match.source, jsonlPath: match.jsonlPath, specVersion: SPEC_VERSION, redactor, diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 3f39d51..8e59090 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1,4 +1,4 @@ -/** Native MCP tools for discovering, navigating and resuming Claude Code sessions. */ +/** MCP tools for discovering sessions and generating source-independent resume prompts. */ import { stat } from 'node:fs/promises'; import { resolve } from 'node:path'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; @@ -23,21 +23,27 @@ import { import { formatGitContextMarkdown, getGitContext } from '../utils/git.js'; import { listAllPicks, readPicks, recordPick, removePicksForNode } from '../utils/picks.js'; import { VERSION } from '../version.js'; +import type { SessionSourceId } from '../sources/types.js'; const logger = createLoggerSync('warn'); +const sourceInput = z + .enum(['claude', 'codex']) + .optional() + .describe('Session source. Discovery defaults to claude; file imports auto-detect.'); const sessionInput = { + source: sourceInput, sessionId: z.string().optional().describe('UUID prefix; omit for the latest session in cwd.'), file: z .string() .optional() - .describe('Exported Claude Code JSONL path; mutually exclusive with sessionId.'), + .describe('Claude Code or Codex JSONL path; mutually exclusive with sessionId.'), cwd: z .string() .min(1) .transform((cwd) => resolve(cwd)) .describe('Caller project directory for session selection and configuration.'), }; -type SessionInput = { sessionId?: string; file?: string; cwd: string }; +type SessionInput = { sessionId?: string; file?: string; cwd: string; source?: SessionSourceId }; const readOnly = { readOnlyHint: true, destructiveHint: false, openWorldHint: false }; function text(value: string): CallToolResult { @@ -57,17 +63,18 @@ function safely( }; } -async function resolveMatch({ sessionId, file, cwd }: SessionInput): Promise { +async function resolveMatch({ sessionId, file, cwd, source }: SessionInput): Promise { if (file) { if (sessionId) throw new Error('Use either sessionId or file, not both.'); - return sessionFromFile(file); + return sessionFromFile(file, source); } if (sessionId) { - const matches = await locateSession(sessionId); + const matches = await locateSession(sessionId, { source }); if (matches.length > 1) throw new Error('Ambiguous session id; use a longer UUID prefix.'); if (matches.length === 1) return matches[0]; } else { - const match = (await findLatestSessionInProject(cwd)) ?? (await findLatestSession()); + const match = + (await findLatestSessionInProject(cwd, { source })) ?? (await findLatestSession({ source })); if (match) return match; } throw new Error('No matching session found.'); @@ -86,6 +93,7 @@ export function createServer(): McpServer { const { config } = await loadConfig({ projectCwd: input.cwd, logger }); const info = await stat(match.jsonlPath); const key = JSON.stringify([ + match.source, match.jsonlPath, info.mtimeMs, info.ctimeMs, @@ -120,8 +128,9 @@ export function createServer(): McpServer { 'agent_tree_sessions', { description: - 'List recent Claude Code sessions without parsing their contents. Returns ids, paths, modification times and sizes.', + 'List recent sessions for one source (default claude). Codex reads metadata headers only. Returns sources, ids, paths, modification times and sizes.', inputSchema: { + source: sourceInput, cwd: z .string() .min(1) @@ -132,11 +141,11 @@ export function createServer(): McpServer { }, annotations: readOnly, }, - safely(async ({ cwd, limit }) => { + safely(async ({ cwd, limit, source }) => { if (cwd && !(await stat(cwd)).isDirectory()) throw new Error('cwd must name a directory.'); const { config } = await loadConfig({ projectCwd: cwd, logger }); const redactor = buildRedactor({}, config, logger); - const sessions = redactDeep(await listSessions({ projectCwd: cwd, limit }), redactor); + const sessions = redactDeep(await listSessions({ source, projectCwd: cwd, limit }), redactor); return { ...text(JSON.stringify({ sessions }, null, 2)), structuredContent: { sessions } }; }), ); @@ -145,7 +154,7 @@ export function createServer(): McpServer { 'agent_tree_list', { description: - 'Render a numbered tree of a Claude Code session, or export its complete redacted mindmap as JSON.', + 'Render a numbered tree of a Claude Code or Codex session, or export its complete redacted mindmap as JSON.', inputSchema: { ...sessionInput, phasesOnly: z.boolean().optional().describe('Hide sub-actions; show phase headers only.'), @@ -167,7 +176,7 @@ export function createServer(): McpServer { return { ...text(JSON.stringify(mindmap, null, 2)), structuredContent: { mindmap } }; } if (result.isEmpty) return text('Session is empty.'); - const picks = await readPicks(match.sessionId); + const picks = await readPicks(match.sessionId, { source: match.source }); const tree = renderTextTree(result.mindmap, { filter: input.filter, groupConsecutive: true, @@ -181,7 +190,7 @@ export function createServer(): McpServer { : ''; return text( result.redactor.apply( - `Session ${match.sessionId.slice(0, 8)} (${match.projectDir})\n\n${tree.text}${footer}`, + `${match.source} session ${match.sessionId.slice(0, 8)} (${match.projectDir})\n\n${tree.text}${footer}`, ), ); }), @@ -217,8 +226,9 @@ export function createServer(): McpServer { ? markdown.slice(0, index) + gitMd + '\n\n' + markdown.slice(index) : markdown + '\n\n' + gitMd + '\n'; } - await recordPick(match.sessionId, node.id, input.mode).catch((error) => - logger.warn('pick history write failed', { error: redactor.apply(String(error)) }), + await recordPick(match.sessionId, node.id, input.mode, { source: match.source }).catch( + (error) => + logger.warn('pick history write failed', { error: redactor.apply(String(error)) }), ); return text(redactor.apply(markdown)); }), @@ -229,16 +239,18 @@ export function createServer(): McpServer { { description: 'List every recorded pick across every session: session, node, mode and timestamp.', - inputSchema: {}, + inputSchema: { source: sourceInput }, annotations: readOnly, }, - safely(async () => { - const all = await listAllPicks(); + safely(async ({ source }) => { + const all = await listAllPicks({ source }); if (all.length === 0) return text('No picks recorded yet.'); const lines: string[] = []; let total = 0; for (const session of all) { - lines.push(`session ${session.sessionId.slice(0, 8)} (${session.picks.length} picks)`); + lines.push( + `${session.source} session ${session.sessionId.slice(0, 8)} (${session.picks.length} picks)`, + ); for (const pick of session.picks) { lines.push(` ${pick.ts} ${pick.mode} ${pick.node_id}`); total++; @@ -318,7 +330,7 @@ export function createServer(): McpServer { if (result.isEmpty) throw new Error('Session is empty.'); const node = lookupSnapshot(result.mindmap, input.nodeId, renderTextTree(result.mindmap)); if (!node) throw new Error('No matching node.'); - const removed = await removePicksForNode(match.sessionId, node.id); + const removed = await removePicksForNode(match.sessionId, node.id, { source: match.source }); return text( removed === 0 ? `No picks recorded for ${node.id}.` diff --git a/src/reader/codex.ts b/src/reader/codex.ts new file mode 100644 index 0000000..703d082 --- /dev/null +++ b/src/reader/codex.ts @@ -0,0 +1,470 @@ +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { createInterface } from 'node:readline'; +import { isFullUuid } from '../sources/files.js'; +import type { ReadSessionOptions, ReadSessionResult } from '../sources/types.js'; +import type { EventEnvelope, MessageContentBlock, RawEvent, SessionMeta } from '../types.js'; + +type Invalid = (reason: string) => void; +type EventPayload = Event extends RawEvent + ? Omit + : never; + +interface MessageOrigin { + kind: 'canonical' | 'fallback'; + role: 'user' | 'assistant'; + text: string; + turnId: string; + boundary: number; +} + +interface Candidate { + event: RawEvent; + origin?: MessageOrigin; + suppressed?: boolean; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function textField( + obj: Record, + key: string, + invalid: Invalid, + required = true, +): string | undefined { + const value = obj[key]; + if (typeof value === 'string') return value; + if (required || value !== undefined) invalid(`${key} must be a string`); + return undefined; +} + +function identifier( + obj: Record, + key: string, + invalid: Invalid, +): string | undefined { + const value = textField(obj, key, invalid); + if (value !== undefined && !value.trim()) { + invalid(`${key} must be nonempty`); + return undefined; + } + return value; +} + +function publicText( + value: unknown, + allowed: readonly string[], + invalid: Invalid, +): MessageContentBlock[] { + if (!Array.isArray(value)) { + invalid('content must be an array'); + return []; + } + const blocks: MessageContentBlock[] = []; + for (const block of value) { + if (!isRecord(block) || typeof block.type !== 'string' || !block.type.trim()) { + invalid('content blocks require a nonempty string type'); + continue; + } + if (!allowed.includes(block.type)) continue; + const text = textField(block, 'text', invalid); + if (text !== undefined) blocks.push({ type: 'text', text }); + } + return blocks; +} + +function blockText(blocks: MessageContentBlock[]): string { + return blocks + .map((block) => ('text' in block && typeof block.text === 'string' ? block.text : '')) + .join('\n'); +} + +function patchInput(name: string, input: unknown): unknown { + if (name.split('.').at(-1) !== 'apply_patch') return input; + const object = isRecord(input) ? input : undefined; + const patch = typeof input === 'string' ? input : (object?.input ?? object?.patch); + if (typeof patch !== 'string') return input; + const paths = new Set(); + if (Array.isArray(object?.paths)) { + for (const path of object.paths) if (typeof path === 'string') paths.add(path); + } + for (const line of patch.split('\n')) { + const match = /^\*\*\* (?:(?:Add|Update|Delete) File|Move to): (.+?)\r?$/.exec(line); + if (match && match[1].trim()) paths.add(match[1].trim()); + } + return { ...(object ?? { input: patch }), paths: [...paths] }; +} + +function toolCall(payload: Record, invalid: Invalid): EventPayload | null { + const name = identifier(payload, 'name', invalid); + const id = identifier(payload, 'call_id', invalid); + let input: unknown; + if (payload.type === 'function_call') { + const argumentsText = textField(payload, 'arguments', invalid); + if (argumentsText === undefined) return null; + try { + input = JSON.parse(argumentsText); + } catch { + invalid('arguments must contain valid JSON'); + return null; + } + } else { + input = textField(payload, 'input', invalid); + if (input === undefined) return null; + } + return name && id + ? { type: 'tool_use', tool_use: { id, name, input: patchInput(name, input) } } + : null; +} + +function localShell(payload: Record, invalid: Invalid): EventPayload | null { + // Earlier Responses records used id instead of call_id for local shell calls. + const id = identifier(payload, payload.call_id === undefined ? 'id' : 'call_id', invalid); + if (!isRecord(payload.action)) { + invalid('action must be an object'); + return null; + } + const action = payload.action; + const type = identifier(action, 'type', invalid); + if (type !== 'exec') return null; + if ( + !Array.isArray(action.command) || + action.command.length === 0 || + !action.command.every((part) => typeof part === 'string') + ) { + invalid('command must be a nonempty array of strings'); + return null; + } + const input = { ...action }; + if (action.working_directory === null) { + delete input.working_directory; + } else if ( + action.working_directory !== undefined && + typeof action.working_directory !== 'string' + ) { + invalid('working_directory must be a string'); + delete input.working_directory; + } + if (action.timeout_ms === null) { + delete input.timeout_ms; + } else if ( + action.timeout_ms !== undefined && + (typeof action.timeout_ms !== 'number' || + !Number.isFinite(action.timeout_ms) || + action.timeout_ms < 0) + ) { + invalid('timeout_ms must be a nonnegative finite number'); + delete input.timeout_ms; + } + return id ? { type: 'tool_use', tool_use: { id, name: 'local_shell', input } } : null; +} + +function responseItem(payload: Record, invalid: Invalid): EventPayload | null { + const type = identifier(payload, 'type', invalid); + switch (type) { + case 'message': { + const role = identifier(payload, 'role', invalid); + const content = publicText(payload.content, ['input_text', 'output_text'], invalid); + // Never retain system/developer instructions, encrypted blocks, or opaque + // future message content. Only the public text projection crosses here. + return (role === 'user' || role === 'assistant') && Array.isArray(payload.content) + ? { type: role, message: { role, content } } + : null; + } + case 'function_call': + case 'custom_tool_call': + return toolCall(payload, invalid); + case 'function_call_output': + case 'custom_tool_call_output': { + const id = identifier(payload, 'call_id', invalid); + if (typeof payload.output !== 'string' && !Array.isArray(payload.output)) { + invalid('output must be a string or an array'); + return null; + } + const content = + typeof payload.output === 'string' + ? payload.output + : publicText(payload.output, ['input_text', 'output_text'], invalid); + return id ? { type: 'tool_result', tool_result: { tool_use_id: id, content } } : null; + } + case 'local_shell_call': + return localShell(payload, invalid); + case 'reasoning': { + const summary = blockText(publicText(payload.summary, ['summary_text'], invalid)); + return summary ? { type: 'system', payload: { type: 'reasoning', summary } } : null; + } + default: + // In particular, agent_message is not the public role/content schema. + return null; + } +} + +function permissionMode(payload: Record, invalid: Invalid): string | undefined { + for (const key of ['permissionMode', 'permission_mode']) { + if (payload[key] !== undefined) return textField(payload, key, invalid); + } + // Structured future approval policies have no lossless string projection. + return typeof payload.approval_policy === 'string' ? payload.approval_policy : undefined; +} + +function recordUuid(raw: string, lineNo: number): string { + // Neither ordinal (which can reset on resume) nor the emitted-event index is + // an identity. Physical line + contents stays stable after append and dedup. + const hex = createHash('sha256').update(`codex\0${lineNo}\0`).update(raw).digest('hex'); + const variant = ((parseInt(hex[16], 16) & 3) | 8).toString(16); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-8${hex.slice(13, 16)}-${variant}${hex.slice(17, 20)}-${hex.slice(20, 32)}`; +} + +/** Match occurrences, not text sets: two identical legitimate turns stay two. */ +function suppressFallbacks(candidates: Candidate[]): number { + let role: MessageOrigin['role'] | undefined; + let turnId = ''; + let boundary = 0; + let suppressed = 0; + const pending = new Map< + string, + { kind: MessageOrigin['kind']; items: Candidate[]; next: number } + >(); + for (const candidate of candidates) { + const origin = candidate.origin; + if (!origin) continue; + if ( + boundary !== origin.boundary || + ((!turnId || !origin.turnId) && (role !== origin.role || turnId !== origin.turnId)) + ) { + pending.clear(); + } + role = origin.role; + turnId = origin.turnId; + boundary = origin.boundary; + // Explicit turns allow independently interleaved role queues. Without one, + // an opposite-role message is the only available conversational boundary. + const key = JSON.stringify([turnId, role, origin.text]); + const queue = pending.get(key); + if (queue && queue.kind !== origin.kind) { + const other = queue.items[queue.next++]; + if ( + origin.kind === 'canonical' && + (candidate.event.type === 'user' || candidate.event.type === 'assistant') && + (other.event.type === 'user' || other.event.type === 'assistant') + ) { + // Authority belongs to the canonical payload, but append must not + // replace the first occurrence's identity or chronological position. + other.event.message = candidate.event.message; + } + candidate.suppressed = true; + suppressed += 1; + if (queue.next === queue.items.length) pending.delete(key); + } else if (queue) { + queue.items.push(candidate); + } else { + pending.set(key, { kind: origin.kind, items: [candidate], next: 0 }); + } + } + return suppressed; +} + +export async function readCodex( + path: string, + opts: ReadSessionOptions = {}, +): Promise { + const { logger, strict = false } = opts; + const stream = createReadStream(path, { encoding: 'utf8' }); + const lines = createInterface({ input: stream, crlfDelay: Infinity }); + const meta: SessionMeta = { sessionId: '', permissionMode: '' }; + const context = { cwd: '', gitBranch: '', version: '', entrypoint: '', isSidechain: false }; + const candidates: Candidate[] = []; + let turnId = ''; + let boundary = 0; + let malformedCount = 0; + let skippedMetaCount = 0; + let lineNo = 0; + let sawMeta = false; + + try { + for await (const raw of lines) { + lineNo += 1; + if (!raw.trim()) continue; + const issues = new Set(); + const invalid: Invalid = (reason) => { + if (strict) throw new Error(`codex jsonl error at line ${lineNo} — ${reason}`); + issues.add(reason); + }; + let parsed: unknown; + try { + parsed = JSON.parse(raw.trim()); + } catch { + invalid('invalid JSON'); + } + let event: EventPayload | null = null; + let origin: MessageOrigin | undefined; + let timestamp = ''; + let metadata = true; + if (parsed !== undefined) { + if (!isRecord(parsed)) { + invalid('expected an object'); + } else { + const type = identifier(parsed, 'type', invalid); + if ( + type && + ['session_meta', 'turn_context', 'response_item', 'event_msg', 'compacted'].includes( + type, + ) + ) { + timestamp = textField(parsed, 'timestamp', invalid) ?? ''; + if (typeof parsed.timestamp === 'string' && !Number.isFinite(Date.parse(timestamp))) { + invalid('timestamp must be a valid date'); + } + if ( + parsed.ordinal !== undefined && + (typeof parsed.ordinal !== 'number' || + !Number.isSafeInteger(parsed.ordinal) || + parsed.ordinal < 0) + ) { + invalid('ordinal must be a nonnegative safe integer'); + } + if (!isRecord(parsed.payload)) { + invalid('payload must be an object'); + } else { + const payload = parsed.payload; + switch (type) { + case 'session_meta': { + const id = identifier(payload, 'id', invalid); + if (id && !isFullUuid(id)) invalid('session id must be a UUID'); + // id identifies this thread/rollout; session_id identifies its + // root thread and may legitimately name a different UUID. + if ( + payload.session_id !== undefined && + (typeof payload.session_id !== 'string' || !isFullUuid(payload.session_id)) + ) { + invalid('root session id must be a UUID'); + } + if (id && isFullUuid(id)) { + if (meta.sessionId && id.toLowerCase() !== meta.sessionId.toLowerCase()) { + invalid('session metadata id changed'); + } else { + meta.sessionId = id; + } + } + const cwd = identifier(payload, 'cwd', invalid); + if (cwd !== undefined) context.cwd = cwd; + const version = textField(payload, 'cli_version', invalid, false); + if (version !== undefined) context.version = version; + if (payload.git !== undefined && payload.git !== null) { + if (!isRecord(payload.git)) invalid('git must be an object'); + else if (payload.git.branch !== null) { + const branch = textField(payload.git, 'branch', invalid, false); + if (branch !== undefined) context.gitBranch = branch; + } + } + context.entrypoint = typeof payload.source === 'string' ? payload.source : ''; + context.isSidechain = + payload.source === 'subagent' || + (isRecord(payload.source) && Object.hasOwn(payload.source, 'subagent')); + const permission = permissionMode(payload, invalid); + if (permission !== undefined) meta.permissionMode = permission; + metadata = sawMeta; + sawMeta = true; + break; + } + case 'turn_context': { + const cwd = identifier(payload, 'cwd', invalid); + if (cwd !== undefined) context.cwd = cwd; + turnId = ''; + if (payload.turn_id !== undefined) { + const id = identifier(payload, 'turn_id', invalid); + if (id !== undefined) turnId = id; + } + const permission = permissionMode(payload, invalid); + if (permission !== undefined) meta.permissionMode = permission; + break; + } + case 'response_item': + if ( + typeof payload.type === 'string' && + (payload.type.endsWith('_call') || payload.type.endsWith('_call_output')) + ) { + boundary += 1; + } + event = responseItem(payload, invalid); + if (event?.type === 'user' || event?.type === 'assistant') { + origin = { + kind: 'canonical', + role: event.type, + turnId, + boundary, + text: blockText(event.message.content as MessageContentBlock[]), + }; + } + break; + case 'event_msg': { + const eventType = identifier(payload, 'type', invalid); + // Tool notifications are not emitted as duplicate calls, but + // intervening work still separates message occurrences. + if (payload.call_id !== undefined) boundary += 1; + if (payload.turn_id !== undefined && typeof payload.turn_id === 'string') + turnId = payload.turn_id; + if (eventType === 'user_message' || eventType === 'agent_message') { + const text = textField(payload, 'message', invalid); + if (text !== undefined) { + const role = eventType === 'user_message' ? 'user' : 'assistant'; + event = { type: role, message: { role, content: [{ type: 'text', text }] } }; + origin = { kind: 'fallback', role, text, turnId, boundary }; + } + } + break; + } + case 'compacted': { + boundary += 1; + const message = textField(payload, 'message', invalid); + if (message !== undefined) + event = { type: 'system', payload: { type: 'compaction', message } }; + break; + } + } + } + } + } + } + if (event) { + candidates.push({ + event: { + ...context, + uuid: recordUuid(raw, lineNo), + parentUuid: null, + timestamp, + sessionId: meta.sessionId, + userType: '', + ...event, + }, + origin, + }); + } else if (metadata && issues.size === 0) { + skippedMetaCount += 1; + } + if (issues.size > 0) { + malformedCount += 1; + logger?.warn('malformed codex jsonl line; recovered valid fields where possible', { + lineNo, + reasons: [...issues], + }); + } + } + } finally { + lines.close(); + stream.destroy(); + } + + skippedMetaCount += suppressFallbacks(candidates); + const events: RawEvent[] = []; + let parentUuid: string | null = null; + for (const candidate of candidates) { + if (candidate.suppressed) continue; + candidate.event.parentUuid = parentUuid; + events.push(candidate.event); + parentUuid = candidate.event.uuid; + } + return { meta, events, malformedCount, skippedMetaCount }; +} diff --git a/src/sources/claude.ts b/src/sources/claude.ts index 623b5b4..7c912b4 100644 --- a/src/sources/claude.ts +++ b/src/sources/claude.ts @@ -17,9 +17,12 @@ async function discover(opts: DiscoverOptions): Promise { const root = resolve(opts.root ?? getProjectsRoot()); const rootInfo = await lstatIfPresent(root); if (!rootInfo?.isDirectory()) return []; - const projectDirs = opts.projectCwd === undefined - ? (await readDirectory(root)).filter((entry) => entry.isDirectory()).map((entry) => entry.name) - : [encodeProjectPath(opts.projectCwd)]; + const projectDirs = + opts.projectCwd === undefined + ? (await readDirectory(root)) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + : [encodeProjectPath(opts.projectCwd)]; const sessions: SessionEntry[] = []; for (const projectDir of projectDirs) { if (!projectDir) continue; @@ -34,12 +37,20 @@ async function discover(opts: DiscoverOptions): Promise { const jsonlPath = join(projectPath, file.name); const info = await lstatIfPresent(jsonlPath); if (!info?.isFile()) continue; - projectSessions.push({ source: 'claude', sessionId, projectDir, jsonlPath, - mtimeMs: info.mtimeMs, sizeBytes: info.size }); + projectSessions.push({ + source: 'claude', + sessionId, + projectDir, + jsonlPath, + mtimeMs: info.mtimeMs, + sizeBytes: info.size, + }); + } + if (await isSameDirectory(projectPath, projectInfo)) { + for (const session of projectSessions) sessions.push(session); } - if (await isSameDirectory(projectPath, projectInfo)) sessions.push(...projectSessions); } - return await isSameDirectory(root, rootInfo) ? sessions : []; + return (await isSameDirectory(root, rootInfo)) ? sessions : []; } export const claudeSource: SessionSource = { diff --git a/src/sources/codex.ts b/src/sources/codex.ts new file mode 100644 index 0000000..703ca2a --- /dev/null +++ b/src/sources/codex.ts @@ -0,0 +1,143 @@ +import { constants } from 'node:fs'; +import { open } from 'node:fs/promises'; +import type { FileHandle } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { readCodex } from '../reader/codex.js'; +import { + isFullUuid, + isMissingPath, + isSameDirectory, + lstatIfPresent, + readDirectory, +} from './files.js'; +import type { DiscoverOptions, SessionEntry, SessionSource } from './types.js'; + +const ROLLOUT_NAME = + /^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}-\d{2})?-([0-9a-fA-F-]{36})\.jsonl$/; +const DATE_DIRECTORIES = [/^\d{4}$/, /^(?:0[1-9]|1[0-2])$/, /^(?:0[1-9]|[12]\d|3[01])$/]; +// Session metadata may contain sizeable base instructions. Discovery must not +// read the rest of a rollout, or allocate an unbounded first physical line. +const HEADER_LIMIT = 1024 * 1024; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +async function sessionEntry(jsonlPath: string, filenameId: string): Promise { + const original = await lstatIfPresent(jsonlPath); + if (!original?.isFile()) return null; + let handle: FileHandle | undefined; + try { + // NONBLOCK also prevents a regular file replaced with a FIFO from hanging. + handle = await open( + jsonlPath, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK, + ); + const info = await handle.stat(); + if (!info.isFile() || info.dev !== original.dev || info.ino !== original.ino) return null; + const chunks: Buffer[] = []; + let length = 0; + let finished = false; + while (length < HEADER_LIMIT) { + const buffer = Buffer.allocUnsafe(Math.min(16 * 1024, HEADER_LIMIT - length)); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, null); + if (bytesRead === 0) { + finished = true; + break; + } + const newline = buffer.subarray(0, bytesRead).indexOf(10); + const used = newline === -1 ? bytesRead : newline; + chunks.push(buffer.subarray(0, used)); + length += used; + if (newline !== -1) { + finished = true; + break; + } + } + if (!finished) return null; + let header: unknown; + try { + header = JSON.parse(Buffer.concat(chunks, length).toString('utf8').trim()); + } catch { + return null; + } + if (!isRecord(header) || header.type !== 'session_meta' || !isRecord(header.payload)) + return null; + const meta = header.payload; + const id = meta.id; + if (typeof id !== 'string' || !isFullUuid(id) || id.toLowerCase() !== filenameId.toLowerCase()) + return null; + if ( + meta.session_id !== undefined && + (typeof meta.session_id !== 'string' || !isFullUuid(meta.session_id)) + ) + return null; + if (typeof meta.cwd !== 'string' || !meta.cwd.trim()) return null; + if ( + meta.source === 'subagent' || + (isRecord(meta.source) && Object.hasOwn(meta.source, 'subagent')) + ) + return null; + const current = await lstatIfPresent(jsonlPath); + if (!current?.isFile() || current.dev !== info.dev || current.ino !== info.ino) return null; + return { + source: 'codex', + sessionId: id, + projectDir: meta.cwd, + jsonlPath, + mtimeMs: info.mtimeMs, + sizeBytes: info.size, + }; + } catch (error) { + if (isMissingPath(error) || (error as NodeJS.ErrnoException | null)?.code === 'ELOOP') + return null; + throw error; + } finally { + await handle?.close(); + } +} + +async function discover(options: DiscoverOptions): Promise { + const root = resolve( + options.root ?? resolve(process.env.CODEX_HOME || join(homedir(), '.codex'), 'sessions'), + ); + const projectCwd = options.projectCwd === undefined ? undefined : resolve(options.projectCwd); + + async function visit(directory: string, depth: number): Promise { + const original = await lstatIfPresent(directory); + if (!original?.isDirectory()) return []; + const sessions: SessionEntry[] = []; + for (const entry of await readDirectory(directory)) { + if (depth < DATE_DIRECTORIES.length) { + if (entry.isDirectory() && DATE_DIRECTORIES[depth].test(entry.name)) { + for (const session of await visit(join(directory, entry.name), depth + 1)) + sessions.push(session); + } + } else if (entry.isFile()) { + const match = ROLLOUT_NAME.exec(entry.name); + if (!match || !isFullUuid(match[1])) continue; + const session = await sessionEntry(join(directory, entry.name), match[1]); + if (session && (projectCwd === undefined || resolve(session.projectDir) === projectCwd)) { + sessions.push(session); + } + } + } + // Discard the entire subtree if any ancestor was replaced during the scan. + return (await isSameDirectory(directory, original)) ? sessions : []; + } + + return visit(root, 0); +} + +export const codexSource: SessionSource = { + id: 'codex', + discover, + accepts: (record) => + typeof record.type === 'string' && + ['session_meta', 'turn_context', 'response_item', 'event_msg', 'compacted'].includes( + record.type, + ) && + isRecord(record.payload), + read: readCodex, +}; diff --git a/src/sources/files.ts b/src/sources/files.ts index cbbf06a..3c275b7 100644 --- a/src/sources/files.ts +++ b/src/sources/files.ts @@ -36,6 +36,10 @@ export async function readDirectory(path: string): Promise { export async function isSameDirectory(path: string, original: Stats): Promise { const current = await lstatIfPresent(path); - return current !== null && current.isDirectory() && - current.dev === original.dev && current.ino === original.ino; + return ( + current !== null && + current.isDirectory() && + current.dev === original.dev && + current.ino === original.ino + ); } diff --git a/src/sources/index.ts b/src/sources/index.ts index b9658a7..852e296 100644 --- a/src/sources/index.ts +++ b/src/sources/index.ts @@ -1,8 +1,9 @@ import { open } from 'node:fs/promises'; import { claudeSource } from './claude.js'; +import { codexSource } from './codex.js'; import type { SessionSource, SessionSourceId } from './types.js'; -const sources: readonly SessionSource[] = [claudeSource]; +const sources: readonly SessionSource[] = [claudeSource, codexSource]; export function getSessionSource(id: SessionSourceId = 'claude'): SessionSource { const source = sources.find((candidate) => candidate.id === id); @@ -34,9 +35,15 @@ export async function detectSessionSource( for (const line of lines) { if (!line.trim()) continue; let record: unknown; - try { record = JSON.parse(line); } catch { continue; } + try { + record = JSON.parse(line.trim()); + } catch { + continue; + } if (!record || typeof record !== 'object' || Array.isArray(record)) continue; - const source = sources.find((candidate) => candidate.accepts(record as Record)); + const source = sources.find((candidate) => + candidate.accepts(record as Record), + ); if (!source) continue; if (selected && source.id !== selected) { throw new Error('Session file does not match the selected source.'); diff --git a/src/tree/builder.ts b/src/tree/builder.ts index 2ba8be5..3c76173 100644 --- a/src/tree/builder.ts +++ b/src/tree/builder.ts @@ -24,11 +24,13 @@ import type { TopicSegment, } from '../types.js'; import type { Redactor } from '../utils/redact.js'; +import type { SessionSourceId } from '../sources/types.js'; import { eventsForSegment } from '../analyzer/segments.js'; import { buildContinueSnapshot, buildForkSnapshot } from './context_snapshot.js'; export interface BuildMindMapOptions { + source?: SessionSourceId; jsonlPath: string; specVersion: string; // e.g. 'v0.3' generatedAt?: string; // ISO-8601; defaults to now @@ -224,6 +226,7 @@ export function buildMindMap( ).length; return { + source: opts.source ?? 'claude', session_id: sessionId, project_path: selectedEvents[0]?.cwd ?? '', generated_at: generatedAt, @@ -392,10 +395,13 @@ function deriveSegmentLabel(seg: TopicSegment, events: RawEvent[], redactor?: Re const topFile = seg.dominant_files[0]; const topTool = seg.dominant_tools[0]; const eventCount = seg.event_uuids.length; - if (topFile && topTool) return truncate(`${basename(topFile)} (${topTool})`, 60); - if (topFile) return truncate(basename(topFile), 60); - if (topTool) return truncate(topTool, 60); - return `${eventCount} events`; + const label = + topFile && topTool + ? `${basename(topFile)} (${topTool})` + : topFile + ? basename(topFile) + : (topTool ?? `${eventCount} events`); + return truncate(redactor ? redactor.apply(label) : label, 60); } /** @@ -448,7 +454,7 @@ function deriveSessionTitle( override?: string | null, redactor?: Redactor, ): string { - if (override) return truncate(override, 60); + if (override) return truncate(redactor ? redactor.apply(override) : override, 60); // Skip noise (Stop-hook injections, skill bootstrap, shell pastes) so the // root label reflects actual user intent. Falls back to the first user // message regardless if every turn is noise. diff --git a/src/types.ts b/src/types.ts index 0561a93..fa024c8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,6 +7,8 @@ * shapes rather than failing the whole run. */ +import type { SessionSourceId } from './sources/types.js'; + // --------------------------------------------------------------------------- // Raw ingestion // --------------------------------------------------------------------------- @@ -25,13 +27,7 @@ export interface EventEnvelope { } export type RawEventType = - | 'attachment' - | 'user' - | 'assistant' - | 'tool_use' - | 'tool_result' - | 'system' - | 'other'; + 'attachment' | 'user' | 'assistant' | 'tool_use' | 'tool_result' | 'system' | 'other'; /** * JSONL `type` values that are event-DAG records and therefore must carry a @@ -169,14 +165,7 @@ export interface TopicSegment { // Mindmap — future (M2+) // --------------------------------------------------------------------------- -export type NodeType = - | 'root' - | 'topic' - | 'action' - | 'decision' - | 'error' - | 'dead_end' - | 'turn'; +export type NodeType = 'root' | 'topic' | 'action' | 'decision' | 'error' | 'dead_end' | 'turn'; export interface ContextSnapshot { mode: 'continue' | 'fork'; @@ -224,6 +213,7 @@ export interface MindMapNode { } export interface MindMap { + source: SessionSourceId; session_id: string; project_path: string; generated_at: string; diff --git a/src/utils/picker.ts b/src/utils/picker.ts index 24c0d5e..98e06bd 100644 --- a/src/utils/picker.ts +++ b/src/utils/picker.ts @@ -9,11 +9,13 @@ import { createInterface } from 'node:readline/promises'; import { listSessions, type SessionMatch, type SessionSelection } from './session_path.js'; +import { defaultRedactor, type Redactor } from './redact.js'; export interface PickOptions extends SessionSelection { limit?: number; // default 10 input?: NodeJS.ReadableStream; output?: NodeJS.WritableStream; + redactor?: Redactor; } export async function pickSession(opts: PickOptions = {}): Promise { @@ -26,11 +28,14 @@ export async function pickSession(opts: PickOptions = {}): Promise { const when = new Date(c.mtimeMs).toISOString().replace('T', ' ').slice(0, 16); out.write( - ` ${String(i + 1).padStart(2, ' ')}. ${c.sessionId.slice(0, 8)} ${when} ${c.projectDir}\n`, + redactor.apply( + ` ${String(i + 1).padStart(2, ' ')}. ${c.sessionId.slice(0, 8)} ${when} ${c.projectDir}\n`, + ), ); }); diff --git a/src/utils/picks.ts b/src/utils/picks.ts index c15f77a..e05233a 100644 --- a/src/utils/picks.ts +++ b/src/utils/picks.ts @@ -1,9 +1,10 @@ /** * Pick history — track which nodes the user has already snapshotted. * - * Recorded at `~/.cache/agent-tree/picks/.jsonl` (session-scoped, - * not config-scoped, so re-running with different `--no-llm` / sidechain - * options still surfaces past picks). Append-only JSONL — each line is one + * Recorded at `~/.cache/agent-tree/picks//.jsonl` + * (source- and session-scoped, not config-scoped, so re-running with different + * `--no-llm` / sidechain options still surfaces past picks). Sources are + * `claude` (the default) and `codex`. Append-only JSONL — each line is one * pick `{node_id, mode, ts}`. * * The list / TUI renderers read this map to mark visited nodes with ⭐ @@ -17,17 +18,20 @@ * is the canonical "append-only log" pattern. * - `removePicksForNode` writes via tmp-file + atomic `rename` to avoid * losing concurrent `recordPick` writes between read and rewrite. - * - Path-traversal hardening: `picksFileFor` rejects any sessionId not - * matching `/^[0-9a-f-]{4,40}$/i` — defense in depth on top of the - * `locateSession` validator that already gates the same regex upstream. + * - Path-traversal hardening: source names are validated at runtime, and + * `picksFileFor` rejects any sessionId not matching `/^[0-9a-f-]{4,40}$/i` + * — defense in depth on top of the `locateSession` validator. */ import { randomBytes } from 'node:crypto'; -import { appendFile, mkdir, readFile } from 'node:fs/promises'; +import type { Dirent } from 'node:fs'; +import { appendFile, lstat, mkdir, readFile, readdir } from 'node:fs/promises'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; +import type { SessionSourceId } from '../sources/types.js'; const PICKS_ROOT = join(homedir(), '.cache', 'agent-tree', 'picks'); +const PICK_SOURCES: readonly SessionSourceId[] = ['claude', 'codex']; export interface Pick { node_id: string; @@ -47,68 +51,91 @@ export interface PickIndex { // untrusted input (path-traversal hardening per security audit MEDIUM-5). const SESSION_ID_RE = /^[0-9a-f-]{4,40}$/i; -function picksFileFor(sessionId: string, root = PICKS_ROOT): string { - if (!SESSION_ID_RE.test(sessionId)) { +function validateSource(source: SessionSourceId): void { + if (source !== 'claude' && source !== 'codex') { + throw new Error(`refusing to compose picks path for invalid source "${source}"`); + } +} + +function picksFileFor( + sessionId: string, + root = PICKS_ROOT, + source: SessionSourceId = 'claude', +): string { + validateSource(source); + if (typeof sessionId !== 'string' || !SESSION_ID_RE.test(sessionId)) { throw new Error(`refusing to compose picks path for invalid sessionId "${sessionId}"`); } - return join(root, `${sessionId}.jsonl`); + return join(root, source, `${sessionId}.jsonl`); } export async function recordPick( sessionId: string, nodeId: string, mode: 'continue' | 'fork', - opts: { root?: string } = {}, + opts: { root?: string; source?: SessionSourceId } = {}, ): Promise { - const file = picksFileFor(sessionId, opts.root); + const file = picksFileFor(sessionId, opts.root, opts.source); await mkdir(dirname(file), { recursive: true, mode: 0o700 }); const entry: Pick = { node_id: nodeId, mode, ts: new Date().toISOString() }; await appendFile(file, JSON.stringify(entry) + '\n', 'utf8'); } export interface SessionPicks { + source: SessionSourceId; sessionId: string; picks: Pick[]; } /** - * List every recorded pick across every session, newest first. Used by - * `agent-tree --picks` so users can review their navigation history. + * List recorded picks across both sources (or one selected source), newest + * session activity first. Only regular files in real source directories are + * read. Used by `agent-tree --picks` to review navigation history. */ export async function listAllPicks( - opts: { root?: string } = {}, + opts: { root?: string; source?: SessionSourceId } = {}, ): Promise { + if (opts.source !== undefined) validateSource(opts.source); + const sources = opts.source === undefined ? PICK_SOURCES : [opts.source]; const root = opts.root ?? PICKS_ROOT; - const { readdir } = await import('node:fs/promises'); - let files: string[]; try { - files = await readdir(root); + if (!(await lstat(root)).isDirectory()) return []; } catch { return []; } const out: SessionPicks[] = []; - for (const fname of files) { - if (!fname.endsWith('.jsonl')) continue; - const sessionId = fname.slice(0, -'.jsonl'.length); - const filePath = join(root, fname); - let raw: string; + for (const source of sources) { + const directory = join(root, source); + let files: Dirent[]; try { - raw = await readFile(filePath, 'utf8'); + if (!(await lstat(directory)).isDirectory()) continue; + files = await readdir(directory, { withFileTypes: true }); } catch { continue; } - const picks: Pick[] = []; - for (const line of raw.split('\n')) { - const trimmed = line.trim(); - if (!trimmed) continue; + for (const file of files) { + if (!file.isFile() || !file.name.endsWith('.jsonl')) continue; + const sessionId = file.name.slice(0, -'.jsonl'.length); + if (!SESSION_ID_RE.test(sessionId)) continue; + let raw: string; try { - const parsed = JSON.parse(trimmed) as Pick; - if (parsed.node_id) picks.push(parsed); + raw = await readFile(join(directory, file.name), 'utf8'); } catch { - // ignore + continue; + } + const picks: Pick[] = []; + for (const line of raw.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const parsed = JSON.parse(trimmed) as Pick; + if (parsed.node_id) picks.push(parsed); + } catch { + // ignore + } } + if (picks.length > 0) out.push({ source, sessionId, picks }); } - if (picks.length > 0) out.push({ sessionId, picks }); } // Newest session activity first (compare last pick timestamp) out.sort((a, b) => { @@ -120,15 +147,15 @@ export async function listAllPicks( } /** - * Remove every pick entry for a given (session, node_id) pair. Returns the - * number of entries removed. Atomic-ish via rewrite-then-rename. + * Remove every pick entry for a given (source, session, node_id) tuple. + * Returns the number of entries removed. Atomic-ish via rewrite-then-rename. */ export async function removePicksForNode( sessionId: string, nodeId: string, - opts: { root?: string } = {}, + opts: { root?: string; source?: SessionSourceId } = {}, ): Promise { - const file = picksFileFor(sessionId, opts.root); + const file = picksFileFor(sessionId, opts.root, opts.source); let raw: string; try { raw = await readFile(file, 'utf8'); @@ -164,20 +191,19 @@ export async function removePicksForNode( const { writeFile, rename } = await import('node:fs/promises'); const rand = randomBytes(4).toString('hex'); const tmp = `${file}.tmp-${process.pid}-${Date.now()}-${rand}`; - await writeFile( - tmp, - kept.length > 0 ? kept.join('\n') + '\n' : '', - { encoding: 'utf8', mode: 0o600 }, - ); + await writeFile(tmp, kept.length > 0 ? kept.join('\n') + '\n' : '', { + encoding: 'utf8', + mode: 0o600, + }); await rename(tmp, file); return removed; } export async function readPicks( sessionId: string, - opts: { root?: string } = {}, + opts: { root?: string; source?: SessionSourceId } = {}, ): Promise { - const file = picksFileFor(sessionId, opts.root); + const file = picksFileFor(sessionId, opts.root, opts.source); const index: PickIndex = { modesByNode: new Map(), total: 0 }; let raw: string; try { diff --git a/src/utils/session_path.ts b/src/utils/session_path.ts index c2efe5c..9bd85f6 100644 --- a/src/utils/session_path.ts +++ b/src/utils/session_path.ts @@ -3,7 +3,12 @@ import { lstat, realpath } from 'node:fs/promises'; import { basename, dirname, extname, resolve } from 'node:path'; import { detectSessionSource, getSessionSource } from '../sources/index.js'; import { isFullUuid, isUuidPrefix } from '../sources/files.js'; -import type { DiscoverOptions, SessionEntry, SessionMatch, SessionSourceId } from '../sources/types.js'; +import type { + DiscoverOptions, + SessionEntry, + SessionMatch, + SessionSourceId, +} from '../sources/types.js'; export type { SessionEntry, SessionMatch } from '../sources/types.js'; @@ -23,7 +28,10 @@ export async function listSessions( return opts.limit === undefined ? sessions : sessions.slice(0, opts.limit); } -export async function sessionFromFile(filePath: string, source?: SessionSourceId): Promise { +export async function sessionFromFile( + filePath: string, + source?: SessionSourceId, +): Promise { const jsonlPath = await realpath(resolve(filePath)); if (extname(jsonlPath) !== '.jsonl') throw new Error('session file must have a .jsonl extension'); const info = await lstat(jsonlPath); @@ -71,9 +79,16 @@ export async function findLatestSessionInProject( } function compareSessions(a: SessionEntry, b: SessionEntry): number { - return b.mtimeMs - a.mtimeMs || (a.jsonlPath < b.jsonlPath ? -1 : a.jsonlPath > b.jsonlPath ? 1 : 0); + return ( + b.mtimeMs - a.mtimeMs || (a.jsonlPath < b.jsonlPath ? -1 : a.jsonlPath > b.jsonlPath ? 1 : 0) + ); } function toSessionMatch(entry: SessionEntry): SessionMatch { - return { source: entry.source, sessionId: entry.sessionId, projectDir: entry.projectDir, jsonlPath: entry.jsonlPath }; + return { + source: entry.source, + sessionId: entry.sessionId, + projectDir: entry.projectDir, + jsonlPath: entry.jsonlPath, + }; } diff --git a/tests/cli.test.ts b/tests/cli.test.ts index ad2a4a8..848f475 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -1,5 +1,5 @@ import { execFile } from 'node:child_process'; -import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, mkdir, readFile, readdir, rm, utimes, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { promisify } from 'node:util'; @@ -9,7 +9,9 @@ import { encodeProjectPath } from '../src/sources/claude.js'; const exec = promisify(execFile); const entry = resolve('src/cli.ts'); const fixture = resolve('tests/fixtures/minimal-session.jsonl'); +const codexFixture = resolve('tests/fixtures/codex-session.jsonl'); const id = 'aaaa1111-2222-3333-4444-555566667777'; +const otherId = 'bbbb1111-2222-3333-4444-555566667777'; let root: string; let home: string; let project: string; @@ -32,6 +34,7 @@ async function cli(...args: string[]) { HOME: home, USERPROFILE: home, CLAUDE_CONFIG_DIR: configDir, + CODEX_HOME: join(root, 'codex'), ANTHROPIC_API_KEY: '', AGENT_TREE_NO_LLM: 'true', }; @@ -52,10 +55,29 @@ async function cli(...args: string[]) { }); } +async function rollout(sessionId = id, cwd = project, modified = 1_700_000_000) { + const dir = join(root, 'codex', 'sessions', '2026', '01', '02'); + await mkdir(dir, { recursive: true }); + const file = join(dir, `rollout-2026-01-02T03-04-05-${sessionId}.jsonl`); + const records = (await readFile(codexFixture, 'utf8')) + .trim() + .split('\n') + .map((line) => { + const record = JSON.parse(line); + if (record.type === 'session_meta') record.payload.id = sessionId; + if (record.payload.cwd) record.payload.cwd = cwd; + return JSON.stringify(record); + }); + await writeFile(file, records.join('\n') + '\n'); + await utimes(file, modified, modified); + return file; +} + describe('portable CLI workflows', () => { it('exports parseable redacted JSON without banners or progress on stdout', async () => { const { stdout } = await cli('--file', fixture, '--json', '--no-llm'); const map = JSON.parse(stdout); + expect(map.source).toBe('claude'); expect(map.session_id).toBe(id); expect(map.stats.total_events).toBeGreaterThan(0); expect(map.root.children.length).toBeGreaterThan(0); @@ -102,12 +124,13 @@ describe('portable CLI workflows', () => { const snapshot = await cli('--file', fixture, '--snapshot', '1', '--mode', 'fork'); expect(snapshot.stdout).toContain('Fork'); const history = await readFile( - join(home, '.cache', 'agent-tree', 'picks', `${id}.jsonl`), + join(home, '.cache', 'agent-tree', 'picks', 'claude', `${id}.jsonl`), 'utf8', ); expect(JSON.parse(history.trim())).toMatchObject({ node_id: 'n_001', mode: 'fork' }); const list = await cli('--file', fixture, '--list'); expect(list.stdout).toContain('--file'); + expect(list.stdout).toContain('--source claude'); expect(list.stdout).toContain('starred'); }); @@ -178,3 +201,228 @@ describe('portable CLI workflows', () => { expect(stderr).not.toContain('sk-ant-dummy123'); }); }); + +describe('Codex CLI workflows', () => { + it('auto-detects imported JSON and preserves metadata identity, tools and deduplicated turns', async () => { + const { stdout } = await cli('--file', codexFixture, '--json', '--strict', '--no-llm'); + const map = JSON.parse(stdout); + expect(map).toMatchObject({ + source: 'codex', + session_id: id, + project_path: '/synthetic/project', + stats: { total_events: 10, total_turns: 4, total_tool_calls: 2 }, + }); + expect(map.root.files_touched).toContain('src/app.ts'); + expect(map.root.tools_used).toEqual(expect.arrayContaining(['read_file', 'apply_patch'])); + expect(map.root.children.length).toBeGreaterThan(0); + expect(stdout).not.toContain('Pick a node'); + expect(stdout).not.toContain('dddd1111-2222-3333-4444-555566667777'); + expect(stdout).not.toContain('SYNTHETIC_PRIVATE_INSTRUCTIONS'); + expect(stdout).not.toContain('SYNTHETIC_PRIVATE_REASONING'); + const explicit = JSON.parse( + (await cli('--file', codexFixture, '--source', 'codex', '--json')).stdout, + ); + expect(explicit).toMatchObject({ source: 'codex', session_id: id, stats: map.stats }); + }); + + it('discovers date-layout rollouts through CODEX_HOME and filters by project', async () => { + const path = await rollout(); + await rollout(otherId, join(root, 'other-project'), 1_700_000_100); + const filtered = await cli( + '--source', + 'codex', + '--sessions', + '--cwd', + project, + '--limit', + '1', + '--json', + ); + expect(JSON.parse(filtered.stdout).sessions).toEqual([ + expect.objectContaining({ + source: 'codex', + sessionId: id, + projectDir: project, + jsonlPath: path, + }), + ]); + const all = JSON.parse((await cli('--source', 'codex', '--sessions', '--json')).stdout); + expect(all.sessions.map((session: { sessionId: string }) => session.sessionId)).toEqual([ + otherId, + id, + ]); + expect(all.sessions[0].sizeBytes).toBeGreaterThan(0); + expect(Number.isFinite(all.sessions[0].mtimeMs)).toBe(true); + expect(JSON.parse((await cli('--sessions', '--json')).stdout).sessions).toEqual([]); + }); + + it('selects Codex by UUID prefix, project default and global latest without crossing sources', async () => { + await rollout(); + await rollout(otherId, join(root, 'other-project'), 1_700_000_100); + const dir = join(configDir, 'projects', encodeProjectPath(project)); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, `${id}.jsonl`), await readFile(fixture)); + for (const selector of [id, id.slice(0, 8)]) { + const map = JSON.parse((await cli('--source', 'codex', selector, '--json')).stdout); + expect(map).toMatchObject({ source: 'codex', session_id: id }); + } + const local = JSON.parse((await cli('--source', 'codex', '--cwd', project, '--json')).stdout); + expect(local).toMatchObject({ source: 'codex', session_id: id }); + const latest = JSON.parse((await cli('--source', 'codex', '--latest', '--json')).stdout); + expect(latest).toMatchObject({ source: 'codex', session_id: otherId }); + expect(JSON.parse((await cli(id, '--json')).stdout)).toMatchObject({ + source: 'claude', + session_id: id, + }); + const list = await cli('--source', 'codex', id, '--list'); + expect(list.stdout).toContain(`agent-tree --source codex ${id.slice(0, 8)} --snapshot`); + }, 30_000); + + it.each([ + [codexFixture, 'claude'], + [fixture, 'codex'], + ])('rejects a mismatched source for %s', async (file, source) => { + await expect(cli('--file', file, '--source', source, '--json')).rejects.toMatchObject({ + code: 1, + stdout: '', + stderr: expect.stringContaining('does not match the selected source'), + }); + }); + + it.each([ + '{"private":"SYNTHETIC_MALFORMED_SECRET"', + JSON.stringify({ + timestamp: '2026-01-02T03:07:00Z', + type: 'response_item', + payload: { + type: 'function_call', + name: 'read_file', + call_id: 'bad', + arguments: 'SYNTHETIC_MALFORMED_SECRET', + }, + }), + ])( + 'strictly rejects malformed Codex records without echoing source contents: %s', + async (broken) => { + const file = join(project, 'broken-codex.jsonl'); + await writeFile(file, (await readFile(codexFixture, 'utf8')) + broken + '\n'); + await expect(cli('--file', file, '--json', '--strict')).rejects.toMatchObject({ + code: 1, + stdout: '', + stderr: expect.stringContaining('codex jsonl error at line'), + }); + try { + await cli('--file', file, '--json', '--strict'); + } catch (error) { + expect((error as { stderr: string }).stderr).not.toContain('SYNTHETIC_MALFORMED_SECRET'); + expect((error as { stderr: string }).stderr).not.toContain('owner@example.com'); + } + const recovered = await cli('--file', file, '--json'); + expect(JSON.parse(recovered.stdout)).toMatchObject({ + source: 'codex', + stats: { total_turns: 4 }, + }); + expect(recovered.stderr).not.toContain('SYNTHETIC_MALFORMED_SECRET'); + }, + ); + + it('redacts Codex exports and emits metadata-only safe catalogs without reading malformed bodies', async () => { + await writeFile( + join(project, '.agent-tree.yaml'), + 'redaction:\n strict: true\n extra_patterns: ["SYNTHETIC_PRIVATE_WORD"]\n', + ); + const exported = await cli('--file', codexFixture, '--cwd', project, '--json'); + expect(exported.stdout).not.toContain('SYNTHETIC_PRIVATE_WORD'); + expect(exported.stdout).not.toContain('owner@example.com'); + expect(exported.stdout).toContain('[EMAIL]'); + const file = await rollout(id, join(project, 'SYNTHETIC_PRIVATE_WORD-owner@example.com')); + const header = (await readFile(file, 'utf8')).split('\n')[0]; + await writeFile(file, header + '\n{"private":"SYNTHETIC_BODY_SECRET"\n'); + const userConfig = join(home, '.config', 'agent-tree'); + await mkdir(userConfig, { recursive: true }); + await writeFile( + join(userConfig, 'config.yaml'), + 'redaction:\n strict: true\n extra_patterns: ["SYNTHETIC_PRIVATE_WORD"]\n', + ); + for (const format of [['--json'], []]) { + const catalog = await cli('--source', 'codex', '--sessions', '--redact-strict', ...format); + expect(catalog.stdout).toContain('aaaa1111-[PHONE]-555566667777'); + expect(catalog.stdout).toContain('codex'); + expect(catalog.stdout).not.toContain('owner@example.com'); + expect(catalog.stdout).not.toContain('SYNTHETIC_PRIVATE_WORD'); + expect(catalog.stdout).not.toContain('SYNTHETIC_BODY_SECRET'); + expect(catalog.stdout).not.toContain('SYNTHETIC_PRIVATE_INSTRUCTIONS'); + expect(catalog.stderr).not.toContain('SYNTHETIC_BODY_SECRET'); + if (format.length) { + expect(Object.keys(JSON.parse(catalog.stdout).sessions[0]).sort()).toEqual( + ['source', 'sessionId', 'projectDir', 'jsonlPath', 'mtimeMs', 'sizeBytes'].sort(), + ); + } + } + }); + + it('records detected and selected Codex snapshots without starring the same Claude UUID', async () => { + for (const mode of ['continue', 'fork']) { + const selection = mode === 'fork' ? ['--source', 'codex'] : []; + const snapshot = await cli( + '--file', + codexFixture, + ...selection, + '--snapshot', + '1', + '--mode', + mode, + ); + expect(snapshot.stdout).toContain(mode === 'fork' ? 'Forking at:' : 'Continuing from:'); + expect(snapshot.stdout).toContain(id); + } + const history = await readFile( + join(home, '.cache', 'agent-tree', 'picks', 'codex', `${id}.jsonl`), + 'utf8', + ); + expect( + history + .trim() + .split('\n') + .map((line) => JSON.parse(line)), + ).toEqual([ + expect.objectContaining({ node_id: 'n_001', mode: 'continue' }), + expect.objectContaining({ node_id: 'n_001', mode: 'fork' }), + ]); + const codexList = await cli('--file', codexFixture, '--list'); + expect(codexList.stdout).toContain('1 node starred, 2 total picks'); + expect(codexList.stdout).toContain('--source codex --file'); + expect((await cli('--file', fixture, '--list')).stdout).not.toContain('starred'); + await expect( + readFile(join(home, '.cache', 'agent-tree', 'picks', 'claude', `${id}.jsonl`)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }, 30_000); + + it('filters picks by source and unstars only the detected source', async () => { + await cli('--file', fixture, '--snapshot', '1'); + await cli('--file', codexFixture, '--snapshot', '1'); + const all = (await cli('--picks')).stdout; + expect(all).toContain(`claude session ${id.slice(0, 8)}`); + expect(all).toContain(`codex session ${id.slice(0, 8)}`); + for (const source of ['claude', 'codex']) { + const picks = (await cli('--picks', '--source', source)).stdout; + expect(picks).toContain(`${source} session ${id.slice(0, 8)}`); + expect(picks).not.toContain(`${source === 'claude' ? 'codex' : 'claude'} session`); + } + expect((await cli('--file', codexFixture, '--unstar', '1')).stderr).toContain( + 'Unstarred n_001', + ); + expect((await cli('--file', codexFixture, '--list')).stdout).not.toContain('starred'); + expect((await cli('--file', fixture, '--list')).stdout).toContain('starred'); + expect((await cli('--picks', '--source', 'codex')).stderr).toContain('No picks'); + }, 30_000); + + it.each(['invalid', 'CODEX', '../codex', ''])( + 'rejects invalid --source %j with exit 2', + async (source) => { + await expect( + cli('--source', source, '--file', join(root, 'missing.jsonl'), '--json'), + ).rejects.toMatchObject({ code: 2 }); + }, + ); +}); diff --git a/tests/codex.test.ts b/tests/codex.test.ts new file mode 100644 index 0000000..09c8ac6 --- /dev/null +++ b/tests/codex.test.ts @@ -0,0 +1,1219 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + appendFileSync, + constants, + mkdirSync, + mkdtempSync, + realpathSync, + renameSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import * as fs from 'node:fs/promises'; +import { homedir, tmpdir } from 'node:os'; +import { basename, join, relative } from 'node:path'; +import { extractSignals } from '../src/analyzer/signals.js'; +import { readCodex } from '../src/reader/codex.js'; +import { buildGraph } from '../src/reader/graph.js'; +import { codexSource } from '../src/sources/codex.js'; +import type { Logger } from '../src/utils/logger.js'; + +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), +})); + +const ID_A = 'aaaaaaaa-0000-4000-8000-000000000001'; +const ID_B = 'BBBBBBBB-0000-4000-8000-000000000002'; +const ID_C = 'cccccccc-0000-4000-8000-000000000003'; +const TIMESTAMP = '2025-01-02T03:04:05.000Z'; +let temporary: string; +let root: string; + +beforeEach(() => { + temporary = realpathSync(mkdtempSync(join(tmpdir(), 'atree-codex-'))); + root = join(temporary, 'sessions'); + mkdirSync(root); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + rmSync(temporary, { recursive: true, force: true }); +}); + +function record(type: string, payload: unknown, extra: Record = {}) { + return { timestamp: TIMESTAMP, type, payload, ...extra }; +} + +function metadata(payload: Record = {}) { + return record('session_meta', { + id: ID_A, + cwd: '/work/project', + cli_version: '0.160.0', + source: 'cli', + ...payload, + }); +} + +function message(role: 'user' | 'assistant', text: string) { + return record('response_item', { + type: 'message', + role, + content: [{ type: role === 'user' ? 'input_text' : 'output_text', text }], + }); +} + +function fallback(role: 'user' | 'assistant', text: string) { + return record('event_msg', { + type: role === 'user' ? 'user_message' : 'agent_message', + message: text, + }); +} + +function transcript(lines: unknown[]): string { + const path = join(temporary, 'import.jsonl'); + writeFileSync( + path, + lines.map((line) => (typeof line === 'string' ? line : JSON.stringify(line))).join('\n') + '\n', + ); + return path; +} + +function rollout( + id = ID_A, + payload: Record = {}, + parts = ['2025', '01', '02'], + body?: string, +): string { + const directory = join(root, ...parts); + mkdirSync(directory, { recursive: true }); + const path = join(directory, `rollout-2025-01-02T03-04-05-${id}.jsonl`); + writeFileSync(path, body ?? JSON.stringify(metadata({ id, ...payload })) + '\n'); + return path; +} + +function ioError(code: string): NodeJS.ErrnoException { + return Object.assign(new Error('synthetic filesystem failure'), { code }); +} + +function loggerSpy(): Logger { + return { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + level: 'warn', + }; +} + +describe('Codex discovery', () => { + it('reads bounded metadata and returns source, cwd, identity and file stats without sorting', async () => { + const path = rollout(ID_A, { cwd: '/work/project/../project' }); + appendFileSync(path, 'a malformed later record is irrelevant to discovery\n'); + const open = vi.spyOn(fs, 'open'); + expect(await codexSource.discover({ root })).toEqual([ + { + source: 'codex', + sessionId: ID_A, + projectDir: '/work/project/../project', + jsonlPath: path, + mtimeMs: statSync(path).mtimeMs, + sizeBytes: statSync(path).size, + }, + ]); + expect(open).toHaveBeenCalledWith( + path, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK, + ); + }); + + it('uses CODEX_HOME dynamically and gives an explicit root priority', async () => { + const path = rollout(); + vi.stubEnv('CODEX_HOME', temporary); + expect((await codexSource.discover({})).map((entry) => entry.jsonlPath)).toEqual([path]); + vi.stubEnv('CODEX_HOME', join(temporary, 'other-home')); + expect(await codexSource.discover({})).toEqual([]); + expect((await codexSource.discover({ root })).map((entry) => entry.jsonlPath)).toEqual([path]); + }); + + it.each([undefined, ''])( + 'uses the home sessions directory when CODEX_HOME is %s without reading private logs', + async (value) => { + vi.stubEnv('CODEX_HOME', value); + const lstat = vi.spyOn(fs, 'lstat').mockRejectedValue(ioError('ENOENT')); + expect(await codexSource.discover({})).toEqual([]); + expect(lstat).toHaveBeenCalledWith(join(homedir(), '.codex', 'sessions')); + }, + ); + + it('filters by resolved cwd rather than directory encoding', async () => { + const cwd = join(temporary, 'project'); + const path = rollout(ID_A, { cwd: join(cwd, 'nested', '..') }); + rollout(ID_B, { cwd: `${cwd}-other` }); + expect( + (await codexSource.discover({ root, projectCwd: relative(process.cwd(), cwd) })).map( + (entry) => entry.jsonlPath, + ), + ).toEqual([path]); + expect(await codexSource.discover({ root, projectCwd: '/missing/project' })).toEqual([]); + }); + + it('traverses exactly regular YYYY/MM/DD directories and rollout timestamp/UUID filenames', async () => { + const accepted = rollout(); + for (const parts of [ + [], + ['2025'], + ['2025', '01'], + ['2025', '01', '02', 'nested'], + ['notes', '01', '02'], + ['2025', '1', '02'], + ['2025', '13', '02'], + ['2025', '01', '00'], + ]) { + rollout(ID_B, {}, parts); + } + const directory = join(root, '2025', '01', '02'); + for (const name of [ + 'notes.jsonl', + `${ID_B}.jsonl`, + `rollout-invalid-${ID_B}.jsonl`, + 'rollout-2025-01-02T03-04-05-not-a-uuid.jsonl', + `rollout-2025-01-02T03-04-05-${ID_C}.JSONL`, + ]) { + writeFileSync(join(directory, name), JSON.stringify(metadata({ id: ID_B }))); + } + mkdirSync(join(directory, `rollout-2025-01-02T03-04-05-${ID_B}.jsonl`)); + expect((await codexSource.discover({ root })).map((entry) => entry.jsonlPath)).toEqual([ + accepted, + ]); + }); + + it.each([ + '', + '{"type":"session_meta","payload":', + '{}', + '[]', + JSON.stringify(record('response_item', { type: 'message', role: 'user', content: [] })), + JSON.stringify(record('session_meta', { cwd: '/work' })), + JSON.stringify(record('session_meta', { session_id: ID_A, cwd: '/work' })), + JSON.stringify(metadata({ id: ID_B })), + JSON.stringify(metadata({ session_id: 'not-a-uuid' })), + JSON.stringify(metadata({ session_id: null })), + JSON.stringify(metadata({ cwd: 9 })), + JSON.stringify(metadata({ cwd: '' })), + JSON.stringify(metadata({ base_instructions: 'x'.repeat(1024 * 1024) })), + ])('skips malformed, mismatched or oversized first metadata (case %#)', async (body) => { + rollout(ID_A, {}, undefined, body); + expect(await codexSource.discover({ root })).toEqual([]); + }); + + it('uses the thread id independently of the root session_id and matches filenames case-insensitively', async () => { + const path = rollout( + ID_A, + {}, + undefined, + JSON.stringify( + record('session_meta', { + id: ID_A.toUpperCase(), + session_id: ID_B, + cwd: '/work/project', + }), + ), + ); + expect(await codexSource.discover({ root })).toMatchObject([ + { sessionId: ID_A.toUpperCase(), jsonlPath: path }, + ]); + }); + + it('excludes subagents from discovery but allows explicit reading', async () => { + const path = rollout(ID_A, { + source: { subagent: { thread_spawn: { parent_thread_id: ID_B } } }, + }); + appendFileSync(path, JSON.stringify(message('user', 'Synthetic child task')) + '\n'); + rollout(ID_C, { source: 'subagent' }); + expect(await codexSource.discover({ root })).toEqual([]); + expect((await codexSource.read(path)).events).toMatchObject([ + { type: 'user', isSidechain: true }, + ]); + }); + + it('skips symlink roots, date directories and rollout files', async () => { + const accepted = rollout(); + const outside = join(temporary, 'outside'); + mkdirSync(outside); + const external = join(outside, `rollout-2025-01-02T03-04-05-${ID_B}.jsonl`); + writeFileSync(external, JSON.stringify(metadata({ id: ID_B }))); + symlinkSync(outside, join(root, '2024'), 'dir'); + symlinkSync(outside, join(root, '2025', '02'), 'dir'); + symlinkSync(outside, join(root, '2025', '01', '03'), 'dir'); + symlinkSync(external, join(root, '2025', '01', '02', basename(external))); + symlinkSync(root, join(temporary, 'root-link'), 'dir'); + expect((await codexSource.discover({ root })).map((entry) => entry.jsonlPath)).toEqual([ + accepted, + ]); + expect(await codexSource.discover({ root: join(temporary, 'root-link') })).toEqual([]); + }); + + it('does not follow a file replaced by a symlink between lstat and open', async () => { + const path = rollout(); + const moved = join(temporary, 'moved.jsonl'); + const original = fs.open; + vi.spyOn(fs, 'open').mockImplementationOnce(async (...args) => { + renameSync(path, moved); + symlinkSync(moved, path); + return original(...args); + }); + expect(await codexSource.discover({ root })).toEqual([]); + }); + + it.each([0, 1, 2, 3])( + 'discards directory replacement at depth %i even if the file inode survives', + async (depth) => { + const path = rollout(); + const components = ['2025', '01', '02', basename(path)]; + const replaced = join(root, ...components.slice(0, depth)); + const moved = join(temporary, 'moved-directory'); + const original = fs.open; + vi.spyOn(fs, 'open').mockImplementationOnce(async (...args) => { + const handle = await original(...args); + renameSync(replaced, moved); + mkdirSync(replaced); + renameSync(join(moved, components[depth]), join(replaced, components[depth])); + return handle; + }); + expect(await codexSource.discover({ root })).toEqual([]); + }, + ); + + it.each(['ENOENT', 'ENOTDIR', 'ELOOP'])( + 'skips files lost during safe open (%s)', + async (code) => { + rollout(); + vi.spyOn(fs, 'open').mockRejectedValue(ioError(code)); + expect(await codexSource.discover({ root })).toEqual([]); + }, + ); + + it('surfaces unexpected directory and file IO errors', async () => { + rollout(); + const directoryError = vi.spyOn(fs, 'readdir').mockRejectedValue(ioError('EACCES')); + await expect(codexSource.discover({ root })).rejects.toMatchObject({ code: 'EACCES' }); + directoryError.mockRestore(); + vi.spyOn(fs, 'open').mockRejectedValue(ioError('EIO')); + await expect(codexSource.discover({ root })).rejects.toMatchObject({ code: 'EIO' }); + }); + + it('returns no entries for absent and nondirectory roots', async () => { + const file = join(temporary, 'file'); + writeFileSync(file, 'not a directory'); + for (const path of [file, join(file, 'sessions'), join(temporary, 'missing')]) { + expect(await codexSource.discover({ root: path })).toEqual([]); + } + }); + + it('identifies Codex envelopes positively without claiming Claude or unknown JSON', () => { + for (const type of [ + 'session_meta', + 'turn_context', + 'response_item', + 'event_msg', + 'compacted', + ]) { + expect(codexSource.accepts(record(type, {}))).toBe(true); + } + for (const value of [ + {}, + { type: 'user', uuid: 'u' }, + { type: 'response_item', payload: [] }, + { type: { toString: null }, payload: {} }, + ]) { + expect(codexSource.accepts(value)).toBe(false); + } + }); +}); + +describe('Codex normalization', () => { + it('preserves metadata and cwd changes, skips instructions, and does not invent source defaults', async () => { + const path = transcript([ + metadata({ + permissionMode: 'plan', + git: { branch: 'feature' }, + base_instructions: { text: 'PRIVATE_BASE_INSTRUCTIONS' }, + }), + record('response_item', { + type: 'message', + role: 'developer', + content: [{ type: 'input_text', text: 'PRIVATE_DEVELOPER_INSTRUCTIONS' }], + }), + record('response_item', { + type: 'message', + role: 'system', + content: [{ type: 'input_text', text: 'PRIVATE_SYSTEM_INSTRUCTIONS' }], + }), + message('user', 'Read the source'), + record('turn_context', { + cwd: '/work/second', + approval_policy: 'on-request', + turn_id: 'synthetic-turn', + }), + message('assistant', 'Read complete'), + ]); + const result = await readCodex(path, { strict: true }); + expect(result.meta).toEqual({ sessionId: ID_A, permissionMode: 'on-request' }); + expect(result.events).toMatchObject([ + { + type: 'user', + cwd: '/work/project', + gitBranch: 'feature', + version: '0.160.0', + entrypoint: 'cli', + userType: '', + }, + { type: 'assistant', cwd: '/work/second', sessionId: ID_A }, + ]); + expect(JSON.stringify(result)).not.toContain('PRIVATE_'); + const minimal = await readCodex( + transcript([record('session_meta', { id: ID_B, cwd: '/work' }), message('user', 'Hello')]), + ); + expect(minimal.meta).toEqual({ sessionId: ID_B, permissionMode: '' }); + expect(minimal.events[0]).toMatchObject({ + version: '', + gitBranch: '', + entrypoint: '', + userType: '', + }); + }); + + it('uses the thread id rather than a differing root session_id in strict mode', async () => { + const result = await readCodex( + transcript([ + metadata({ session_id: ID_B }), + message('user', 'Hello'), + metadata({ id: ID_A.toUpperCase(), session_id: ID_B.toLowerCase() }), + message('assistant', 'Done'), + ]), + { strict: true }, + ); + expect(result.meta.sessionId).toBe(ID_A.toUpperCase()); + expect(result.events.map((event) => event.sessionId)).toEqual([ID_A, ID_A.toUpperCase()]); + expect(result.malformedCount).toBe(0); + }); + + it('retains function/custom/local-shell calls and output pairings without counting event tool notifications', async () => { + const outputs = [{ type: 'input_text', text: 'Read complete' }]; + const patch = + '*** Begin Patch\n*** Add File: src/new.ts\n+new\n*** Update File: src/old.ts\n*** Move to: src/moved.ts\n@@\n-old\n+new\n*** Delete File: src/deleted.ts\n*** End Patch'; + const result = await readCodex( + transcript([ + metadata(), + record('response_item', { + type: 'function_call', + name: 'functions.read_file', + arguments: '{"path":"src/input.ts"}', + call_id: 'call-read', + }), + record('event_msg', { + type: 'exec_command_begin', + call_id: 'call-read', + command: ['ignored'], + }), + record('response_item', { + type: 'function_call_output', + call_id: 'call-read', + output: outputs, + }), + record('response_item', { + type: 'custom_tool_call', + name: 'apply_patch', + input: patch, + call_id: 'call-patch', + }), + record('event_msg', { type: 'patch_apply_begin', call_id: 'call-patch', changes: {} }), + record('response_item', { + type: 'custom_tool_call_output', + call_id: 'call-patch', + output: 'Success', + }), + record('response_item', { + type: 'custom_tool_call', + name: 'custom_language', + input: 'raw custom syntax', + call_id: 'call-custom', + }), + record('response_item', { + type: 'local_shell_call', + id: 'shell-id', + action: { type: 'exec', command: ['pwd'], working_directory: '/work', timeout_ms: 1000 }, + }), + record('response_item', { + type: 'function_call_output', + call_id: 'shell-id', + output: '/work', + }), + ]), + { strict: true }, + ); + expect(result.events.map((event) => event.type)).toEqual([ + 'tool_use', + 'tool_result', + 'tool_use', + 'tool_result', + 'tool_use', + 'tool_use', + 'tool_result', + ]); + expect(result.events).toMatchObject([ + { + tool_use: { id: 'call-read', name: 'functions.read_file', input: { path: 'src/input.ts' } }, + }, + { + tool_result: { + tool_use_id: 'call-read', + content: [{ type: 'text', text: 'Read complete' }], + }, + }, + { + tool_use: { + id: 'call-patch', + name: 'apply_patch', + input: { + input: patch, + paths: ['src/new.ts', 'src/old.ts', 'src/moved.ts', 'src/deleted.ts'], + }, + }, + }, + { tool_result: { tool_use_id: 'call-patch', content: 'Success' } }, + { tool_use: { id: 'call-custom', name: 'custom_language', input: 'raw custom syntax' } }, + { tool_use: { id: 'shell-id', name: 'local_shell', input: { command: ['pwd'] } } }, + { tool_result: { tool_use_id: 'shell-id', content: '/work' } }, + ]); + expect(extractSignals(result.events)[2].files).toEqual([ + 'src/new.ts', + 'src/old.ts', + 'src/moved.ts', + 'src/deleted.ts', + ]); + }); + + it.each(['function_call_output', 'custom_tool_call_output'])( + 'projects only public text from %s arrays', + async (type) => { + const result = await readCodex( + transcript([ + record('response_item', { + type, + call_id: 'c', + output: [ + { type: 'input_text', text: 'ok', encrypted_content: 'PRIVATE_CIPHERTEXT' }, + { type: 'reasoning', encrypted_content: 'PRIVATE_CIPHERTEXT' }, + { type: 'output_text', text: 'done', opaque: { content: 'PRIVATE_OPAQUE' } }, + { type: 'future_output', content: 'PRIVATE_FUTURE' }, + ], + }), + ]), + { strict: true }, + ); + expect(result.events).toMatchObject([ + { + type: 'tool_result', + tool_result: { + tool_use_id: 'c', + content: [ + { type: 'text', text: 'ok' }, + { type: 'text', text: 'done' }, + ], + }, + }, + ]); + expect(result.malformedCount).toBe(0); + expect(JSON.stringify(result)).not.toContain('PRIVATE_'); + }, + ); + + it('treats nullable local-shell working_directory and timeout_ms as absent in strict mode', async () => { + const logger = loggerSpy(); + const result = await readCodex( + transcript([ + record('response_item', { + type: 'local_shell_call', + call_id: 'c', + action: { type: 'exec', command: ['pwd'], working_directory: null, timeout_ms: null }, + }), + ]), + { strict: true, logger }, + ); + expect(result.events[0]).toMatchObject({ + type: 'tool_use', + tool_use: { id: 'c', name: 'local_shell' }, + }); + expect(result.events[0].type === 'tool_use' && result.events[0].tool_use.input).toEqual({ + type: 'exec', + command: ['pwd'], + }); + expect(result.malformedCount).toBe(0); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('extracts namespaced function apply_patch paths without duplicate or added-content headers', async () => { + const patch = + '*** Update File: a.ts\r\n*** Move to: b.ts\r\n+*** Add File: not-a-path\n*** Delete File: a.ts\n*** Add File: space name.ts'; + const result = await readCodex( + transcript([ + record('response_item', { + type: 'function_call', + name: 'functions.apply_patch', + call_id: 'patch', + arguments: JSON.stringify({ patch, paths: ['existing.ts', 'a.ts'] }), + }), + ]), + ); + expect(extractSignals(result.events)[0].files).toEqual([ + 'existing.ts', + 'a.ts', + 'b.ts', + 'space name.ts', + ]); + }); + + it.each(['canonical-first', 'event-first'])( + 'deduplicates messages by occurrence, including identical repeated turns (%s)', + async (order) => { + const lines: unknown[] = [metadata()]; + for (let turn = 0; turn < 3; turn += 1) { + for (const role of ['user', 'assistant'] as const) { + const canonical = message(role, role === 'user' ? 'Again' : 'Done'); + const event = fallback(role, role === 'user' ? 'Again' : 'Done'); + lines.push(...(order === 'canonical-first' ? [canonical, event] : [event, canonical])); + } + } + const result = await readCodex(transcript(lines)); + expect(result.events.map((event) => event.type)).toEqual([ + 'user', + 'assistant', + 'user', + 'assistant', + 'user', + 'assistant', + ]); + expect(result.skippedMetaCount).toBe(6); + }, + ); + + it('preserves unmatched fallback occurrences and repeated canonical messages in a batch', async () => { + const result = await readCodex( + transcript([ + metadata(), + fallback('user', 'Repeat'), + fallback('user', 'Repeat'), + fallback('user', 'Repeat'), + message('user', 'Repeat'), + message('user', 'Repeat'), + message('assistant', 'Canonical only'), + message('assistant', 'Canonical only'), + fallback('assistant', 'Fallback only'), + ]), + ); + expect(extractSignals(result.events).map((signal) => signal.text)).toEqual([ + 'Repeat', + 'Repeat', + 'Repeat', + 'Canonical only', + 'Canonical only', + 'Fallback only', + ]); + }); + + it('does not pair identical text across different explicit turns', async () => { + const result = await readCodex( + transcript([ + record('turn_context', { cwd: '/work', turn_id: 'turn-one' }), + fallback('user', 'Again'), + record('turn_context', { cwd: '/work', turn_id: 'turn-two' }), + message('user', 'Again'), + ]), + ); + expect(result.events.map((event) => event.type)).toEqual(['user', 'user']); + }); + + it('does not pair unidentified turns across intervening opposite-role messages', async () => { + const result = await readCodex( + transcript([ + message('user', 'Again'), + message('assistant', 'Done'), + fallback('user', 'Again'), + ]), + ); + expect(result.events.map((event) => event.type)).toEqual(['user', 'assistant', 'user']); + }); + + it('stops inheriting an explicit turn id when a new turn_context has none', async () => { + const result = await readCodex( + transcript([ + record('turn_context', { cwd: '/work', turn_id: 't' }), + message('user', 'Again'), + record('turn_context', { cwd: '/work' }), + message('assistant', 'Done'), + fallback('user', 'Again'), + ]), + { strict: true }, + ); + expect(result.events.map((event) => event.type)).toEqual(['user', 'assistant', 'user']); + }); + + it.each(['canonical-first', 'event-first'])( + 'matches interleaved roles independently within an explicit turn (%s)', + async (order) => { + const canonical = [message('user', 'Q'), message('assistant', 'A')]; + const notifications = [fallback('user', 'Q'), fallback('assistant', 'A')]; + const result = await readCodex( + transcript([ + record('turn_context', { cwd: '/work', turn_id: 't' }), + ...(order === 'canonical-first' + ? [...canonical, ...notifications] + : [...notifications, ...canonical]), + ]), + { strict: true }, + ); + expect(result.events.map((event) => event.type)).toEqual(['user', 'assistant']); + expect(extractSignals(result.events).map((signal) => signal.text)).toEqual(['Q', 'A']); + expect(result.skippedMetaCount).toBe(3); + }, + ); + + it.each(['canonical-first', 'event-first'])( + 'preserves repeated occurrences in interleaved explicit turns (%s)', + async (order) => { + const lines: unknown[] = [metadata()]; + for (const turnId of ['one', 'two']) { + const canonical = [ + message('user', 'Again'), + message('user', 'Again'), + message('assistant', 'Done'), + ]; + const notifications = [ + fallback('user', 'Again'), + fallback('assistant', 'Done'), + fallback('user', 'Again'), + ]; + lines.push(record('turn_context', { cwd: '/work', turn_id: turnId })); + lines.push( + ...(order === 'canonical-first' + ? [...canonical, ...notifications] + : [...notifications, ...canonical]), + ); + } + const result = await readCodex(transcript(lines), { strict: true }); + const expectedTurn = + order === 'canonical-first' ? ['user', 'user', 'assistant'] : ['user', 'assistant', 'user']; + expect(result.events.map((event) => event.type)).toEqual([...expectedTurn, ...expectedTurn]); + expect(new Set(result.events.map((event) => event.uuid)).size).toBe(6); + expect(result.skippedMetaCount).toBe(8); + }, + ); + + it.each(['canonical-first', 'event-first'])( + 'preserves legitimate identical messages across compaction (%s)', + async (order) => { + const canonical = message('assistant', 'Done'); + const notification = fallback('assistant', 'Done'); + const [before, after] = + order === 'canonical-first' ? [canonical, notification] : [notification, canonical]; + const result = await readCodex( + transcript([before, record('compacted', { message: 'Summary' }), after]), + { strict: true }, + ); + expect(result.events).toMatchObject([ + { type: 'assistant', message: { content: [{ type: 'text', text: 'Done' }] } }, + { type: 'system', payload: { type: 'compaction', message: 'Summary' } }, + { type: 'assistant', message: { content: [{ type: 'text', text: 'Done' }] } }, + ]); + expect(result.skippedMetaCount).toBe(0); + }, + ); + + it.each([ + [ + 'function call', + record('response_item', { + type: 'function_call', + name: 'read', + call_id: 'c', + arguments: '{}', + }), + ], + [ + 'custom call', + record('response_item', { + type: 'custom_tool_call', + name: 'patch', + call_id: 'c', + input: 'patch', + }), + ], + [ + 'shell call', + record('response_item', { + type: 'local_shell_call', + call_id: 'c', + action: { type: 'exec', command: ['pwd'] }, + }), + ], + [ + 'function output', + record('response_item', { type: 'function_call_output', call_id: 'c', output: 'ok' }), + ], + [ + 'custom output', + record('response_item', { type: 'custom_tool_call_output', call_id: 'c', output: 'ok' }), + ], + [ + 'tool notification', + record('event_msg', { type: 'exec_command_begin', call_id: 'c', command: ['pwd'] }), + ], + ['compaction in an explicit turn', record('compacted', { message: 'Summary' })], + ] as const)( + 'does not match repeated text across %s even with an explicit turn id', + async (_name, activity) => { + const result = await readCodex( + transcript([ + record('turn_context', { cwd: '/work', turn_id: 't' }), + fallback('assistant', 'Done'), + activity, + message('assistant', 'Done'), + ]), + { strict: true }, + ); + expect(result.events.filter((event) => event.type === 'assistant')).toHaveLength(2); + expect( + result.events.filter( + (event) => + event.type === 'tool_use' || event.type === 'tool_result' || event.type === 'system', + ), + ).toHaveLength(activity.type === 'event_msg' ? 0 : 1); + }, + ); + + it('keeps fallback-only repeated messages and ignores progress/tool event duplicates', async () => { + const result = await readCodex( + transcript([ + fallback('user', 'Again'), + fallback('assistant', 'Done'), + record('event_msg', { type: 'agent_message_delta', delta: 'Done' }), + record('event_msg', { + type: 'exec_command_end', + call_id: 'notification', + output: 'not a call', + }), + fallback('user', 'Again'), + fallback('assistant', 'Done'), + ]), + ); + expect(result.events.map((event) => event.type)).toEqual([ + 'user', + 'assistant', + 'user', + 'assistant', + ]); + }); + + it('exposes only public reasoning summaries and marks compaction without replaying history', async () => { + const result = await readCodex( + transcript([ + metadata({ base_instructions: 'PRIVATE_BASE_INSTRUCTIONS' }), + record('response_item', { + type: 'reasoning', + summary: [{ type: 'summary_text', text: 'Public summary' }], + encrypted_content: 'PRIVATE_CIPHERTEXT', + content: [{ type: 'reasoning_text', text: 'PRIVATE_REASONING' }], + }), + record('response_item', { + type: 'reasoning', + summary: [], + encrypted_content: 'PRIVATE_CIPHERTEXT', + }), + record('response_item', { + type: 'agent_message', + encrypted_content: 'PRIVATE_AGENT_CONTENT', + content: [{ text: 'PRIVATE_UNKNOWN_CONTENT' }], + }), + record('compacted', { + message: 'Compacted public summary', + replacement_history: [message('user', 'PRIVATE_REPLACEMENT_REQUEST').payload], + }), + message('user', 'Continue'), + ]), + { strict: true }, + ); + expect(result.events).toMatchObject([ + { type: 'system', payload: { type: 'reasoning', summary: 'Public summary' } }, + { type: 'system', payload: { type: 'compaction', message: 'Compacted public summary' } }, + { type: 'user' }, + ]); + expect(result.events.filter((event) => event.type === 'user')).toHaveLength(1); + expect(JSON.stringify(result)).not.toContain('PRIVATE_'); + }); + + it('generates unique, deterministic line identities and a single chain through resumes and appends', async () => { + const repeated = message('user', 'Identical physical content'); + const path = transcript([ + metadata(), + { ...repeated, ordinal: 0 }, + '', + '{"unfinished":', + { ...repeated, ordinal: 0 }, + ]); + const first = await readCodex(path); + const again = await readCodex(path); + expect(first.events.map((event) => event.uuid)).toEqual( + again.events.map((event) => event.uuid), + ); + expect(new Set(first.events.map((event) => event.uuid)).size).toBe(2); + for (const event of first.events) + expect(event.uuid).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-8[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + appendFileSync( + path, + JSON.stringify(metadata()) + '\n' + JSON.stringify({ ...repeated, ordinal: 0 }) + '\n', + ); + const appended = await readCodex(path); + expect(appended.events.slice(0, 2)).toEqual(first.events); + expect(appended.events[2].parentUuid).toBe(first.events[1].uuid); + const graph = buildGraph(appended.meta, appended.events); + expect(graph.roots).toEqual([appended.events[0].uuid]); + expect(graph.byUuid.size).toBe(3); + expect(graph.childrenOf.get(appended.events[0].uuid)).toEqual([appended.events[1].uuid]); + }); + + it('does not renumber canonical IDs when a later fallback copy is appended', async () => { + const path = transcript([metadata(), message('user', 'Hello')]); + const initial = await readCodex(path); + appendFileSync(path, JSON.stringify(fallback('user', 'Hello')) + '\n'); + expect((await readCodex(path)).events).toEqual(initial.events); + }); + + it('does not replace fallback IDs when a later canonical copy is appended', async () => { + const path = transcript([metadata(), fallback('user', 'Hello')]); + const initial = await readCodex(path); + appendFileSync(path, JSON.stringify(message('user', 'Hello')) + '\n'); + expect((await readCodex(path)).events).toEqual(initial.events); + }); + + it('promotes canonical payloads while preserving first occurrence order, timestamps and graph identities after append', async () => { + const path = transcript([ + metadata(), + record('turn_context', { cwd: '/work', turn_id: 't' }), + fallback('user', 'First\nSecond'), + fallback('assistant', 'Answer'), + ]); + const initial = await readCodex(path, { strict: true }); + appendFileSync( + path, + JSON.stringify( + record( + 'response_item', + { + type: 'message', + role: 'user', + content: [ + { type: 'input_text', text: 'First' }, + { type: 'input_text', text: 'Second' }, + ], + }, + { timestamp: '2025-01-02T03:04:06.000Z' }, + ), + ) + '\n', + ); + const appended = await readCodex(path, { strict: true }); + expect(appended.events).toEqual([ + { + ...initial.events[0], + message: { + role: 'user', + content: [ + { type: 'text', text: 'First' }, + { type: 'text', text: 'Second' }, + ], + }, + }, + initial.events[1], + ]); + const graph = buildGraph(appended.meta, appended.events); + expect(graph.roots).toEqual([initial.events[0].uuid]); + expect(graph.childrenOf.get(initial.events[0].uuid)).toEqual([initial.events[1].uuid]); + }); +}); + +describe('Codex validation', () => { + const malformed = [ + ['missing timestamp', { type: 'session_meta', payload: { id: ID_A, cwd: '/work' } }], + [ + 'invalid timestamp', + record( + 'event_msg', + { type: 'agent_message', message: 'Hello' }, + { timestamp: 'DO_NOT_LEAK' }, + ), + ], + [ + 'invalid ordinal', + record('event_msg', { type: 'agent_message', message: 'Hello' }, { ordinal: -1 }), + ], + ['missing id', record('session_meta', { cwd: '/work' })], + ['missing id with root session_id', record('session_meta', { session_id: ID_A, cwd: '/work' })], + ['invalid id', metadata({ id: 'DO_NOT_LEAK' })], + ['invalid root session_id', metadata({ session_id: 'DO_NOT_LEAK' })], + ['null root session_id', metadata({ session_id: null })], + ['missing cwd', record('session_meta', { id: ID_A })], + ['turn cwd', record('turn_context', { cwd: 7 })], + ['payload', record('response_item', [])], + ['item type', record('response_item', {})], + ['role', record('response_item', { type: 'message', content: [] })], + ['content', record('response_item', { type: 'message', role: 'user' })], + [ + 'text', + record('response_item', { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: {} }], + }), + ], + [ + 'tool name', + record('response_item', { type: 'function_call', call_id: 'call', arguments: '{}' }), + ], + ['call id', record('response_item', { type: 'function_call', name: 'read', arguments: '{}' })], + [ + 'arguments JSON', + record('response_item', { + type: 'function_call', + name: 'read', + call_id: 'call', + arguments: 'DO_NOT_LEAK', + }), + ], + [ + 'arguments type', + record('response_item', { + type: 'function_call', + name: 'read', + call_id: 'call', + arguments: {}, + }), + ], + [ + 'custom input', + record('response_item', { + type: 'custom_tool_call', + name: 'custom', + call_id: 'call', + input: 7, + }), + ], + [ + 'output', + record('response_item', { type: 'function_call_output', call_id: 'call', output: {} }), + ], + ['output id', record('response_item', { type: 'custom_tool_call_output', output: 'done' })], + ['shell action', record('response_item', { type: 'local_shell_call', call_id: 'call' })], + [ + 'shell command', + record('response_item', { + type: 'local_shell_call', + call_id: 'call', + action: { type: 'exec', command: 'pwd' }, + }), + ], + [ + 'shell working directory', + record('response_item', { + type: 'local_shell_call', + call_id: 'call', + action: { type: 'exec', command: ['pwd'], working_directory: 7 }, + }), + ], + [ + 'shell timeout type', + record('response_item', { + type: 'local_shell_call', + call_id: 'call', + action: { type: 'exec', command: ['pwd'], timeout_ms: 'DO_NOT_LEAK' }, + }), + ], + [ + 'shell negative timeout', + record('response_item', { + type: 'local_shell_call', + call_id: 'call', + action: { type: 'exec', command: ['pwd'], timeout_ms: -1 }, + }), + ], + ['reasoning summary', record('response_item', { type: 'reasoning', summary: {} })], + ['compaction summary', record('compacted', { replacement_history: [] })], + ['event message', record('event_msg', { type: 'user_message', message: {} })], + ['permission mode', metadata({ permissionMode: 7 })], + ] as const; + + it.each(malformed)( + 'strictly validates %s without disclosing source values', + async (_name, line) => { + const path = transcript([line]); + await expect(readCodex(path, { strict: true })).rejects.toThrow( + 'codex jsonl error at line 1', + ); + try { + await readCodex(path, { strict: true }); + } catch (error) { + expect(String(error)).not.toContain('DO_NOT_LEAK'); + } + const logger = loggerSpy(); + const result = await readCodex(path, { logger }); + expect(result.malformedCount).toBe(1); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(JSON.stringify(vi.mocked(logger.warn).mock.calls)).not.toContain('DO_NOT_LEAK'); + }, + ); + + it('does not recover a missing thread id from root session_id in tolerant mode', async () => { + const result = await readCodex( + transcript([ + record('session_meta', { session_id: ID_A, cwd: '/work' }), + message('user', 'Hello'), + ]), + ); + expect(result.meta.sessionId).toBe(''); + expect(result.events[0].sessionId).toBe(''); + expect(result.malformedCount).toBe(1); + }); + + it('continues rejecting a changed thread id regardless of root session_id', async () => { + const path = transcript([ + metadata({ session_id: ID_C }), + metadata({ id: ID_B, session_id: ID_C }), + message('user', 'Hello'), + ]); + await expect(readCodex(path, { strict: true })).rejects.toThrow('session metadata id changed'); + const result = await readCodex(path); + expect(result.meta.sessionId).toBe(ID_A); + expect(result.events[0].sessionId).toBe(ID_A); + expect(result.malformedCount).toBe(1); + }); + + describe.each(['function_call_output', 'custom_tool_call_output'])('%s arrays', (type) => { + it.each([null, {}, { type: 7 }, { type: 'input_text', text: {} }, { type: 'output_text' }])( + 'rejects malformed public blocks in strict mode (case %#)', + async (block) => { + const logger = loggerSpy(); + const path = transcript([ + record('response_item', { + type, + call_id: 'c', + output: [block, { type: 'input_text', text: 'Keep this' }], + }), + ]); + await expect(readCodex(path, { strict: true })).rejects.toThrow( + 'codex jsonl error at line 1', + ); + const result = await readCodex(path, { logger }); + expect(result.malformedCount).toBe(1); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(result.events).toMatchObject([ + { + type: 'tool_result', + tool_result: { tool_use_id: 'c', content: [{ type: 'text', text: 'Keep this' }] }, + }, + ]); + }, + ); + }); + + it('counts each physical malformed line once and recovers valid public blocks', async () => { + const logger = loggerSpy(); + const path = transcript([ + metadata(), + '{"DO_NOT_LEAK":', + '[]', + { + type: 'response_item', + timestamp: {}, + ordinal: 'DO_NOT_LEAK', + payload: { + type: 'message', + role: 'user', + content: [ + null, + { type: 'input_text', text: {} }, + { type: 'input_text', text: 'Keep this' }, + ], + }, + }, + message('assistant', 'Intact'), + ]); + const result = await readCodex(path, { logger }); + expect(result.malformedCount).toBe(3); + expect(logger.warn).toHaveBeenCalledTimes(3); + expect(extractSignals(result.events).map((signal) => signal.text)).toEqual([ + 'Keep this', + 'Intact', + ]); + expect(JSON.stringify(vi.mocked(logger.warn).mock.calls)).not.toContain('DO_NOT_LEAK'); + }); + + it('tolerates future metadata and opaque content without retaining it', async () => { + const result = await readCodex( + transcript([ + { type: 'future_rollout_record', payload: 'PRIVATE_FUTURE_METADATA' }, + record('response_item', { type: 'future_item', extra: 'PRIVATE_FUTURE_ITEM' }), + record('event_msg', { type: 'future_notification', message: 'PRIVATE_FUTURE_EVENT' }), + record('response_item', { + type: 'message', + role: 'assistant', + content: [ + { type: 'future_content', content: 'PRIVATE_FUTURE_CONTENT' }, + { type: 'output_text', text: 'Public answer' }, + ], + }), + ]), + { strict: true }, + ); + expect(result.malformedCount).toBe(0); + expect(result.skippedMetaCount).toBe(3); + expect(extractSignals(result.events).map((signal) => signal.text)).toEqual(['Public answer']); + expect(JSON.stringify(result)).not.toContain('PRIVATE_'); + }); + + it('handles empty logs and keeps complete events before a truncated final physical line', async () => { + expect(await readCodex(transcript([]), { strict: true })).toEqual({ + meta: { sessionId: '', permissionMode: '' }, + events: [], + malformedCount: 0, + skippedMetaCount: 0, + }); + const path = transcript([metadata(), message('user', 'Complete')]); + appendFileSync(path, '{"type":"response_item","payload":'); + const result = await readCodex(path); + expect(result.events).toHaveLength(1); + expect(result.malformedCount).toBe(1); + await expect(readCodex(path, { strict: true })).rejects.toThrow('line 3'); + }); + + it('handles BOM, CRLF, blank lines and complete last lines without a newline', async () => { + const path = join(temporary, 'line-endings.jsonl'); + writeFileSync( + path, + '\uFEFF' + JSON.stringify(metadata()) + '\r\n\r\n' + JSON.stringify(message('user', 'Hello')), + ); + const result = await readCodex(path, { strict: true }); + expect(result.malformedCount).toBe(0); + expect(result.events).toHaveLength(1); + }); + + it('surfaces missing-file IO errors', async () => { + await expect(readCodex(join(temporary, 'missing.jsonl'))).rejects.toMatchObject({ + code: 'ENOENT', + }); + }); +}); diff --git a/tests/fixtures/codex-session.jsonl b/tests/fixtures/codex-session.jsonl new file mode 100644 index 0000000..24e439e --- /dev/null +++ b/tests/fixtures/codex-session.jsonl @@ -0,0 +1,17 @@ +{"timestamp":"2026-01-02T03:04:05Z","type":"session_meta","payload":{"id":"aaaa1111-2222-3333-4444-555566667777","session_id":"dddd1111-2222-3333-4444-555566667777","cwd":"/synthetic/project","cli_version":"0.160.0","source":"cli","base_instructions":"SYNTHETIC_PRIVATE_INSTRUCTIONS"}} +{"timestamp":"2026-01-02T03:04:06Z","type":"turn_context","payload":{"turn_id":"synthetic-turn-1","cwd":"/synthetic/project"}} +{"timestamp":"2026-01-02T03:04:07Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"Please update src/app.ts for SYNTHETIC_PRIVATE_WORD owner@example.com."}]}} +{"timestamp":"2026-01-02T03:04:07Z","type":"event_msg","payload":{"type":"user_message","message":"Please update src/app.ts for SYNTHETIC_PRIVATE_WORD owner@example.com."}} +{"timestamp":"2026-01-02T03:04:08Z","type":"response_item","payload":{"type":"reasoning","summary":[{"type":"summary_text","text":"Inspect src/app.ts before applying the change."}],"encrypted_content":"SYNTHETIC_PRIVATE_REASONING"}} +{"timestamp":"2026-01-02T03:04:09Z","type":"response_item","payload":{"type":"function_call","name":"read_file","call_id":"synthetic-read-1","arguments":"{\"path\":\"src/app.ts\"}"}} +{"timestamp":"2026-01-02T03:04:10Z","type":"response_item","payload":{"type":"function_call_output","call_id":"synthetic-read-1","output":"export const enabled = false;"}} +{"timestamp":"2026-01-02T03:04:11Z","type":"response_item","payload":{"type":"custom_tool_call","name":"apply_patch","call_id":"synthetic-patch-1","input":"*** Begin Patch\n*** Update File: src/app.ts\n@@\n-export const enabled = false;\n+export const enabled = true;\n*** End Patch"}} +{"timestamp":"2026-01-02T03:04:12Z","type":"response_item","payload":{"type":"custom_tool_call_output","call_id":"synthetic-patch-1","output":"Success. Updated src/app.ts."}} +{"timestamp":"2026-01-02T03:04:13Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Updated src/app.ts to enable the feature."}]}} +{"timestamp":"2026-01-02T03:04:13Z","type":"event_msg","payload":{"type":"agent_message","message":"Updated src/app.ts to enable the feature."}} +{"timestamp":"2026-01-02T03:05:00Z","type":"compacted","payload":{"message":"Updated src/app.ts; next add a guard.","replacement_history":[]}} +{"timestamp":"2026-01-02T03:06:00Z","type":"turn_context","payload":{"turn_id":"synthetic-turn-2","cwd":"/synthetic/project"}} +{"timestamp":"2026-01-02T03:06:01Z","type":"event_msg","payload":{"type":"user_message","message":"Now explain how to guard the new feature."}} +{"timestamp":"2026-01-02T03:06:01Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"Now explain how to guard the new feature."}]}} +{"timestamp":"2026-01-02T03:06:02Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Guard the feature with the enabled flag."}]}} +{"timestamp":"2026-01-02T03:06:02Z","type":"event_msg","payload":{"type":"agent_message","message":"Guard the feature with the enabled flag."}} diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index b586680..ff4b14a 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -7,9 +7,9 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createServer } from '../src/mcp/server.js'; import { encodeProjectPath } from '../src/sources/claude.js'; -import { recordPick } from '../src/utils/picks.js'; +import { listAllPicks, readPicks, recordPick, removePicksForNode } from '../src/utils/picks.js'; -const testState = vi.hoisted(() => ({ userConfigPath: '' })); +const testState = vi.hoisted(() => ({ userConfigPath: '', picksRoot: '' })); vi.mock('../src/config/loader.js', async (importOriginal) => { const actual = await importOriginal(); return { @@ -19,12 +19,33 @@ vi.mock('../src/config/loader.js', async (importOriginal) => { }; }); -vi.mock('../src/utils/picks.js', () => ({ - readPicks: vi.fn(async () => ({ modesByNode: new Map(), total: 0 })), - recordPick: vi.fn(async () => undefined), - listAllPicks: vi.fn(async () => []), - removePicksForNode: vi.fn(async () => 1), -})); +vi.mock('../src/utils/picks.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readPicks: vi.fn((sessionId: string, options: Parameters[1] = {}) => + actual.readPicks(sessionId, { ...options, root: testState.picksRoot }), + ), + recordPick: vi.fn( + ( + sessionId: string, + nodeId: string, + mode: 'continue' | 'fork', + options: Parameters[3] = {}, + ) => actual.recordPick(sessionId, nodeId, mode, { ...options, root: testState.picksRoot }), + ), + listAllPicks: vi.fn((options: Parameters[0] = {}) => + actual.listAllPicks({ ...options, root: testState.picksRoot }), + ), + removePicksForNode: vi.fn( + ( + sessionId: string, + nodeId: string, + options: Parameters[2] = {}, + ) => actual.removePicksForNode(sessionId, nodeId, { ...options, root: testState.picksRoot }), + ), + }; +}); vi.mock('../src/utils/git.js', () => ({ getGitContext: vi.fn(async () => ({ available: true })), formatGitContextMarkdown: vi.fn(() => '## Git context\nPRIVATE_WORD owner@example.com'), @@ -33,12 +54,17 @@ vi.mock('../src/utils/git.js', () => ({ let root: string; let project: string; let file: string; +let codexFile: string; let server: McpServer; let client: Client; const id = 'aaaa1111-2222-3333-4444-555566667777'; +// Strict PII redaction also matches this deliberately numeric fixture UUID. +const strictRedactedId = 'aaaa1111-[PHONE]-555566667777'; +const otherId = 'bbbb1111-2222-3333-4444-555566667777'; beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'agent-tree-mcp-')); testState.userConfigPath = join(root, 'user.yaml'); + testState.picksRoot = join(root, 'picks'); project = join(root, 'project'); await mkdir(project); file = join(project, 'portable.jsonl'); @@ -49,7 +75,12 @@ beforeEach(async () => { 'PRIVATE_WORD owner@example.com Please read', ), ); + codexFile = join(project, 'codex-export.jsonl'); + await writeFile(codexFile, await readFile(resolve('tests/fixtures/codex-session.jsonl'))); vi.stubEnv('CLAUDE_CONFIG_DIR', join(root, 'claude')); + vi.stubEnv('CODEX_HOME', join(root, 'codex')); + vi.stubEnv('AGENT_TREE_NO_LLM', 'true'); + vi.stubEnv('ANTHROPIC_API_KEY', ''); vi.stubEnv('AGENT_TREE_MODEL', undefined); vi.stubEnv('AGENT_TREE_REDACT_STRICT', ''); server = createServer(); @@ -70,6 +101,31 @@ async function call(name: string, args: Record = {}) { return client.callTool({ name, arguments: args }); } +async function rollout(sessionId = id, cwd = project) { + const directory = join(root, 'codex', 'sessions', '2026', '01', '02'); + await mkdir(directory, { recursive: true }); + const path = join(directory, `rollout-2026-01-02T03-04-05-${sessionId}.jsonl`); + const lines = (await readFile(codexFile, 'utf8')) + .trim() + .split('\n') + .map((line) => { + const record = JSON.parse(line); + if (record.type === 'session_meta') record.payload.id = sessionId; + if (record.payload.cwd) record.payload.cwd = cwd; + return JSON.stringify(record); + }); + await writeFile(path, lines.join('\n') + '\n'); + return path; +} + +function text(result: Awaited>) { + expect(result.isError).not.toBe(true); + return (result.content as Array<{ type: string; text?: string }>) + .filter((block) => block.type === 'text') + .map((block) => block.text ?? '') + .join('\n'); +} + describe('MCP protocol tools', () => { it('advertises all six tools and accurate mutation annotations', async () => { const { tools } = await client.listTools(); @@ -81,6 +137,13 @@ describe('MCP protocol tools', () => { 'agent_tree_snapshot', 'agent_tree_unstar', ]); + for (const tool of tools) { + expect(tool.inputSchema.properties?.source).toMatchObject({ + type: 'string', + enum: ['claude', 'codex'], + }); + expect(tool.inputSchema.required ?? []).not.toContain('source'); + } expect( tools.find((tool) => tool.name === 'agent_tree_sessions')?.annotations?.readOnlyHint, ).toBe(true); @@ -96,7 +159,9 @@ describe('MCP protocol tools', () => { const args = { cwd: project, file, format: 'json' }; const first = await call('agent_tree_list', args); expect(first.isError).not.toBe(true); - expect(first.structuredContent).toMatchObject({ mindmap: { session_id: id } }); + expect(first.structuredContent).toMatchObject({ + mindmap: { source: 'claude', session_id: id }, + }); expect(JSON.stringify(first)).toContain('owner@example.com'); await writeFile( join(project, '.agent-tree.yaml'), @@ -146,7 +211,7 @@ describe('MCP protocol tools', () => { expect(JSON.stringify(result)).toContain('Git context'); expect(JSON.stringify(result)).not.toContain('PRIVATE_WORD'); expect(JSON.stringify(result)).not.toContain('owner@example.com'); - expect(recordPick).toHaveBeenCalledWith(id, 'n_001', mode); + expect(recordPick).toHaveBeenCalledWith(id, 'n_001', mode, { source: 'claude' }); } }); @@ -164,3 +229,251 @@ describe('MCP protocol tools', () => { } }); }); + +describe('Codex MCP workflows', () => { + it('auto-detects files and returns matching redacted text JSON and structured mindmaps', async () => { + await writeFile( + join(project, '.agent-tree.yaml'), + 'redaction:\n strict: true\n extra_patterns: ["SYNTHETIC_PRIVATE_WORD"]\n', + ); + for (const source of [undefined, 'codex']) { + const result = await call('agent_tree_list', { + cwd: project, + file: codexFile, + format: 'json', + source, + }); + const map = JSON.parse(text(result)); + expect(result.structuredContent).toEqual({ mindmap: map }); + expect(map).toMatchObject({ + source: 'codex', + session_id: strictRedactedId, + project_path: '/synthetic/project', + stats: { total_events: 10, total_turns: 4, total_tool_calls: 2 }, + }); + expect(map.root.files_touched).toContain('src/app.ts'); + expect(map.root.tools_used).toEqual(expect.arrayContaining(['read_file', 'apply_patch'])); + expect(JSON.stringify(result)).not.toContain('SYNTHETIC_PRIVATE_WORD'); + expect(JSON.stringify(result)).not.toContain('owner@example.com'); + expect(JSON.stringify(result)).not.toContain('SYNTHETIC_PRIVATE_INSTRUCTIONS'); + expect(JSON.stringify(result)).not.toContain('SYNTHETIC_PRIVATE_REASONING'); + expect(JSON.stringify(result)).not.toContain('dddd1111-2222-3333-4444-555566667777'); + expect(JSON.stringify(result)).toContain('[EMAIL]'); + } + }); + + it('discovers Codex catalogs and resolves project-latest and UUID selectors independently of Claude', async () => { + const path = await rollout(); + await rollout(otherId, join(root, 'other-project')); + const claudeDir = join(root, 'claude', 'projects', encodeProjectPath(project)); + await mkdir(claudeDir, { recursive: true }); + await writeFile(join(claudeDir, `${id}.jsonl`), await readFile(file)); + const catalog = await call('agent_tree_sessions', { + source: 'codex', + cwd: relative(process.cwd(), project), + limit: 1, + }); + const expected = [{ source: 'codex', sessionId: id, projectDir: project, jsonlPath: path }]; + expect(catalog.structuredContent).toMatchObject({ sessions: expected }); + expect(JSON.parse(text(catalog))).toEqual(catalog.structuredContent); + const all = JSON.parse(text(await call('agent_tree_sessions', { source: 'codex' }))); + expect(all.sessions).toHaveLength(2); + expect(all.sessions.every((session: { source: string }) => session.source === 'codex')).toBe( + true, + ); + expect((await call('agent_tree_sessions', { cwd: project })).structuredContent).toMatchObject({ + sessions: [{ source: 'claude', sessionId: id }], + }); + for (const sessionId of [undefined, id, id.slice(0, 8)]) { + const result = await call('agent_tree_list', { + cwd: project, + source: 'codex', + sessionId, + format: 'json', + }); + expect(result.isError).not.toBe(true); + expect(result.structuredContent).toMatchObject({ + mindmap: { source: 'codex', session_id: id }, + }); + } + expect( + (await call('agent_tree_list', { cwd: project, sessionId: id, format: 'json' })) + .structuredContent, + ).toMatchObject({ + mindmap: { source: 'claude', session_id: id }, + }); + }); + + it('keeps catalogs metadata-only and redacts paths in text and structured responses', async () => { + await writeFile( + testState.userConfigPath, + 'redaction:\n strict: true\n extra_patterns: ["SYNTHETIC_PRIVATE_WORD"]\n', + ); + const path = await rollout(id, join(project, 'SYNTHETIC_PRIVATE_WORD-owner@example.com')); + await writeFile( + path, + (await readFile(path, 'utf8')).split('\n')[0] + '\n{"private":"SYNTHETIC_BODY_SECRET"\n', + ); + const result = await call('agent_tree_sessions', { source: 'codex' }); + const catalog = JSON.parse(text(result)); + expect(catalog.sessions).toHaveLength(1); + expect(catalog.sessions[0]).toMatchObject({ source: 'codex', sessionId: strictRedactedId }); + expect(Object.keys(catalog.sessions[0]).sort()).toEqual( + ['source', 'sessionId', 'projectDir', 'jsonlPath', 'mtimeMs', 'sizeBytes'].sort(), + ); + expect(JSON.stringify(result)).not.toContain('SYNTHETIC_BODY_SECRET'); + expect(JSON.stringify(result)).not.toContain('SYNTHETIC_PRIVATE_INSTRUCTIONS'); + expect(JSON.stringify(result)).not.toContain('owner@example.com'); + expect(JSON.stringify(result)).not.toContain('SYNTHETIC_PRIVATE_WORD'); + expect(JSON.stringify(result)).toContain('[EMAIL]'); + }); + + it('isolates snapshots, stars and unstar actions for the same UUID across both sources', async () => { + await writeFile( + join(project, '.agent-tree.yaml'), + 'redaction:\n strict: true\n extra_patterns: ["PRIVATE_WORD"]\n', + ); + for (const mode of ['continue', 'fork']) { + const result = await call('agent_tree_snapshot', { + cwd: project, + file: codexFile, + nodeId: '1', + mode, + ...(mode === 'fork' ? { source: 'codex' } : {}), + }); + expect(text(result)).toContain(mode === 'fork' ? 'Forking at:' : 'Continuing from:'); + expect(text(result)).toContain(strictRedactedId); + expect(text(result)).not.toContain('PRIVATE_WORD'); + expect(text(result)).not.toContain('owner@example.com'); + expect(recordPick).toHaveBeenCalledWith(id, 'n_001', mode, { source: 'codex' }); + } + expect(text(await call('agent_tree_list', { cwd: project, file: codexFile }))).toContain( + '1 starred nodes, 2 total picks', + ); + expect(text(await call('agent_tree_list', { cwd: project, file }))).not.toContain('starred'); + expect(readPicks).toHaveBeenCalledWith(id, { source: 'codex' }); + expect(readPicks).toHaveBeenCalledWith(id, { source: 'claude' }); + text(await call('agent_tree_snapshot', { cwd: project, file, nodeId: '1' })); + const all = text(await call('agent_tree_picks')); + expect(all).toContain(`claude session ${id.slice(0, 8)}`); + expect(all).toContain(`codex session ${id.slice(0, 8)}`); + expect(listAllPicks).toHaveBeenCalledWith({ source: undefined }); + for (const source of ['claude', 'codex']) { + const picks = text(await call('agent_tree_picks', { source })); + expect(picks).toContain(`${source} session ${id.slice(0, 8)}`); + expect(picks).not.toContain(`${source === 'claude' ? 'codex' : 'claude'} session`); + expect(listAllPicks).toHaveBeenCalledWith({ source }); + } + const unstar = await call('agent_tree_unstar', { cwd: project, file: codexFile, nodeId: '1' }); + expect(text(unstar)).toContain('Unstarred n_001; removed 2 pick entries'); + expect(removePicksForNode).toHaveBeenCalledWith(id, 'n_001', { source: 'codex' }); + expect(text(await call('agent_tree_list', { cwd: project, file: codexFile }))).not.toContain( + 'starred', + ); + expect(text(await call('agent_tree_list', { cwd: project, file }))).toContain( + '1 starred nodes, 1 total picks', + ); + expect(text(await call('agent_tree_picks', { source: 'codex' }))).toContain('No picks'); + expect( + text( + await call('agent_tree_unstar', { + cwd: project, + source: 'codex', + file: codexFile, + nodeId: '1', + }), + ), + ).toContain('No picks recorded'); + }); + + it('diffs Codex nodes with normalized tool and patch-file information', async () => { + const result = await call('agent_tree_diff', { + cwd: project, + file: codexFile, + source: 'codex', + from: '2', + to: '1', + }); + expect(text(result)).toContain('# Diff:'); + expect(text(result)).toContain('event range:'); + expect(text(result)).toContain('src/app.ts'); + expect(text(result)).toContain('apply_patch'); + expect(text(result)).toContain('read_file'); + }); + + it('rejects source mismatches before serving cached results or mutating picks', async () => { + for (const [imported, source, mismatch] of [ + [codexFile, 'codex', 'claude'], + [file, 'claude', 'codex'], + ]) { + const args = { cwd: project, file: imported, source, format: 'json' }; + expect((await call('agent_tree_list', args)).structuredContent).toMatchObject({ + mindmap: { source }, + }); + for (const [name, extra] of [ + ['agent_tree_list', { format: 'json' }], + ['agent_tree_snapshot', { nodeId: '1' }], + ['agent_tree_diff', { from: '1', to: '2' }], + ['agent_tree_unstar', { nodeId: '1' }], + ] as const) { + const result = await call(name, { + cwd: project, + file: imported, + source: mismatch, + ...extra, + }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result)).toContain('does not match the selected source'); + expect(JSON.stringify(result)).not.toContain('owner@example.com'); + expect(result.structuredContent).toBeUndefined(); + } + expect((await call('agent_tree_list', args)).structuredContent).toMatchObject({ + mindmap: { source }, + }); + } + expect(recordPick).not.toHaveBeenCalled(); + expect(removePicksForNode).not.toHaveBeenCalled(); + }); + + it('separates cached analyses by source even when file contents and metadata are unchanged', async () => { + const empty = join(project, 'empty.jsonl'); + await writeFile(empty, ''); + for (const source of ['claude', 'codex', 'claude', 'codex']) { + const result = await call('agent_tree_list', { + cwd: project, + file: empty, + source, + format: 'json', + }); + expect(result.isError).not.toBe(true); + expect(result.structuredContent).toMatchObject({ + mindmap: { source, stats: { total_events: 0 } }, + }); + } + const args = { cwd: project, file, format: 'json' }; + expect((await call('agent_tree_list', args)).structuredContent).toMatchObject({ + mindmap: { source: 'claude' }, + }); + await writeFile(file, await readFile(codexFile)); + expect((await call('agent_tree_list', args)).structuredContent).toMatchObject({ + mindmap: { source: 'codex', session_id: id }, + }); + }); + + it('rejects invalid source values on every tool', async () => { + const { tools } = await client.listTools(); + for (const tool of tools) { + const result = await call(tool.name, { + cwd: project, + file: codexFile, + nodeId: '1', + from: '1', + to: '2', + source: 'invalid', + }); + expect(result.isError).toBe(true); + } + expect(recordPick).not.toHaveBeenCalled(); + expect(removePicksForNode).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/options.test.ts b/tests/options.test.ts index 02b9019..d561dbe 100644 --- a/tests/options.test.ts +++ b/tests/options.test.ts @@ -30,6 +30,11 @@ describe('CLI argument validation', () => { ['--json', '--tui'], ['--json', '--filter', 'file'], ['--json', '--phases-only'], + ['--source'], + ['--source', 'invalid'], + ['--source', 'CODEX'], + ['--source', '../codex'], + ['--source', ''], ['--unknown'], ])('rejects invalid or conflicting arguments %j', (...args) => { expect(parse(...args)).toEqual({ ok: false, exitCode: 2 }); @@ -51,6 +56,29 @@ describe('CLI argument validation', () => { expect(parsed.opts.maxLlmTokens).toBeUndefined(); }); + it.each(['claude', 'codex'])('accepts optional source %s with every workflow', (source) => { + for (const args of [ + ['--sessions', '--json'], + ['--picks'], + ['--file', 'export.jsonl', '--json'], + ['aaaa1111', '--list'], + ['--latest', '--snapshot', '1'], + ['--file', 'export.jsonl', '--unstar', '1'], + ['--file', 'export.jsonl', '--diff', '1', '2'], + ['--pick'], + ]) { + expect(parse('--source', source, ...args)).toMatchObject({ ok: true, opts: { source } }); + } + }); + + it('preserves absent source for file auto-detection and cross-source picks', () => { + for (const args of [['--file', 'export.jsonl', '--json'], ['--picks'], ['--sessions']]) { + const parsed = parse(...args); + expect(parsed.ok).toBe(true); + if (parsed.ok) expect(parsed.opts.source).toBeUndefined(); + } + }); + it('parses explicit budgets and portable JSON output', () => { const parsed = parse( '--file', diff --git a/tests/picks.test.ts b/tests/picks.test.ts new file mode 100644 index 0000000..3b50751 --- /dev/null +++ b/tests/picks.test.ts @@ -0,0 +1,290 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { SessionSourceId } from '../src/sources/types.js'; +import { listAllPicks, readPicks, recordPick, removePicksForNode } from '../src/utils/picks.js'; + +const ID_A = 'aaaaaaaa-0000-4000-8000-000000000001'; +const ID_B = 'BBBBBBBB-0000-4000-8000-000000000002'; +const ID_C = 'cccccccc-0000-4000-8000-000000000003'; +const EARLY = '2026-01-01T00:00:00.000Z'; +const MIDDLE = '2026-01-02T00:00:00.000Z'; +const LATE = '2026-01-03T00:00:00.000Z'; +let workdir: string; +let root: string; + +beforeEach(async () => { + workdir = await mkdtemp(join(tmpdir(), 'agent-tree-source-picks-')); + root = join(workdir, 'picks'); + await mkdir(root); +}); + +afterEach(async () => { + await rm(workdir, { recursive: true, force: true }); +}); + +async function writeHistory(source: string, sessionId: string, body: string): Promise { + const directory = join(root, source); + await mkdir(directory, { recursive: true }); + const file = join(directory, `${sessionId}.jsonl`); + await writeFile(file, body, 'utf8'); + return file; +} + +describe('source-scoped pick history', () => { + it('isolates identical session and node IDs while defaulting per-session calls to Claude', async () => { + await recordPick(ID_A, 'shared', 'continue', { root }); + await recordPick(ID_A, 'claude-only', 'fork', { root, source: 'claude' }); + await recordPick(ID_A, 'shared', 'fork', { root, source: 'codex' }); + await recordPick(ID_A, 'codex-only', 'continue', { root, source: 'codex' }); + + const claude = await readPicks(ID_A, { root }); + expect(claude).toEqual({ + total: 2, + modesByNode: new Map([ + ['shared', new Set(['continue'])], + ['claude-only', new Set(['fork'])], + ]), + }); + expect(await readPicks(ID_A, { root, source: 'claude' })).toEqual(claude); + expect(await readPicks(ID_A, { root, source: 'codex' })).toEqual({ + total: 2, + modesByNode: new Map([ + ['shared', new Set(['fork'])], + ['codex-only', new Set(['continue'])], + ]), + }); + expect((await readdir(root)).sort()).toEqual(['claude', 'codex']); + const lines = (await readFile(join(root, 'claude', `${ID_A}.jsonl`), 'utf8')) + .trim() + .split('\n'); + expect(lines.map((line) => JSON.parse(line))).toEqual([ + { node_id: 'shared', mode: 'continue', ts: expect.any(String) }, + { node_id: 'claude-only', mode: 'fork', ts: expect.any(String) }, + ]); + }); + + it('removes all Codex picks for a node without changing Claude stars or other Codex nodes', async () => { + await recordPick(ID_A, 'shared', 'continue', { root }); + await recordPick(ID_A, 'shared', 'fork', { root }); + await recordPick(ID_A, 'shared', 'continue', { root, source: 'codex' }); + await recordPick(ID_A, 'keep', 'fork', { root, source: 'codex' }); + await recordPick(ID_A, 'shared', 'fork', { root, source: 'codex' }); + const claudeFile = join(root, 'claude', `${ID_A}.jsonl`); + const originalClaude = await readFile(claudeFile, 'utf8'); + + expect(await removePicksForNode(ID_A, 'shared', { root, source: 'codex' })).toBe(2); + expect(await readFile(claudeFile, 'utf8')).toBe(originalClaude); + expect(await readPicks(ID_A, { root })).toEqual({ + total: 2, + modesByNode: new Map([['shared', new Set(['continue', 'fork'])]]), + }); + expect(await readPicks(ID_A, { root, source: 'codex' })).toEqual({ + total: 1, + modesByNode: new Map([['keep', new Set(['fork'])]]), + }); + expect(await removePicksForNode(ID_A, 'shared', { root, source: 'codex' })).toBe(0); + expect(await readdir(join(root, 'codex'))).toEqual([`${ID_A}.jsonl`]); + + expect(await removePicksForNode(ID_A, 'shared', { root })).toBe(2); + expect(await readFile(claudeFile, 'utf8')).toBe(''); + expect((await readPicks(ID_A, { root, source: 'codex' })).total).toBe(1); + }); + + it('does not read, migrate, or remove legacy flat histories', async () => { + const legacyFile = join(root, `${ID_A}.jsonl`); + const legacy = JSON.stringify({ node_id: 'legacy', mode: 'fork', ts: EARLY }) + '\n'; + await writeFile(legacyFile, legacy, 'utf8'); + + for (const source of ['claude', 'codex'] as const) { + expect(await readPicks(ID_A, { root, source })).toEqual({ total: 0, modesByNode: new Map() }); + expect(await removePicksForNode(ID_A, 'legacy', { root, source })).toBe(0); + } + expect(await listAllPicks({ root })).toEqual([]); + await recordPick(ID_A, 'new', 'continue', { root }); + expect((await readPicks(ID_A, { root })).modesByNode.has('legacy')).toBe(false); + expect(await readFile(legacyFile, 'utf8')).toBe(legacy); + expect(await listAllPicks({ root })).toEqual([ + { + source: 'claude', + sessionId: ID_A, + picks: [{ node_id: 'new', mode: 'continue', ts: expect.any(String) }], + }, + ]); + }); + + it('lists both sources and filters each source, ordered by last recorded activity', async () => { + const first = { node_id: 'first', mode: 'continue', ts: EARLY }; + const middle = { node_id: 'middle', mode: 'fork', ts: MIDDLE }; + const last = { node_id: 'last', mode: 'fork', ts: LATE }; + await writeHistory('claude', ID_A, `${JSON.stringify(last)}\n${JSON.stringify(first)}\n`); + await writeHistory('codex', ID_A, `${JSON.stringify(first)}\n${JSON.stringify(last)}\n`); + await writeHistory('claude', ID_B, JSON.stringify(middle) + '\n'); + await writeHistory('unknown-source', ID_A, JSON.stringify(last) + '\n'); + await writeHistory('codex', ID_C, '\ninvalid-json\n{}\nnull\n'); + + const claudeA = { source: 'claude', sessionId: ID_A, picks: [last, first] }; + const claudeB = { source: 'claude', sessionId: ID_B, picks: [middle] }; + const codexA = { source: 'codex', sessionId: ID_A, picks: [first, last] }; + expect(await listAllPicks({ root })).toEqual([codexA, claudeB, claudeA]); + expect(await listAllPicks({ root, source: 'claude' })).toEqual([claudeB, claudeA]); + expect(await listAllPicks({ root, source: 'codex' })).toEqual([codexA]); + }); + + it('keeps best-effort reads for missing or non-file histories and absent sources', async () => { + const absentRoot = join(workdir, 'absent'); + expect(await listAllPicks({ root: absentRoot })).toEqual([]); + expect(await listAllPicks({ root })).toEqual([]); + expect(await readPicks(ID_A, { root, source: 'codex' })).toEqual({ + total: 0, + modesByNode: new Map(), + }); + expect(await removePicksForNode(ID_A, 'missing', { root, source: 'codex' })).toBe(0); + await mkdir(join(root, 'claude', `${ID_A}.jsonl`), { recursive: true }); + await writeFile(join(root, 'codex'), 'not a directory', 'utf8'); + expect(await listAllPicks({ root })).toEqual([]); + expect(await listAllPicks({ root: join(root, 'codex') })).toEqual([]); + expect(await readPicks(ID_A, { root })).toEqual({ total: 0, modesByNode: new Map() }); + expect(await removePicksForNode(ID_A, 'missing', { root })).toBe(0); + }); + + it('preserves malformed-line handling and keeps unrelated lines during removal', async () => { + const removed = { node_id: 'remove', mode: 'continue', ts: EARLY }; + const kept = { node_id: 'keep', mode: 'fork', ts: MIDDLE }; + const invalidMode = { node_id: 'invalid-mode', mode: 'other', ts: LATE }; + const file = await writeHistory( + 'codex', + ID_A, + [ + '', + 'not-json', + 'null', + '{}', + '[]', + JSON.stringify(removed), + ` ${JSON.stringify(kept)} `, + JSON.stringify(invalidMode), + '', + ].join('\n'), + ); + + expect(await readPicks(ID_A, { root, source: 'codex' })).toEqual({ + total: 2, + modesByNode: new Map([ + ['remove', new Set(['continue'])], + ['keep', new Set(['fork'])], + ]), + }); + // Listing retains the existing node_id-only check; indexing also checks mode. + expect(await listAllPicks({ root })).toEqual([ + { source: 'codex', sessionId: ID_A, picks: [removed, kept, invalidMode] }, + ]); + expect(await removePicksForNode(ID_A, 'remove', { root, source: 'codex' })).toBe(1); + expect(await readFile(file, 'utf8')).toBe( + ['not-json', 'null', '{}', '[]', JSON.stringify(kept), JSON.stringify(invalidMode), ''].join( + '\n', + ), + ); + expect(await readPicks(ID_A, { root, source: 'codex' })).toEqual({ + total: 1, + modesByNode: new Map([['keep', new Set(['fork'])]]), + }); + }); +}); + +describe('pick path validation and listing boundaries', () => { + it.each([ + '', + 'Claude', + 'unknown', + '../claude', + '/codex', + 'codex/../claude', + 'codex\\..', + 'codex\0', + null, + 1234, + ])('rejects runtime source %j in every API before accessing paths', async (invalid) => { + const opts = { root, source: invalid as SessionSourceId }; + await expect(recordPick(ID_A, 'node', 'continue', opts)).rejects.toThrow(/invalid source/); + await expect(readPicks(ID_A, opts)).rejects.toThrow(/invalid source/); + await expect(removePicksForNode(ID_A, 'node', opts)).rejects.toThrow(/invalid source/); + await expect(listAllPicks(opts)).rejects.toThrow(/invalid source/); + expect(await readdir(root)).toEqual([]); + }); + + it.each([ + '', + ' ', + 'abc', + 'a'.repeat(41), + '../abcd', + '/abcd', + 'abcd/efab', + 'abcd\\efab', + 'abcd.jsonl', + 'abcd\0', + null, + 1234, + ])('rejects runtime session identifier %j for both sources', async (invalid) => { + for (const source of ['claude', 'codex'] as const) { + const opts = { root, source }; + const sessionId = invalid as string; + await expect(recordPick(sessionId, 'node', 'continue', opts)).rejects.toThrow( + /invalid sessionId/, + ); + await expect(readPicks(sessionId, opts)).rejects.toThrow(/invalid sessionId/); + await expect(removePicksForNode(sessionId, 'node', opts)).rejects.toThrow( + /invalid sessionId/, + ); + } + expect(await readdir(root)).toEqual([]); + }); + + it('reads only valid session filenames and skips symlink files and nested directories', async () => { + const pick = { node_id: 'visible', mode: 'continue', ts: EARLY }; + const body = JSON.stringify(pick) + '\n'; + await writeHistory('claude', ID_A, body); + const directory = join(root, 'claude'); + for (const filename of [ + 'invalid.jsonl', + 'abc.jsonl', + `${'a'.repeat(41)}.jsonl`, + '.jsonl', + `${ID_A}.jsonl.tmp-1`, + `${ID_A}.JSONL`, + ]) { + await writeFile(join(directory, filename), body, 'utf8'); + } + const outside = join(workdir, 'outside.jsonl'); + await writeFile(outside, body, 'utf8'); + await symlink(outside, join(directory, `${ID_B}.jsonl`)); + await mkdir(join(directory, `${ID_C}.jsonl`)); + await writeFile(join(directory, `${ID_C}.jsonl`, `${ID_A}.jsonl`), body, 'utf8'); + await symlink(join(workdir, 'missing'), join(directory, 'dddd.jsonl')); + await symlink(join(directory, `${ID_C}.jsonl`), join(directory, 'eeee.jsonl'), 'dir'); + + expect(await listAllPicks({ root })).toEqual([ + { source: 'claude', sessionId: ID_A, picks: [pick] }, + ]); + }); + + it('does not traverse symlink source directories or a symlink root', async () => { + const pick = { node_id: 'visible', mode: 'fork', ts: EARLY }; + const body = JSON.stringify(pick) + '\n'; + await writeHistory('claude', ID_A, body); + const outside = join(workdir, 'outside'); + await mkdir(outside); + await writeFile(join(outside, `${ID_B}.jsonl`), body, 'utf8'); + await symlink(outside, join(root, 'codex'), 'dir'); + const linkedRoot = join(workdir, 'linked-picks'); + await symlink(root, linkedRoot, 'dir'); + + expect(await listAllPicks({ root })).toEqual([ + { source: 'claude', sessionId: ID_A, picks: [pick] }, + ]); + expect(await listAllPicks({ root, source: 'codex' })).toEqual([]); + expect(await listAllPicks({ root: linkedRoot })).toEqual([]); + }); +}); diff --git a/tests/security-hardening.test.ts b/tests/security-hardening.test.ts index 3fe0ade..b15f33b 100644 --- a/tests/security-hardening.test.ts +++ b/tests/security-hardening.test.ts @@ -218,7 +218,7 @@ describe('picks — sessionId validator + atomic delete', () => { expect(after.total).toBe(1); // The implementation writes a `.tmp-…` sibling then renames; after the // rename completes there must be no leftover tmp file. - const file = join(root, `${sid}.jsonl`); + const file = join(root, 'claude', `${sid}.jsonl`); const dir = dirname(file); const { readdir } = await import('node:fs/promises'); const entries = await readdir(dir); diff --git a/tests/session-path.test.ts b/tests/session-path.test.ts index cf68fce..ed31618 100644 --- a/tests/session-path.test.ts +++ b/tests/session-path.test.ts @@ -407,6 +407,16 @@ describe('sessionFromFile', () => { }); }); + it('detects a BOM-prefixed header consistently with the reader', async () => { + const file = makeSession( + '-project', + ID_A, + TIME, + String.fromCharCode(0xfeff) + '{"type":"permission-mode","sessionId":"' + ID_A + '"}\n', + ); + expect((await sessionFromFile(file)).source).toBe('claude'); + }); + it.each(['user', 'assistant'])( 'detects Claude from a %s event after blank, invalid and unrecognized lines', async (role) => { diff --git a/tests/source-redaction.test.ts b/tests/source-redaction.test.ts new file mode 100644 index 0000000..918d823 --- /dev/null +++ b/tests/source-redaction.test.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { runPipeline } from '../src/cli/pipeline.js'; +import { DEFAULT_CONFIG } from '../src/config/schema.js'; +import { renderTextTree } from '../src/render/text.js'; +import { sessionFromFile } from '../src/utils/session_path.js'; +import { createLoggerSync } from '../src/utils/logger.js'; + +const directories: string[] = []; +afterEach(async () => { + await Promise.all(directories.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +// Long credentials used as tool/path metadata must be redacted before labels +// are truncated; output-boundary redaction cannot recognize a sliced key. +describe('source-independent tool metadata redaction', () => { + it.each(['claude', 'codex'] as const)( + 'redacts %s tool labels before shortening', + async (source) => { + const root = await mkdtemp(join(tmpdir(), 'atree-source-redaction-')); + directories.push(root); + const file = join(root, 'export.jsonl'); + const secret = 'github_pat_' + 'Z'.repeat(82); + const id = 'aaaaaaaa-0000-4000-8000-000000000001'; + const timestamp = '2026-10-09T00:00:00Z'; + const records = + source === 'claude' + ? [ + { type: 'permission-mode', sessionId: id }, + { + type: 'tool_use', + uuid: 'event-1', + sessionId: id, + timestamp, + tool_use: { id: 'call-1', name: secret, input: { path: `/work/${secret}.ts` } }, + }, + ] + : [ + { type: 'session_meta', timestamp, payload: { id, cwd: '/work', source: 'cli' } }, + { + type: 'response_item', + timestamp, + payload: { + type: 'function_call', + name: secret, + call_id: 'call-1', + arguments: JSON.stringify({ path: `/work/${secret}.ts` }), + }, + }, + ]; + await writeFile(file, records.map((record) => JSON.stringify(record)).join('\n')); + const result = await runPipeline({ + match: await sessionFromFile(file), + opts: { llm: false, dryRun: true, strict: true }, + config: DEFAULT_CONFIG, + logger: createLoggerSync('error'), + quiet: true, + }); + expect(result.mindmap.root.children.length).toBeGreaterThan(0); + const text = renderTextTree(result.mindmap, { color: false }).text; + expect(text).not.toContain('Z'.repeat(12)); + expect(text).toContain('REDACTED'); + }, + ); +}); From 022a29d14da531af8b9f34a08fee62e572f0e9b2 Mon Sep 17 00:00:00 2001 From: SEUNGWOO LEE <69357689+lifrary@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:49:52 +0900 Subject: [PATCH 3/3] release: prepare 0.3.0 with Codex documentation and bundles --- .claude-plugin/marketplace.json | 19 +- .claude-plugin/plugin.json | 15 +- .claude/commands/mcp-smoke.md | 6 + CHANGELOG.md | 38 +- CONTRIBUTING.md | 28 +- README.md | 70 +- RELEASING.md | 33 +- dist/cli.js | 1079 ++++++++++++++++++++++++------- dist/mcp-server.js | 1055 +++++++++++++++++++++++------- package-lock.json | 4 +- package.json | 5 +- skills/agent-tree/SKILL.md | 59 +- 12 files changed, 1853 insertions(+), 558 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 5afb4e4..31f8058 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -4,14 +4,14 @@ "name": "Seungwoo Lee" }, "metadata": { - "description": "Single-plugin marketplace for agent-tree. Navigate any Claude Code session as a numbered tree and resume from any node.", - "version": "0.2.1" + "description": "Single-plugin marketplace for agent-tree. Navigate Claude Code and Codex sessions and generate resume prompts.", + "version": "0.3.0" }, "plugins": [ { "name": "agent-tree", - "description": "Visualize a Claude Code session as a numbered tree in your terminal/chat and resume from any node. Terminal-only — no browser.", - "version": "0.2.1", + "description": "Navigate Claude Code and Codex sessions as numbered trees and generate resume prompts. Terminal-only — no browser.", + "version": "0.3.0", "author": { "name": "Seungwoo Lee" }, @@ -20,7 +20,16 @@ "homepage": "https://github.com/lifrary/agent-tree", "repository": "https://github.com/lifrary/agent-tree", "license": "MIT", - "keywords": ["claude", "claude-code", "session", "mindmap", "resume", "fork", "navigation"] + "keywords": [ + "claude", + "claude-code", + "codex", + "session", + "mindmap", + "resume", + "fork", + "navigation" + ] } ] } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 27d030b..3e6ee9c 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,14 +1,23 @@ { "name": "agent-tree", - "version": "0.2.1", - "description": "Visualize a Claude Code session as a numbered tree in your terminal/chat and resume from any node. Terminal-only — no browser.", + "version": "0.3.0", + "description": "Navigate Claude Code and Codex sessions as numbered trees and generate resume prompts. Terminal-only — no browser.", "author": { "name": "Seungwoo Lee" }, "repository": "https://github.com/lifrary/agent-tree", "homepage": "https://github.com/lifrary/agent-tree", "license": "MIT", - "keywords": ["claude", "claude-code", "session", "mindmap", "resume", "fork", "navigation"], + "keywords": [ + "claude", + "claude-code", + "codex", + "session", + "mindmap", + "resume", + "fork", + "navigation" + ], "skills": "./skills/", "mcpServers": { "agent-tree": { diff --git a/.claude/commands/mcp-smoke.md b/.claude/commands/mcp-smoke.md index bea67f0..5c3203e 100644 --- a/.claude/commands/mcp-smoke.md +++ b/.claude/commands/mcp-smoke.md @@ -7,6 +7,12 @@ You are running the agent-tree post-publish MCP smoke test. The goal: prove that Run this **after `npm publish`** has succeeded. It installs from the registry, not from local source. +The handshake below checks tool presence, not source behavior. Before release, +also run `npm run smoke:release`: its installed-tarball checks cover Claude and +Codex discovery, portable imports, source schemas, and redacted JSON output. +All six tools accept optional `source: "claude" | "codex"`; picks without a +source filter span both. + > **Fixes from Loop 1 + Loop 2 + Loop 3 reviews**: > > - **Spec-compliant MCP handshake** (Loop 1): the previous version sent diff --git a/CHANGELOG.md b/CHANGELOG.md index 9af1836..ee2f09c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,43 @@ All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/) and this project adheres to [Semantic Versioning](https://semver.org/). -## [Unreleased] — 0.2.1 +## [Unreleased] — 0.3.0 + +### Added + +- A session-source interface separating discovery and log normalization from + analysis, rendering, configuration, and MCP transport. +- Codex rollout support via CLI `--source codex` and MCP `source: "codex"`. + `CODEX_HOME` controls discovery, project filtering uses metadata cwd, and + portable `--file` imports detect Claude Code or Codex automatically. +- Codex message/tool normalization, apply-patch file paths, public reasoning + summaries, compaction markers, duplicate-notification suppression, and + deterministic event identities. Thread IDs remain distinct from root + session IDs. Discovery excludes subagent sessions and symlink entries. +- Source identity in JSON mindmaps and catalogs; source-preserving CLI + snapshot hints and optional source filters for pick history. +- Synthetic adapter, CLI, MCP, and installed-tarball regressions for both + sources. No private rollout fixtures are distributed. + +### Fixed + +- Tool and file labels redact credentials before truncation, preventing + partial tokens from surviving shorter heuristic labels. +- Interactive picker and discovery fallback messages apply output redaction. + +### Breaking + +- Pick history is now stored at + `~/.cache/agent-tree/picks//.jsonl`. Flat-directory + history is neither read nor migrated; Claude and Codex UUIDs cannot share + or remove one another's stars. +- Nonempty unrecognized imports require an explicit source instead of + silently producing an empty Claude tree. + +This release also includes the previously prepared 0.2.1 changes below. +Publishing is separate from preparing or pushing these versions. + +## [0.2.1] — Unreleased preparation ### Toolchain and release verification diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a6bfb8c..7bf29a0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,7 +13,7 @@ npm test # Vitest 5 — must stay green Requirements: -- **Node.js ≥22.13.0** for the upcoming 0.2.1 release. +- **Node.js ≥22.13.0**. - **macOS or Linux** for the full smoke loop. Windows works for the CLI; clipboard / git subprocess paths are platform-shimmed but less exercised. - CI covers **Node 22, 24, and 26 on Linux and macOS**. - **ESLint 10** uses flat config; **Vitest 5** runs the tests. @@ -57,7 +57,7 @@ If you touch any of these, add a test in `tests/security-hardening.test.ts` (or - `src/utils/picks.ts` — concurrency / atomicity changes - `src/utils/safe_path.ts` — path-trust hardening - `src/mcp/server.ts` — any new tool, session resolution, or analysis cache change -- `src/utils/session_path.ts` / `src/reader/jsonl.ts` — discovery, portable imports, or malformed-input handling +- `src/sources/`, `src/utils/session_path.ts`, and `src/reader/` — discovery, portable imports, or malformed-input handling - `src/config/loader.ts` / `src/config/schema.ts` — precedence, per-field validation, or security settings - `src/llm/labeler.ts` / `src/llm/anthropic.ts` — input reservations, token counting, or paid-request handling @@ -65,7 +65,24 @@ The reason: redaction and path-safety bugs are silent failures that bypass every ### Routing through canonical helpers -If you add a new code path that analyzes a session, route it through `runPipeline` (MCP uses the analysis/cache helper inside `createServer`). Do **not** call `buildMindMap` directly — it bypasses the pipeline's redaction policy. v0.1.0 had this exact bug for three MCP tools. Catalog discovery is intentionally parse-free, but catalog strings still need redaction before output. +If you add a new code path that analyzes a session, route it through `runPipeline` (MCP uses the analysis/cache helper inside `createServer`). Do **not** call `buildMindMap` directly — it bypasses the pipeline's redaction policy. v0.1.0 had this exact bug for three MCP tools. Claude catalogs are parse-free; Codex catalogs read bounded metadata headers, never analyze the conversation. Catalog strings still need redaction before output. + +### Adding a session source + +Implement `SessionSource` from `src/sources/types.ts`: `discover`, positive +record identification via `accepts`, and `read` into normalized `RawEvent[]`. +Register it in `src/sources/index.ts`, extend the source ID type and CLI/MCP +choices, and include it in source-isolated pick enumeration. Keep log-format +knowledge inside the adapter/reader, not in tree rendering. Unknown formats +must not silently become empty Claude sessions. Test discovery races, metadata +limits, malformed records, duplicate notifications, and end-to-end exports +using synthetic fixtures. Never commit private rollout logs. + +Discovery skips symlink entries and discards detected directory replacements. +These checks are not a filesystem sandbox against another process with write +access to the session directories: portable Node filesystem APIs do not pin +every ancestor during path traversal. Treat imported metadata as untrusted, +retain output redaction, and never execute commands from a transcript. ### Keep the public contracts aligned @@ -73,8 +90,9 @@ If you add a new code path that analyzes a session, route it through `runPipelin There are six tools: `agent_tree_sessions`, `agent_tree_list`, `agent_tree_snapshot`, `agent_tree_picks`, `agent_tree_diff`, and `agent_tree_unstar`. All per-session tools accept `file` or `sessionId`, - never both; picks takes `{}`. MCP remains heuristic-only. -- Cover portable exports, `CLAUDE_CONFIG_DIR`, UUID-only regular-file + never both. All tools accept optional `source`; picks can filter by source + or list both. MCP remains heuristic-only. +- Cover portable exports, `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, regular-file discovery (no agent/subagent or symlink entries), strict parse failures, JSON redaction/no stdout banners, and incompatible CLI flags (exit 2). JSON is the complete mindmap, not a filtered text view. diff --git a/README.md b/README.md index a02ab36..40b16c7 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # agent-tree -**Navigate any Claude Code session as a numbered tree — and pick up from any point in it.** +**Navigate Claude Code and Codex sessions as numbered trees — and pick up from any point.** [![npm](https://img.shields.io/npm/v/@seungwoolee/agent-tree.svg?color=cb3837&label=npm)](https://www.npmjs.com/package/@seungwoolee/agent-tree) [![CI](https://github.com/lifrary/agent-tree/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/lifrary/agent-tree/actions/workflows/ci.yml) @@ -21,7 +21,7 @@ A long Claude Code session is hard to look back on. Somewhere in hundreds of messages is the moment right before things went sideways, and scrolling the transcript won't find it for you. -**agent-tree** reads the session log Claude Code already keeps and turns it into a numbered outline: every prompt you typed, with the reads, edits and commands it triggered underneath. Every numbered row is a step. Pick one and you get a ready-to-paste prompt that carries on from that moment in a fresh session — or tries it another way. +**agent-tree** reads the session logs Claude Code and Codex already keep and turns them into numbered outlines: every prompt you typed, with the reads, edits and commands it triggered underneath. Every numbered row is a step. Pick one and you get a ready-to-paste prompt that carries on from that moment in a fresh session — or tries it another way. @@ -78,7 +78,7 @@ API keys, tokens and card numbers are stripped before anything is shown, copied npm install -g @seungwoolee/agent-tree ``` -The npm release needs Node.js 20 or later. To use what is on `main` right now, including everything in [What's new in 0.2](#whats-new-in-02), install from source instead. `dist/` is committed, so there is no build step: +The published 0.1.x release needs Node.js 20 or later. Source builds need Node.js 22.13 or later and include [Codex support](#codex-sessions). Until the next npm release, install from source. `dist/` is committed, so there is no build step: ```bash git clone https://github.com/lifrary/agent-tree @@ -113,9 +113,49 @@ agent-tree 3f9c2a71 --list # a specific session, by ID prefix If `ANTHROPIC_API_KEY` is set, agent-tree also asks Claude for short step labels, which bills your Anthropic account. Add `--no-llm` to skip that. `agent-tree --help` lists every option. +## Codex sessions + +Discovery defaults to Claude Code. Select Codex explicitly, or import a file +and let agent-tree detect its format: + +```bash +agent-tree --source codex --sessions --limit 10 +agent-tree --source codex --cwd ~/Code/api --no-llm --list +agent-tree --source codex 3f9c2a71 --no-llm --snapshot 7 --mode fork +agent-tree --file ./rollout.jsonl --no-llm --strict --json +agent-tree --source codex --picks +``` + +Codex discovery reads metadata headers from +`/sessions/YYYY/MM/DD/rollout--.jsonl`. +It excludes subagent sessions and symlinks; explicit `--file` can inspect a +subagent export. `--cwd` matches the working directory recorded in the header. +Archived sessions are not scanned; open those with `--file`. + +Messages, tool calls/results, patch file paths, and public reasoning summaries +are normalized into the same analysis pipeline as Claude logs. Duplicate +`event_msg` message notifications are not counted again. Compaction summaries +are retained as system events; replacement history is not replayed. Encrypted +reasoning is not decoded. A rollout is a chronological log, not a reconstructed +cross-session fork tree. + +Continue/fork output is a **copy-paste context prompt**, not a native +`codex resume`/`codex fork` operation, and does not restore files. Claude and +Codex stars are isolated under +`~/.cache/agent-tree/picks//.jsonl`. Old flat-directory +star history is not read or migrated. + +JSON mindmaps and catalog entries identify their `source`. +All MCP tools accept optional `source: "claude" | "codex"`. Omit it for +Claude discovery or automatic file detection; `agent_tree_picks({})` lists +both sources. An explicitly selected source that disagrees with a recognized +file header is rejected. For unknown/headerless exports, specify the source. + ## What's new in 0.2 -0.2.1 (October 2026) is on `main` and ships to npm with the next release; [install from source](#install) to use it today. +The 0.2.1 preparation work (October 2026) is included in the current source +alongside Codex support. npm publication is still pending; +[install from source](#install) to use it today. - **Browse sessions across projects.** `agent-tree --sessions` lists your recent sessions instantly, without opening them. Claude gets the same view through the new `agent_tree_sessions` tool. - **Open any session file.** `--file` reads a session log from anywhere, such as one a teammate exported, and `--cwd` points at another project without changing directory. @@ -268,7 +308,8 @@ flowchart LR | `` | smart default | UUID or prefix (4+ characters; 8+ is safer) | | `--latest` | — | the most recent session across all projects | | `--pick` | — | choose from recent sessions interactively | -| `--file ` | — | read a Claude Code JSONL file directly (new in 0.2) | +| `--file ` | — | read a Claude Code or Codex JSONL file; auto-detect its source | +| `--source ` | Claude discovery; automatic file detection | select the session source | | `--cwd ` | current directory | project for discovery and `.agent-tree.yaml` (new in 0.2) | | `--sessions` | off | list sessions without analyzing them; all projects unless `--cwd` (new in 0.2) | | `--limit ` | `20` | how many sessions `--sessions` lists (new in 0.2) | @@ -349,10 +390,10 @@ If you are an LLM agent who was handed this repository, the notes below give you ```text package @seungwoolee/agent-tree bins: agent-tree, atree -versions npm 0.1.2 | main 0.2.1 (this README describes main) +versions npm 0.1.2 | main 0.3.0 (this README describes main) runtime Node.js ≥ 22.13 on main, ≥ 20 for 0.1.x -input /projects//.jsonl, or --file -output text tree, markdown resume prompts, JSON; MCP tools over stdio +input Claude projects or Codex rollouts; --source selects discovery, --file auto-detects +output text tree, markdown resume prompts, source-tagged JSON; MCP tools over stdio ``` Self-test in an isolated directory: @@ -386,8 +427,9 @@ Bare `npx -y @seungwoolee/agent-tree …` works with npm 12, but npm 10 could no MCP inputs are validated with zod. Success returns `{ "content": [{ "type": "text", "text": "…" }] }`; failure adds `"isError": true`. Catalog results also carry `structuredContent: { sessions: [...] }`, and JSON list results carry `structuredContent: { mindmap: {...} }`. Per-session tools take `sessionId` or `file`, never both, and fall back to the latest session in `cwd`, then the latest overall. `agent_tree_sessions`, `file` and `format` are new in 0.2. ```jsonc -// agent_tree_sessions: list sessions without parsing them, newest first -{ "cwd": "string?", "limit": 20 } // limit: integer 1–1000 +// All tools accept optional source: "claude" | "codex". +// agent_tree_sessions: newest first; Codex reads metadata headers only +{ "cwd": "string?", "source": "codex", "limit": 20 } // limit: integer 1–1000 // agent_tree_list: numbered tree; format "json" returns the full redacted mindmap { "cwd": "string", "sessionId": "string?", "file": "string?", @@ -403,8 +445,8 @@ MCP inputs are validated with zod. Success returns `{ "content": [{ "type": "tex // agent_tree_unstar: remove a step's star { "cwd": "string", "nodeId": "string", "sessionId": "string?", "file": "string?" } -// agent_tree_picks: every star across sessions; takes no arguments -{} +// agent_tree_picks: every star across sources; optional source filter +{ "source": "codex" } ``` `format: "json"` rejects a nonempty `filter` and `phasesOnly: true`. The canonical definitions live in [`src/mcp/server.ts`](./src/mcp/server.ts), and the skill Claude Code loads with the plugin is [`skills/agent-tree/SKILL.md`](./skills/agent-tree/SKILL.md). @@ -416,8 +458,8 @@ MCP inputs are validated with zod. Success returns `{ "content": [{ "type": "tex - [x] **0.1** (April 2026): numbered session tree, continue and fork resume prompts, stars, and a Claude Code plugin with five MCP tools - [x] **0.2** (October 2026, on `main`): session catalog, portable session files, JSON export, layered configuration, and support for the current Claude Code log format - [ ] Publish 0.2 to npm -- [ ] A session-source interface, so logs from other coding agents can plug in -- [ ] Codex CLI sessions +- [x] A session-source interface, so logs from other coding agents can plug in +- [x] Codex CLI sessions - [ ] Gemini CLI sessions Have an idea or hit a bug? [Open an issue](https://github.com/lifrary/agent-tree/issues). diff --git a/RELEASING.md b/RELEASING.md index 00b22f0..c16d338 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -3,7 +3,7 @@ This is the v0.X.Y → v0.X.Y+1 (or v0.X+1.0) checklist. Captures the sequence that shipped v0.1.0 so the next release doesn't re-discover it. -The next release is **0.2.1 (unreleased)**. Updating the repository does not +The next release is **0.3.0 (unreleased)**. Updating the repository does not publish to npm; registry and GitHub release steps below are a separate maintainer action after release approval. @@ -34,13 +34,13 @@ post-publish smoke test, MCP plugin re-install). ### 1. Bump version everywhere -For 0.2.1, synchronize these editable version fields: +For 0.3.0, synchronize these editable version fields: -- `package.json` → `"version": "0.2.1"` -- `.claude-plugin/plugin.json` → `"version": "0.2.1"` +- `package.json` → `"version": "0.3.0"` +- `.claude-plugin/plugin.json` → `"version": "0.3.0"` - `.claude-plugin/marketplace.json` → **both** `metadata.version` AND - `plugins[0].version` → `"0.2.1"` -- `skills/agent-tree/SKILL.md` → frontmatter `version: 0.2.1` + `plugins[0].version` → `"0.3.0"` +- `skills/agent-tree/SKILL.md` → frontmatter `version: 0.3.0` - Refresh `package-lock.json` so its root package versions match. CLI and MCP both use `src/version.ts`, which reads `package.json` in source @@ -59,14 +59,14 @@ identity/runtime guidance and `.claude/commands/` audit expectations too. > ```bash > grep -nE '"version": "0\.[0-9]+\.[0-9]+"' package.json .claude-plugin/*.json > grep -nE '^version: 0\.[0-9]+\.[0-9]+' skills/agent-tree/SKILL.md -> # All five editable fields above must show 0.2.1 for this release. +> # All five editable fields above must show 0.3.0 for this release. > node -e 'const p=require("./package-lock.json"); console.log(p.version, p.packages[""].version)' > # Both lockfile root versions must also match. > ``` ### 2. Update CHANGELOG.md -- Move `## [Unreleased] — 0.2.1` content → `## [v0.2.1] — YYYY-MM-DD` +- Move `## [Unreleased] — 0.3.0` content → `## [v0.3.0] — YYYY-MM-DD` - Add a fresh empty `## [Unreleased]` at the top - Keep already-published historical entries intact. Do not promote the unreleased section merely because a preparation commit was pushed. @@ -84,10 +84,15 @@ Review the release contract before publishing: packs the local project, installs the tarball into a temporary directory, and exercises the installed CLI and MCP server. Neither command publishes. -- `--file` imports portable Claude Code JSONL; `--cwd` controls discovery and - project config; `CLAUDE_CONFIG_DIR` changes the Claude session root. +- `--file` auto-detects Claude Code or Codex JSONL; `--source` selects discovery + (default Claude) or validates an explicit import. `--cwd` controls project + discovery/config. `CLAUDE_CONFIG_DIR` and `CODEX_HOME` select source roots. - `--sessions --limit 20 --json` returns a redacted `{ "sessions": [...] }` - catalog across all projects unless `--cwd` restricts it. + catalog for the selected source across projects unless `--cwd` restricts it. + Codex reads bounded metadata headers and excludes subagents/symlinks. +- The Codex fixture exercises messages, tool calls/results, duplicate + notifications, patch paths and compaction. Verify source-isolated stars + and that resume hints retain `--source codex`. - `--file --no-llm --strict --json` emits only a complete redacted mindmap on stdout. Malformed strict input must fail; incompatible modes/selectors/JSON display filters must exit 2. @@ -161,7 +166,7 @@ npm publish --access public --otp=NNNNNN ```bash # Run only after this exact version has been published. -VERSION=0.2.1 +VERSION=0.3.0 export VERSION SMOKE_DIR=$(mktemp -d) cd "$SMOKE_DIR" @@ -229,7 +234,9 @@ try { assert.equal(catalog.limit.minimum, 1); assert.equal(catalog.limit.maximum, 1000); assert.equal(catalog.cwd.type, 'string'); - assert.deepEqual(schemas.agent_tree_picks.properties ?? {}, {}); + for (const schema of Object.values(schemas)) { + assert.deepEqual(schema.properties.source.enum, ['claude', 'codex']); + } for (const name of ['list', 'snapshot', 'diff', 'unstar']) { const schema = schemas[`agent_tree_${name}`]; assert.equal(schema.properties.file.type, 'string'); diff --git a/dist/cli.js b/dist/cli.js index 73449a7..2d61800 100755 --- a/dist/cli.js +++ b/dist/cli.js @@ -3337,7 +3337,7 @@ var init_js_yaml = __esm({ }); // src/cli.ts -import { resolve as resolve5 } from "node:path"; +import { resolve as resolve7 } from "node:path"; import { stat } from "node:fs/promises"; // node_modules/commander/lib/error.js @@ -6709,14 +6709,19 @@ function useColor() { var program = new Command(); // src/version.ts -var VERSION = true ? "0.2.1" : JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version; +var VERSION = true ? "0.3.0" : JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version; // src/cli/options.ts function parseCliArgs(argv) { const program2 = new Command(); program2.name("agent-tree").description( - "Navigate a Claude Code session as a numbered file-tree in your terminal and resume from any node." - ).version(VERSION, "-V, --version", "print agent-tree version").argument("[session-id]", "session UUID or short prefix (e.g. 69c2f35e)").option("--latest", "use the most recently modified session").option("--pick", "interactive picker over recent sessions").option("--file ", "read an exported Claude Code JSONL file directly").option("--cwd ", "project directory for session discovery and configuration").option("--sessions", "list recent sessions without analyzing their contents").option("--limit ", "maximum sessions to list (default: 20)", positiveInteger).option("--json", "emit a redacted mindmap or session catalog as JSON").option("--strict", "reject malformed JSONL instead of skipping invalid lines").option("--no-llm", "skip LLM labeling and run heuristic-only").option( + "Navigate Claude Code or Codex sessions as numbered trees and generate resume prompts." + ).version(VERSION, "-V, --version", "print agent-tree version").argument("[session-id]", "session UUID or short prefix (e.g. 69c2f35e)").option("--latest", "use the most recently modified session").option("--pick", "interactive picker over recent sessions").option("--file ", "read a Claude Code or Codex JSONL file (auto-detected)").addOption( + new Option("--source ", "session source (discovery: claude; file: auto)").choices([ + "claude", + "codex" + ]) + ).option("--cwd ", "project directory for session discovery and configuration").option("--sessions", "list recent sessions without analyzing their contents").option("--limit ", "maximum sessions to list (default: 20)", positiveInteger).option("--json", "emit a redacted mindmap or session catalog as JSON").option("--strict", "reject malformed JSONL instead of skipping invalid lines").option("--no-llm", "skip LLM labeling and run heuristic-only").option( "--dump-json ", "dump intermediate artifacts (raw events / graph / segments / tree) as JSON" ).option("-v, --verbose", "debug logging").option("--trace", "trace logging (implies --verbose)").option("--dry-run", "run the analysis pipeline but do not emit any output").option("--model ", "Anthropic model for LLM labeling").option("--max-llm-tokens ", "input token budget ceiling across segments", positiveInteger).option("--redact-strict", "add PII patterns (email/phone/SSN/RRN); card check is always on").option("--redact-dryrun", "print redaction hit counts to stderr").option("--include-sidechains", "keep sidechain segments as a branch (default)").option("--flatten-sidechains", "merge sidechains into main tree").option("--drop-sidechains", "omit sidechain events entirely").option("--list", "print numbered ASCII tree to stdout (skill-friendly)").option("--snapshot ", "print single node's snapshot markdown to stdout").addOption(new Option("--mode ", "snapshot mode").choices(["continue", "fork"])).option("--tui", "interactive readline prompt with numbered selection").option( @@ -7087,7 +7092,7 @@ function topN(counts, n) { } // src/cli/pipeline.ts -import { createHash as createHash2 } from "node:crypto"; +import { createHash as createHash3 } from "node:crypto"; // src/cache/disk.ts import { createHash } from "node:crypto"; @@ -7379,7 +7384,7 @@ function errMsg(err) { return status === null ? "network or client error" : `HTTP ${status}`; } function defaultSleep(ms) { - return new Promise((resolve6) => setTimeout(resolve6, ms)); + return new Promise((resolve8) => setTimeout(resolve8, ms)); } // src/utils/git.ts @@ -7408,7 +7413,7 @@ async function getGitContext(cwd) { }; } function runGit(cwd, args) { - return new Promise((resolve6) => { + return new Promise((resolve8) => { let settled = false; const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"] }); let stdout = ""; @@ -7420,7 +7425,7 @@ function runGit(cwd, args) { child.kill(); } catch { } - resolve6({ ok: false, stdout: "" }); + resolve8({ ok: false, stdout: "" }); }, TIMEOUT_MS); child.stdout?.on("data", (chunk) => { if (capped) return; @@ -7438,13 +7443,13 @@ function runGit(cwd, args) { if (settled) return; settled = true; clearTimeout(timer); - resolve6({ ok: false, stdout: "" }); + resolve8({ ok: false, stdout: "" }); }); child.on("close", (code) => { if (settled) return; settled = true; clearTimeout(timer); - resolve6({ ok: capped || code === 0, stdout }); + resolve8({ ok: capped || code === 0, stdout }); }); }); } @@ -7935,6 +7940,13 @@ function applyResult(entry, res, graph, jsonlPath, redactor, stats, logger) { entry.node.context_snapshot_fork = buildForkSnapshot(snapInput); } +// src/sources/index.ts +import { open as open2 } from "node:fs/promises"; + +// src/sources/claude.ts +import { homedir as homedir2 } from "node:os"; +import { join as join2, resolve as resolve2 } from "node:path"; + // src/reader/jsonl.ts import { createReadStream as createReadStream2 } from "node:fs"; import { createInterface } from "node:readline"; @@ -8216,6 +8228,606 @@ function coerceRawEvent(obj, invalid) { } } +// src/sources/files.ts +import { lstat, readdir } from "node:fs/promises"; +var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +function isFullUuid(value) { + return UUID_RE.test(value); +} +function isUuidPrefix(value) { + return /^[0-9a-f-]+$/i.test(value) && value.length >= 4 && value.length <= 36; +} +function isMissingPath(error62) { + const code = error62?.code; + return code === "ENOENT" || code === "ENOTDIR"; +} +async function lstatIfPresent(path2) { + try { + return await lstat(path2); + } catch (error62) { + if (isMissingPath(error62)) return null; + throw error62; + } +} +async function readDirectory(path2) { + try { + return await readdir(path2, { withFileTypes: true }); + } catch (error62) { + if (isMissingPath(error62)) return []; + throw error62; + } +} +async function isSameDirectory(path2, original) { + const current = await lstatIfPresent(path2); + return current !== null && current.isDirectory() && current.dev === original.dev && current.ino === original.ino; +} + +// src/sources/claude.ts +function getProjectsRoot() { + return resolve2(process.env.CLAUDE_CONFIG_DIR || join2(homedir2(), ".claude"), "projects"); +} +function encodeProjectPath(absPath) { + return absPath.replace(/[^a-zA-Z0-9]/g, "-"); +} +async function discover(opts) { + const root = resolve2(opts.root ?? getProjectsRoot()); + const rootInfo = await lstatIfPresent(root); + if (!rootInfo?.isDirectory()) return []; + const projectDirs = opts.projectCwd === void 0 ? (await readDirectory(root)).filter((entry) => entry.isDirectory()).map((entry) => entry.name) : [encodeProjectPath(opts.projectCwd)]; + const sessions = []; + for (const projectDir of projectDirs) { + if (!projectDir) continue; + const projectPath = join2(root, projectDir); + const projectInfo = await lstatIfPresent(projectPath); + if (!projectInfo?.isDirectory()) continue; + const projectSessions = []; + for (const file2 of await readDirectory(projectPath)) { + if (!file2.isFile() || !file2.name.endsWith(".jsonl")) continue; + const sessionId = file2.name.slice(0, -".jsonl".length); + if (!isFullUuid(sessionId)) continue; + const jsonlPath = join2(projectPath, file2.name); + const info = await lstatIfPresent(jsonlPath); + if (!info?.isFile()) continue; + projectSessions.push({ + source: "claude", + sessionId, + projectDir, + jsonlPath, + mtimeMs: info.mtimeMs, + sizeBytes: info.size + }); + } + if (await isSameDirectory(projectPath, projectInfo)) { + for (const session of projectSessions) sessions.push(session); + } + } + return await isSameDirectory(root, rootInfo) ? sessions : []; +} +var claudeSource = { + id: "claude", + discover, + accepts: (record2) => typeof record2.uuid === "string" && typeof record2.type === "string" || record2.type === "permission-mode" || typeof record2.sessionId === "string" && typeof record2.type === "string", + read: readJsonl +}; + +// src/sources/codex.ts +import { constants } from "node:fs"; +import { open } from "node:fs/promises"; +import { homedir as homedir3 } from "node:os"; +import { join as join3, resolve as resolve3 } from "node:path"; + +// src/reader/codex.ts +import { createHash as createHash2 } from "node:crypto"; +import { createReadStream as createReadStream3 } from "node:fs"; +import { createInterface as createInterface2 } from "node:readline"; +function isRecord2(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function textField(obj, key, invalid, required2 = true) { + const value = obj[key]; + if (typeof value === "string") return value; + if (required2 || value !== void 0) invalid(`${key} must be a string`); + return void 0; +} +function identifier(obj, key, invalid) { + const value = textField(obj, key, invalid); + if (value !== void 0 && !value.trim()) { + invalid(`${key} must be nonempty`); + return void 0; + } + return value; +} +function publicText(value, allowed, invalid) { + if (!Array.isArray(value)) { + invalid("content must be an array"); + return []; + } + const blocks = []; + for (const block of value) { + if (!isRecord2(block) || typeof block.type !== "string" || !block.type.trim()) { + invalid("content blocks require a nonempty string type"); + continue; + } + if (!allowed.includes(block.type)) continue; + const text = textField(block, "text", invalid); + if (text !== void 0) blocks.push({ type: "text", text }); + } + return blocks; +} +function blockText(blocks) { + return blocks.map((block) => "text" in block && typeof block.text === "string" ? block.text : "").join("\n"); +} +function patchInput(name, input2) { + if (name.split(".").at(-1) !== "apply_patch") return input2; + const object2 = isRecord2(input2) ? input2 : void 0; + const patch = typeof input2 === "string" ? input2 : object2?.input ?? object2?.patch; + if (typeof patch !== "string") return input2; + const paths = /* @__PURE__ */ new Set(); + if (Array.isArray(object2?.paths)) { + for (const path2 of object2.paths) if (typeof path2 === "string") paths.add(path2); + } + for (const line of patch.split("\n")) { + const match = /^\*\*\* (?:(?:Add|Update|Delete) File|Move to): (.+?)\r?$/.exec(line); + if (match && match[1].trim()) paths.add(match[1].trim()); + } + return { ...object2 ?? { input: patch }, paths: [...paths] }; +} +function toolCall(payload, invalid) { + const name = identifier(payload, "name", invalid); + const id = identifier(payload, "call_id", invalid); + let input2; + if (payload.type === "function_call") { + const argumentsText = textField(payload, "arguments", invalid); + if (argumentsText === void 0) return null; + try { + input2 = JSON.parse(argumentsText); + } catch { + invalid("arguments must contain valid JSON"); + return null; + } + } else { + input2 = textField(payload, "input", invalid); + if (input2 === void 0) return null; + } + return name && id ? { type: "tool_use", tool_use: { id, name, input: patchInput(name, input2) } } : null; +} +function localShell(payload, invalid) { + const id = identifier(payload, payload.call_id === void 0 ? "id" : "call_id", invalid); + if (!isRecord2(payload.action)) { + invalid("action must be an object"); + return null; + } + const action = payload.action; + const type = identifier(action, "type", invalid); + if (type !== "exec") return null; + if (!Array.isArray(action.command) || action.command.length === 0 || !action.command.every((part) => typeof part === "string")) { + invalid("command must be a nonempty array of strings"); + return null; + } + const input2 = { ...action }; + if (action.working_directory === null) { + delete input2.working_directory; + } else if (action.working_directory !== void 0 && typeof action.working_directory !== "string") { + invalid("working_directory must be a string"); + delete input2.working_directory; + } + if (action.timeout_ms === null) { + delete input2.timeout_ms; + } else if (action.timeout_ms !== void 0 && (typeof action.timeout_ms !== "number" || !Number.isFinite(action.timeout_ms) || action.timeout_ms < 0)) { + invalid("timeout_ms must be a nonnegative finite number"); + delete input2.timeout_ms; + } + return id ? { type: "tool_use", tool_use: { id, name: "local_shell", input: input2 } } : null; +} +function responseItem(payload, invalid) { + const type = identifier(payload, "type", invalid); + switch (type) { + case "message": { + const role = identifier(payload, "role", invalid); + const content = publicText(payload.content, ["input_text", "output_text"], invalid); + return (role === "user" || role === "assistant") && Array.isArray(payload.content) ? { type: role, message: { role, content } } : null; + } + case "function_call": + case "custom_tool_call": + return toolCall(payload, invalid); + case "function_call_output": + case "custom_tool_call_output": { + const id = identifier(payload, "call_id", invalid); + if (typeof payload.output !== "string" && !Array.isArray(payload.output)) { + invalid("output must be a string or an array"); + return null; + } + const content = typeof payload.output === "string" ? payload.output : publicText(payload.output, ["input_text", "output_text"], invalid); + return id ? { type: "tool_result", tool_result: { tool_use_id: id, content } } : null; + } + case "local_shell_call": + return localShell(payload, invalid); + case "reasoning": { + const summary = blockText(publicText(payload.summary, ["summary_text"], invalid)); + return summary ? { type: "system", payload: { type: "reasoning", summary } } : null; + } + default: + return null; + } +} +function permissionMode(payload, invalid) { + for (const key of ["permissionMode", "permission_mode"]) { + if (payload[key] !== void 0) return textField(payload, key, invalid); + } + return typeof payload.approval_policy === "string" ? payload.approval_policy : void 0; +} +function recordUuid(raw, lineNo) { + const hex3 = createHash2("sha256").update(`codex\0${lineNo}\0`).update(raw).digest("hex"); + const variant = (parseInt(hex3[16], 16) & 3 | 8).toString(16); + return `${hex3.slice(0, 8)}-${hex3.slice(8, 12)}-8${hex3.slice(13, 16)}-${variant}${hex3.slice(17, 20)}-${hex3.slice(20, 32)}`; +} +function suppressFallbacks(candidates) { + let role; + let turnId = ""; + let boundary = 0; + let suppressed = 0; + const pending = /* @__PURE__ */ new Map(); + for (const candidate of candidates) { + const origin = candidate.origin; + if (!origin) continue; + if (boundary !== origin.boundary || (!turnId || !origin.turnId) && (role !== origin.role || turnId !== origin.turnId)) { + pending.clear(); + } + role = origin.role; + turnId = origin.turnId; + boundary = origin.boundary; + const key = JSON.stringify([turnId, role, origin.text]); + const queue = pending.get(key); + if (queue && queue.kind !== origin.kind) { + const other = queue.items[queue.next++]; + if (origin.kind === "canonical" && (candidate.event.type === "user" || candidate.event.type === "assistant") && (other.event.type === "user" || other.event.type === "assistant")) { + other.event.message = candidate.event.message; + } + candidate.suppressed = true; + suppressed += 1; + if (queue.next === queue.items.length) pending.delete(key); + } else if (queue) { + queue.items.push(candidate); + } else { + pending.set(key, { kind: origin.kind, items: [candidate], next: 0 }); + } + } + return suppressed; +} +async function readCodex(path2, opts = {}) { + const { logger, strict = false } = opts; + const stream = createReadStream3(path2, { encoding: "utf8" }); + const lines = createInterface2({ input: stream, crlfDelay: Infinity }); + const meta3 = { sessionId: "", permissionMode: "" }; + const context = { cwd: "", gitBranch: "", version: "", entrypoint: "", isSidechain: false }; + const candidates = []; + let turnId = ""; + let boundary = 0; + let malformedCount = 0; + let skippedMetaCount = 0; + let lineNo = 0; + let sawMeta = false; + try { + for await (const raw of lines) { + lineNo += 1; + if (!raw.trim()) continue; + const issues = /* @__PURE__ */ new Set(); + const invalid = (reason) => { + if (strict) throw new Error(`codex jsonl error at line ${lineNo} \u2014 ${reason}`); + issues.add(reason); + }; + let parsed; + try { + parsed = JSON.parse(raw.trim()); + } catch { + invalid("invalid JSON"); + } + let event = null; + let origin; + let timestamp = ""; + let metadata = true; + if (parsed !== void 0) { + if (!isRecord2(parsed)) { + invalid("expected an object"); + } else { + const type = identifier(parsed, "type", invalid); + if (type && ["session_meta", "turn_context", "response_item", "event_msg", "compacted"].includes( + type + )) { + timestamp = textField(parsed, "timestamp", invalid) ?? ""; + if (typeof parsed.timestamp === "string" && !Number.isFinite(Date.parse(timestamp))) { + invalid("timestamp must be a valid date"); + } + if (parsed.ordinal !== void 0 && (typeof parsed.ordinal !== "number" || !Number.isSafeInteger(parsed.ordinal) || parsed.ordinal < 0)) { + invalid("ordinal must be a nonnegative safe integer"); + } + if (!isRecord2(parsed.payload)) { + invalid("payload must be an object"); + } else { + const payload = parsed.payload; + switch (type) { + case "session_meta": { + const id = identifier(payload, "id", invalid); + if (id && !isFullUuid(id)) invalid("session id must be a UUID"); + if (payload.session_id !== void 0 && (typeof payload.session_id !== "string" || !isFullUuid(payload.session_id))) { + invalid("root session id must be a UUID"); + } + if (id && isFullUuid(id)) { + if (meta3.sessionId && id.toLowerCase() !== meta3.sessionId.toLowerCase()) { + invalid("session metadata id changed"); + } else { + meta3.sessionId = id; + } + } + const cwd = identifier(payload, "cwd", invalid); + if (cwd !== void 0) context.cwd = cwd; + const version2 = textField(payload, "cli_version", invalid, false); + if (version2 !== void 0) context.version = version2; + if (payload.git !== void 0 && payload.git !== null) { + if (!isRecord2(payload.git)) invalid("git must be an object"); + else if (payload.git.branch !== null) { + const branch = textField(payload.git, "branch", invalid, false); + if (branch !== void 0) context.gitBranch = branch; + } + } + context.entrypoint = typeof payload.source === "string" ? payload.source : ""; + context.isSidechain = payload.source === "subagent" || isRecord2(payload.source) && Object.hasOwn(payload.source, "subagent"); + const permission = permissionMode(payload, invalid); + if (permission !== void 0) meta3.permissionMode = permission; + metadata = sawMeta; + sawMeta = true; + break; + } + case "turn_context": { + const cwd = identifier(payload, "cwd", invalid); + if (cwd !== void 0) context.cwd = cwd; + turnId = ""; + if (payload.turn_id !== void 0) { + const id = identifier(payload, "turn_id", invalid); + if (id !== void 0) turnId = id; + } + const permission = permissionMode(payload, invalid); + if (permission !== void 0) meta3.permissionMode = permission; + break; + } + case "response_item": + if (typeof payload.type === "string" && (payload.type.endsWith("_call") || payload.type.endsWith("_call_output"))) { + boundary += 1; + } + event = responseItem(payload, invalid); + if (event?.type === "user" || event?.type === "assistant") { + origin = { + kind: "canonical", + role: event.type, + turnId, + boundary, + text: blockText(event.message.content) + }; + } + break; + case "event_msg": { + const eventType = identifier(payload, "type", invalid); + if (payload.call_id !== void 0) boundary += 1; + if (payload.turn_id !== void 0 && typeof payload.turn_id === "string") + turnId = payload.turn_id; + if (eventType === "user_message" || eventType === "agent_message") { + const text = textField(payload, "message", invalid); + if (text !== void 0) { + const role = eventType === "user_message" ? "user" : "assistant"; + event = { type: role, message: { role, content: [{ type: "text", text }] } }; + origin = { kind: "fallback", role, text, turnId, boundary }; + } + } + break; + } + case "compacted": { + boundary += 1; + const message = textField(payload, "message", invalid); + if (message !== void 0) + event = { type: "system", payload: { type: "compaction", message } }; + break; + } + } + } + } + } + } + if (event) { + candidates.push({ + event: { + ...context, + uuid: recordUuid(raw, lineNo), + parentUuid: null, + timestamp, + sessionId: meta3.sessionId, + userType: "", + ...event + }, + origin + }); + } else if (metadata && issues.size === 0) { + skippedMetaCount += 1; + } + if (issues.size > 0) { + malformedCount += 1; + logger?.warn("malformed codex jsonl line; recovered valid fields where possible", { + lineNo, + reasons: [...issues] + }); + } + } + } finally { + lines.close(); + stream.destroy(); + } + skippedMetaCount += suppressFallbacks(candidates); + const events = []; + let parentUuid = null; + for (const candidate of candidates) { + if (candidate.suppressed) continue; + candidate.event.parentUuid = parentUuid; + events.push(candidate.event); + parentUuid = candidate.event.uuid; + } + return { meta: meta3, events, malformedCount, skippedMetaCount }; +} + +// src/sources/codex.ts +var ROLLOUT_NAME = /^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}-\d{2})?-([0-9a-fA-F-]{36})\.jsonl$/; +var DATE_DIRECTORIES = [/^\d{4}$/, /^(?:0[1-9]|1[0-2])$/, /^(?:0[1-9]|[12]\d|3[01])$/]; +var HEADER_LIMIT = 1024 * 1024; +function isRecord3(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +async function sessionEntry(jsonlPath, filenameId) { + const original = await lstatIfPresent(jsonlPath); + if (!original?.isFile()) return null; + let handle; + try { + handle = await open( + jsonlPath, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK + ); + const info = await handle.stat(); + if (!info.isFile() || info.dev !== original.dev || info.ino !== original.ino) return null; + const chunks = []; + let length = 0; + let finished = false; + while (length < HEADER_LIMIT) { + const buffer = Buffer.allocUnsafe(Math.min(16 * 1024, HEADER_LIMIT - length)); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, null); + if (bytesRead === 0) { + finished = true; + break; + } + const newline = buffer.subarray(0, bytesRead).indexOf(10); + const used = newline === -1 ? bytesRead : newline; + chunks.push(buffer.subarray(0, used)); + length += used; + if (newline !== -1) { + finished = true; + break; + } + } + if (!finished) return null; + let header; + try { + header = JSON.parse(Buffer.concat(chunks, length).toString("utf8").trim()); + } catch { + return null; + } + if (!isRecord3(header) || header.type !== "session_meta" || !isRecord3(header.payload)) + return null; + const meta3 = header.payload; + const id = meta3.id; + if (typeof id !== "string" || !isFullUuid(id) || id.toLowerCase() !== filenameId.toLowerCase()) + return null; + if (meta3.session_id !== void 0 && (typeof meta3.session_id !== "string" || !isFullUuid(meta3.session_id))) + return null; + if (typeof meta3.cwd !== "string" || !meta3.cwd.trim()) return null; + if (meta3.source === "subagent" || isRecord3(meta3.source) && Object.hasOwn(meta3.source, "subagent")) + return null; + const current = await lstatIfPresent(jsonlPath); + if (!current?.isFile() || current.dev !== info.dev || current.ino !== info.ino) return null; + return { + source: "codex", + sessionId: id, + projectDir: meta3.cwd, + jsonlPath, + mtimeMs: info.mtimeMs, + sizeBytes: info.size + }; + } catch (error62) { + if (isMissingPath(error62) || error62?.code === "ELOOP") + return null; + throw error62; + } finally { + await handle?.close(); + } +} +async function discover2(options) { + const root = resolve3( + options.root ?? resolve3(process.env.CODEX_HOME || join3(homedir3(), ".codex"), "sessions") + ); + const projectCwd = options.projectCwd === void 0 ? void 0 : resolve3(options.projectCwd); + async function visit3(directory, depth) { + const original = await lstatIfPresent(directory); + if (!original?.isDirectory()) return []; + const sessions = []; + for (const entry of await readDirectory(directory)) { + if (depth < DATE_DIRECTORIES.length) { + if (entry.isDirectory() && DATE_DIRECTORIES[depth].test(entry.name)) { + for (const session of await visit3(join3(directory, entry.name), depth + 1)) + sessions.push(session); + } + } else if (entry.isFile()) { + const match = ROLLOUT_NAME.exec(entry.name); + if (!match || !isFullUuid(match[1])) continue; + const session = await sessionEntry(join3(directory, entry.name), match[1]); + if (session && (projectCwd === void 0 || resolve3(session.projectDir) === projectCwd)) { + sessions.push(session); + } + } + } + return await isSameDirectory(directory, original) ? sessions : []; + } + return visit3(root, 0); +} +var codexSource = { + id: "codex", + discover: discover2, + accepts: (record2) => typeof record2.type === "string" && ["session_meta", "turn_context", "response_item", "event_msg", "compacted"].includes( + record2.type + ) && isRecord3(record2.payload), + read: readCodex +}; + +// src/sources/index.ts +var sources = [claudeSource, codexSource]; +function getSessionSource(id = "claude") { + const source = sources.find((candidate) => candidate.id === id); + if (!source) throw new Error("Unsupported session source."); + return source; +} +async function detectSessionSource(path2, selected) { + const file2 = await open2(path2, "r"); + const buffer = Buffer.alloc(1024 * 1024); + let bytes = 0; + try { + while (bytes < buffer.length) { + const chunk = await file2.read(buffer, bytes, buffer.length - bytes, bytes); + if (chunk.bytesRead === 0) break; + bytes += chunk.bytesRead; + } + } finally { + await file2.close(); + } + const text = buffer.toString("utf8", 0, bytes); + const lines = text.split("\n"); + if (bytes === buffer.length) lines.pop(); + for (const line of lines) { + if (!line.trim()) continue; + let record2; + try { + record2 = JSON.parse(line.trim()); + } catch { + continue; + } + if (!record2 || typeof record2 !== "object" || Array.isArray(record2)) continue; + const source = sources.find( + (candidate) => candidate.accepts(record2) + ); + if (!source) continue; + if (selected && source.id !== selected) { + throw new Error("Session file does not match the selected source."); + } + return source.id; + } + if (selected) return getSessionSource(selected).id; + if (bytes < buffer.length && !text.trim()) return "claude"; + throw new Error("Cannot identify session format; select --source explicitly."); +} + // src/tree/builder.ts function buildMindMap(graph, segments, opts) { const generatedAt = opts.generatedAt ?? (/* @__PURE__ */ new Date()).toISOString(); @@ -8364,6 +8976,7 @@ function buildMindMap(graph, segments, opts) { (e) => e.type === "user" || e.type === "assistant" ).length; return { + source: opts.source ?? "claude", session_id: sessionId, project_path: selectedEvents[0]?.cwd ?? "", generated_at: generatedAt, @@ -8469,10 +9082,8 @@ function deriveSegmentLabel(seg, events, redactor) { const topFile = seg.dominant_files[0]; const topTool = seg.dominant_tools[0]; const eventCount = seg.event_uuids.length; - if (topFile && topTool) return truncate3(`${basename(topFile)} (${topTool})`, 60); - if (topFile) return truncate3(basename(topFile), 60); - if (topTool) return truncate3(topTool, 60); - return `${eventCount} events`; + const label = topFile && topTool ? `${basename(topFile)} (${topTool})` : topFile ? basename(topFile) : topTool ?? `${eventCount} events`; + return truncate3(redactor ? redactor.apply(label) : label, 60); } function looksLikeSystemNoise(text) { const t = text.trim(); @@ -8496,7 +9107,7 @@ function deriveSegmentSummary(seg) { return `events ${seg.start_index}\u2013${seg.end_index}${reasons}${files ? ` \xB7 files: ${files}` : ""}${tools ? ` \xB7 tools: ${tools}` : ""}`; } function deriveSessionTitle(events, sessionId, override, redactor) { - if (override) return truncate3(override, 60); + if (override) return truncate3(redactor ? redactor.apply(override) : override, 60); const firstSignificant = firstSignificantUserText(events); const firstUserText = firstSignificant ?? firstUserMessageText(events); if (firstUserText) { @@ -8848,143 +9459,10 @@ function luhnValid(digits) { return sum % 10 === 0; } -// src/utils/session_path.ts -import { lstat, readdir, realpath } from "node:fs/promises"; -import { homedir as homedir2 } from "node:os"; -import { basename as basename2, dirname, extname, join as join2, resolve as resolve2 } from "node:path"; -function getProjectsRoot() { - return resolve2(process.env.CLAUDE_CONFIG_DIR || join2(homedir2(), ".claude"), "projects"); -} -function encodeProjectPath(absPath) { - return absPath.replace(/[^a-zA-Z0-9]/g, "-"); -} -var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -var UUID_LOOSE = /^[0-9a-f-]+$/i; -function isFullUuid(s) { - return UUID_RE.test(s); -} -function isUuidPrefix(s) { - return UUID_LOOSE.test(s) && s.length >= 4 && s.length <= 36; -} -async function listSessions(opts = {}) { - if (opts.limit !== void 0 && (!Number.isSafeInteger(opts.limit) || opts.limit < 0)) { - throw new RangeError("session limit must be a nonnegative safe integer"); - } - if (opts.limit === 0) return []; - const root = resolve2(opts.projectsRoot ?? getProjectsRoot()); - const rootInfo = await lstatIfPresent(root); - if (!rootInfo?.isDirectory()) return []; - const projectDirs = opts.projectCwd === void 0 ? (await readDirectory(root)).filter((entry) => entry.isDirectory()).map((entry) => entry.name) : [encodeProjectPath(opts.projectCwd)]; - const sessions = []; - for (const projectDir of projectDirs) { - if (!projectDir) continue; - const projectPath = join2(root, projectDir); - const projectInfo = await lstatIfPresent(projectPath); - if (!projectInfo?.isDirectory()) continue; - const projectSessions = []; - for (const file2 of await readDirectory(projectPath)) { - if (!file2.isFile() || !file2.name.endsWith(".jsonl")) continue; - const sessionId = file2.name.slice(0, -".jsonl".length); - if (!isFullUuid(sessionId)) continue; - const jsonlPath = join2(projectPath, file2.name); - const info = await lstatIfPresent(jsonlPath); - if (!info?.isFile()) continue; - projectSessions.push({ - sessionId, - projectDir, - jsonlPath, - mtimeMs: info.mtimeMs, - sizeBytes: info.size - }); - } - if (await isSameDirectory(projectPath, projectInfo)) { - for (const session of projectSessions) sessions.push(session); - } - } - if (!await isSameDirectory(root, rootInfo)) return []; - sessions.sort(compareSessions); - return opts.limit === void 0 ? sessions : sessions.slice(0, opts.limit); -} -async function sessionFromFile(filePath) { - const jsonlPath = await realpath(resolve2(filePath)); - if (extname(jsonlPath) !== ".jsonl") { - throw new Error("session file must have a .jsonl extension"); - } - const info = await lstat(jsonlPath); - if (!info.isFile()) { - throw new Error("session file must be a regular file"); - } - return { - sessionId: basename2(jsonlPath, ".jsonl"), - projectDir: basename2(dirname(jsonlPath)), - jsonlPath - }; -} -async function locateSession(sessionIdOrPrefix, opts = {}) { - const prefix = sessionIdOrPrefix.toLowerCase(); - if (!isUuidPrefix(prefix) && !isFullUuid(prefix)) { - throw new Error(`session id "${sessionIdOrPrefix}" is not a valid UUID or prefix`); - } - const matches = (await listSessions({ projectsRoot: opts.projectsRoot })).filter( - (session) => session.sessionId.toLowerCase().startsWith(prefix) - ); - matches.sort((a, b) => { - if (opts.projectHint) { - const aHit = a.projectDir === opts.projectHint ? 1 : 0; - const bHit = b.projectDir === opts.projectHint ? 1 : 0; - if (aHit !== bHit) return bHit - aHit; - } - return compareSessions(a, b); - }); - return matches.map(toSessionMatch); -} -async function findLatestSession(projectsRoot = getProjectsRoot()) { - const [latest] = await listSessions({ projectsRoot, limit: 1 }); - return latest ? toSessionMatch(latest) : null; -} -async function findLatestSessionInProject(cwd, projectsRoot = getProjectsRoot()) { - const [latest] = await listSessions({ projectsRoot, projectCwd: cwd, limit: 1 }); - return latest ? toSessionMatch(latest) : null; -} -function compareSessions(a, b) { - return b.mtimeMs - a.mtimeMs || (a.jsonlPath < b.jsonlPath ? -1 : a.jsonlPath > b.jsonlPath ? 1 : 0); -} -function toSessionMatch(entry) { - return { - sessionId: entry.sessionId, - projectDir: entry.projectDir, - jsonlPath: entry.jsonlPath - }; -} -function isMissingPath(error62) { - const code = error62?.code; - return code === "ENOENT" || code === "ENOTDIR"; -} -async function lstatIfPresent(path2) { - try { - return await lstat(path2); - } catch (error62) { - if (isMissingPath(error62)) return null; - throw error62; - } -} -async function readDirectory(path2) { - try { - return await readdir(path2, { withFileTypes: true }); - } catch (error62) { - if (isMissingPath(error62)) return []; - throw error62; - } -} -async function isSameDirectory(path2, original) { - const current = await lstatIfPresent(path2); - return current !== null && current.isDirectory() && current.dev === original.dev && current.ino === original.ino; -} - // src/config/loader.ts import { readFile as readFile2 } from "node:fs/promises"; -import { homedir as homedir3 } from "node:os"; -import { join as join3, resolve as resolve3 } from "node:path"; +import { homedir as homedir4 } from "node:os"; +import { join as join4, resolve as resolve4 } from "node:path"; // node_modules/zod/v4/classic/external.js var external_exports = {}; @@ -13812,9 +14290,9 @@ function partPattern(schema) { return own2; } if (def.options) { - const sources = def.options.map(partPattern); - if (sources.every(Boolean)) - return `^(${sources.map((s) => cleanRegex(s)).join("|")})$`; + const sources2 = def.options.map(partPattern); + if (sources2.every(Boolean)) + return `^(${sources2.map((s) => cleanRegex(s)).join("|")})$`; } return leafPattern(schema); } @@ -14012,7 +14490,7 @@ var recursive = /* @__PURE__ */ new WeakMap(); var NONE = 0; var ASSUMED = 1; var PROVEN = 2; -function isRecursive(inst, stack, resolve6) { +function isRecursive(inst, stack, resolve8) { const cached2 = recursive.get(inst); if (cached2 !== void 0) return cached2 ? PROVEN : NONE; @@ -14022,7 +14500,7 @@ function isRecursive(inst, stack, resolve6) { let result = NONE; const check2 = (child) => { if (result !== PROVEN && child?._zod) { - const answer = isRecursive(child, stack, resolve6); + const answer = isRecursive(child, stack, resolve8); if (answer > result) result = answer; } @@ -14033,7 +14511,7 @@ function isRecursive(inst, stack, resolve6) { const desc = Object.getOwnPropertyDescriptor(sh, key); if (spread && !desc.enumerable) continue; - const child = desc.get ? ASSUMED : desc.value?._zod ? isRecursive(desc.value, stack, resolve6) : NONE; + const child = desc.get ? ASSUMED : desc.value?._zod ? isRecursive(desc.value, stack, resolve8) : NONE; if (child > answer) answer = child; } @@ -14097,7 +14575,7 @@ function isRecursive(inst, stack, resolve6) { break; // `$ZodLazy` caches its inner on the def, so a resolved edge is followed exactly case "lazy": { - const inner = def._cachedInner ?? (resolve6 ? inst._zod.innerType : void 0); + const inner = def._cachedInner ?? (resolve8 ? inst._zod.innerType : void 0); merge2(inner ? isRecursive(inner, stack, false) : ASSUMED); break; } @@ -14161,7 +14639,7 @@ function bucketFor(state, inst) { return bucket; } var handoff; -var open = []; +var open3 = []; var memo = { alloc(_inst, payload, empty) { const bucket = handoff; @@ -14170,7 +14648,7 @@ var memo = { handoff = void 0; const entry = { value: empty, issues: null }; bucket.set(payload.value, entry); - open.push(entry); + open3.push(entry); return empty; }, guard(inst) { @@ -14241,10 +14719,10 @@ var memo = { return payload; } handoff = bucket; - const depth = open.length; + const depth = open3.length; const result = base(payload, ctx); handoff = void 0; - const entry = open.length > depth ? open.pop() : void 0; + const entry = open3.length > depth ? open3.pop() : void 0; if (result instanceof Promise) { return result.then((r) => { if (entry) @@ -24549,8 +25027,8 @@ function _stringFormat(Class2, format, fnOrRegex, _params = {}) { } // node_modules/zod/v4/core/to-json-schema.js -function assignProps(target, ...sources) { - for (const source of sources) { +function assignProps(target, ...sources2) { + for (const source of sources2) { for (const key of Reflect.ownKeys(source)) { if (Object.prototype.propertyIsEnumerable.call(source, key)) { assignProp(target, key, source[key]); @@ -28737,7 +29215,7 @@ var DEFAULT_CONFIG = { } }; function parseConfigLayer(source, warn) { - if (!isRecord2(source)) { + if (!isRecord4(source)) { warn?.("ignored invalid config root: expected an object"); return null; } @@ -28748,7 +29226,7 @@ function parseConfigLayer(source, warn) { continue; } if (value === void 0) continue; - if (!isRecord2(value)) { + if (!isRecord4(value)) { warn?.(`ignored invalid config section ${section}: expected an object`); continue; } @@ -28781,7 +29259,7 @@ function mergeConfig(target, source) { } return out; } -function isRecord2(v) { +function isRecord4(v) { if (typeof v !== "object" || v === null || Array.isArray(v)) return false; const prototype = Object.getPrototypeOf(v); return prototype === Object.prototype || prototype === null; @@ -28798,7 +29276,7 @@ function formatPath(parts) { } // src/config/loader.ts -var USER_CONFIG_DEFAULT = join3(homedir3(), ".config", "agent-tree", "config.yaml"); +var USER_CONFIG_DEFAULT = join4(homedir4(), ".config", "agent-tree", "config.yaml"); function envToPartial(env, logger) { const out = {}; const llm = {}; @@ -28861,7 +29339,7 @@ async function loadConfig(opts = {}) { const env = opts.env ?? process.env; const projectCwd = opts.projectCwd ?? process.cwd(); const userPath = opts.userConfigPath ?? USER_CONFIG_DEFAULT; - const projectPath = resolve3(projectCwd, ".agent-tree.yaml"); + const projectPath = resolve4(projectCwd, ".agent-tree.yaml"); const [userYaml, projectYaml] = await Promise.all([ readYamlIfPresent(userPath, "user", opts.logger), readYamlIfPresent(projectPath, "project", opts.logger) @@ -28884,10 +29362,10 @@ async function loadConfig(opts = {}) { function expandPath(template, projectCwd) { let p = template; if (p.startsWith("~/") || p === "~") { - p = join3(homedir3(), p.slice(1)); + p = join4(homedir4(), p.slice(1)); } p = p.replace("{project}", projectCwd); - return resolve3(p); + return resolve4(p); } // src/cli/pipeline.ts @@ -28902,6 +29380,7 @@ async function runPipeline(deps) { const cacheHash = await computeInputHash({ jsonlPath: match.jsonlPath, configJson: JSON.stringify({ + source: match.source, config: config2, llm: opts.llm !== false, model: opts.model, @@ -28912,12 +29391,14 @@ async function runPipeline(deps) { specVersion: SPEC_VERSION }); progress("[1/5] Parsing JSONL... "); - const { meta: meta3, events, malformedCount, skippedMetaCount } = await readJsonl(match.jsonlPath, { + const { meta: meta3, events, malformedCount, skippedMetaCount } = await getSessionSource( + match.source + ).read(match.jsonlPath, { logger, strict: opts.strict }); if (!isFullUuid(meta3.sessionId)) { - meta3.sessionId = createHash2("sha256").update(match.jsonlPath).digest("hex").slice(0, 32); + meta3.sessionId = createHash3("sha256").update(match.jsonlPath).digest("hex").slice(0, 32); } progress(`\u2714 ${events.length} events`); if (malformedCount > 0) progress(` (${malformedCount} malformed)`); @@ -28930,6 +29411,7 @@ async function runPipeline(deps) { } if (events.length === 0) { const emptyMindmap = { + source: match.source, session_id: meta3.sessionId, project_path: "", generated_at: (/* @__PURE__ */ new Date()).toISOString(), @@ -28982,6 +29464,7 @@ async function runPipeline(deps) { `); progress("[4/5] Building mindmap... "); const mindmap = buildMindMap(graph, segments, { + source: match.source, jsonlPath: match.jsonlPath, specVersion: SPEC_VERSION, redactor @@ -29136,7 +29619,7 @@ _(no events were parsed.)_ // src/cli/modes.ts import { mkdir as mkdir3, writeFile as writeFile2 } from "node:fs/promises"; -import { resolve as resolve4 } from "node:path"; +import { resolve as resolve5 } from "node:path"; // src/render/text.ts var DEFAULT_MAX_WIDTH = 90; @@ -29398,7 +29881,7 @@ function findNodeById(node2, id) { } // src/cli/tui.ts -import { createInterface as createInterface2 } from "node:readline/promises"; +import { createInterface as createInterface3 } from "node:readline/promises"; async function runTui(mindmap, opts = {}) { const out = opts.output ?? process.stderr; const inn = opts.input ?? process.stdin; @@ -29412,7 +29895,7 @@ async function runTui(mindmap, opts = {}) { ` ); - const rl = createInterface2({ input: inn, output: out }); + const rl = createInterface3({ input: inn, output: out }); const controller = new AbortController(); const abort = () => controller.abort(); rl.once("SIGINT", abort); @@ -29456,19 +29939,19 @@ import { platform } from "node:os"; function copyToClipboard(text) { const cmd = pickCommand(); if (!cmd) return Promise.resolve({ ok: false, reason: "no clipboard tool detected" }); - return new Promise((resolve6) => { + return new Promise((resolve8) => { try { const child = spawn2(cmd.command, cmd.args, { stdio: ["pipe", "ignore", "ignore"] }); child.on("error", (err) => { - resolve6({ ok: false, command: cmd.command, reason: err.message }); + resolve8({ ok: false, command: cmd.command, reason: err.message }); }); child.on("exit", (code) => { - if (code === 0) resolve6({ ok: true, command: cmd.command }); - else resolve6({ ok: false, command: cmd.command, reason: `exit ${code}` }); + if (code === 0) resolve8({ ok: true, command: cmd.command }); + else resolve8({ ok: false, command: cmd.command, reason: `exit ${code}` }); }); child.stdin?.end(text, "utf8"); } catch (err) { - resolve6({ + resolve8({ ok: false, command: cmd.command, reason: err instanceof Error ? err.message : String(err) @@ -29489,7 +29972,7 @@ function pickCommand() { } // src/utils/safe_path.ts -import { realpath as realpath2 } from "node:fs/promises"; +import { realpath } from "node:fs/promises"; import { isAbsolute } from "node:path"; async function safeGitCwd(eventsCwd, callerCwd) { for (const candidate of [eventsCwd, callerCwd]) { @@ -29497,7 +29980,7 @@ async function safeGitCwd(eventsCwd, callerCwd) { continue; } try { - const real = await realpath2(candidate); + const real = await realpath(candidate); if (isAbsolute(real)) return real; } catch { } @@ -29507,54 +29990,71 @@ async function safeGitCwd(eventsCwd, callerCwd) { // src/utils/picks.ts import { randomBytes } from "node:crypto"; -import { appendFile, mkdir as mkdir2, readFile as readFile3 } from "node:fs/promises"; -import { homedir as homedir4 } from "node:os"; -import { dirname as dirname2, join as join4 } from "node:path"; -var PICKS_ROOT = join4(homedir4(), ".cache", "agent-tree", "picks"); +import { appendFile, lstat as lstat2, mkdir as mkdir2, readFile as readFile3, readdir as readdir2 } from "node:fs/promises"; +import { homedir as homedir5 } from "node:os"; +import { dirname, join as join5 } from "node:path"; +var PICKS_ROOT = join5(homedir5(), ".cache", "agent-tree", "picks"); +var PICK_SOURCES = ["claude", "codex"]; var SESSION_ID_RE = /^[0-9a-f-]{4,40}$/i; -function picksFileFor(sessionId, root = PICKS_ROOT) { - if (!SESSION_ID_RE.test(sessionId)) { +function validateSource(source) { + if (source !== "claude" && source !== "codex") { + throw new Error(`refusing to compose picks path for invalid source "${source}"`); + } +} +function picksFileFor(sessionId, root = PICKS_ROOT, source = "claude") { + validateSource(source); + if (typeof sessionId !== "string" || !SESSION_ID_RE.test(sessionId)) { throw new Error(`refusing to compose picks path for invalid sessionId "${sessionId}"`); } - return join4(root, `${sessionId}.jsonl`); + return join5(root, source, `${sessionId}.jsonl`); } async function recordPick(sessionId, nodeId, mode, opts = {}) { - const file2 = picksFileFor(sessionId, opts.root); - await mkdir2(dirname2(file2), { recursive: true, mode: 448 }); + const file2 = picksFileFor(sessionId, opts.root, opts.source); + await mkdir2(dirname(file2), { recursive: true, mode: 448 }); const entry = { node_id: nodeId, mode, ts: (/* @__PURE__ */ new Date()).toISOString() }; await appendFile(file2, JSON.stringify(entry) + "\n", "utf8"); } async function listAllPicks(opts = {}) { + if (opts.source !== void 0) validateSource(opts.source); + const sources2 = opts.source === void 0 ? PICK_SOURCES : [opts.source]; const root = opts.root ?? PICKS_ROOT; - const { readdir: readdir2 } = await import("node:fs/promises"); - let files; try { - files = await readdir2(root); + if (!(await lstat2(root)).isDirectory()) return []; } catch { return []; } const out = []; - for (const fname of files) { - if (!fname.endsWith(".jsonl")) continue; - const sessionId = fname.slice(0, -".jsonl".length); - const filePath = join4(root, fname); - let raw; + for (const source of sources2) { + const directory = join5(root, source); + let files; try { - raw = await readFile3(filePath, "utf8"); + if (!(await lstat2(directory)).isDirectory()) continue; + files = await readdir2(directory, { withFileTypes: true }); } catch { continue; } - const picks = []; - for (const line of raw.split("\n")) { - const trimmed = line.trim(); - if (!trimmed) continue; + for (const file2 of files) { + if (!file2.isFile() || !file2.name.endsWith(".jsonl")) continue; + const sessionId = file2.name.slice(0, -".jsonl".length); + if (!SESSION_ID_RE.test(sessionId)) continue; + let raw; try { - const parsed = JSON.parse(trimmed); - if (parsed.node_id) picks.push(parsed); + raw = await readFile3(join5(directory, file2.name), "utf8"); } catch { + continue; + } + const picks = []; + for (const line of raw.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const parsed = JSON.parse(trimmed); + if (parsed.node_id) picks.push(parsed); + } catch { + } } + if (picks.length > 0) out.push({ source, sessionId, picks }); } - if (picks.length > 0) out.push({ sessionId, picks }); } out.sort((a, b) => { const ta = a.picks[a.picks.length - 1]?.ts ?? ""; @@ -29564,7 +30064,7 @@ async function listAllPicks(opts = {}) { return out; } async function removePicksForNode(sessionId, nodeId, opts = {}) { - const file2 = picksFileFor(sessionId, opts.root); + const file2 = picksFileFor(sessionId, opts.root, opts.source); let raw; try { raw = await readFile3(file2, "utf8"); @@ -29592,16 +30092,15 @@ async function removePicksForNode(sessionId, nodeId, opts = {}) { const { writeFile: writeFile3, rename } = await import("node:fs/promises"); const rand = randomBytes(4).toString("hex"); const tmp = `${file2}.tmp-${process.pid}-${Date.now()}-${rand}`; - await writeFile3( - tmp, - kept.length > 0 ? kept.join("\n") + "\n" : "", - { encoding: "utf8", mode: 384 } - ); + await writeFile3(tmp, kept.length > 0 ? kept.join("\n") + "\n" : "", { + encoding: "utf8", + mode: 384 + }); await rename(tmp, file2); return removed; } async function readPicks(sessionId, opts = {}) { - const file2 = picksFileFor(sessionId, opts.root); + const file2 = picksFileFor(sessionId, opts.root, opts.source); const index = { modesByNode: /* @__PURE__ */ new Map(), total: 0 }; let raw; try { @@ -29633,7 +30132,7 @@ async function runListMode(ctx) { process.stdout.write(JSON.stringify(redactDeep(ctx.mindmap, ctx.redactor), null, 2) + "\n"); return 0; } - const picks = await readPicks(ctx.match.sessionId); + const picks = await readPicks(ctx.match.sessionId, { source: ctx.match.source }); const tree = renderTextTree(ctx.mindmap, { filter: ctx.opts.filter, groupConsecutive: ctx.opts.group !== false, @@ -29646,7 +30145,7 @@ async function runListMode(ctx) { process.stdout.write( ctx.redactor.apply( `Pick a node by number. Re-run as: - agent-tree ${selector} --snapshot --mode continue|fork + agent-tree --source ${ctx.match.source} ${selector} --snapshot --mode continue|fork ` ) ); @@ -29675,9 +30174,9 @@ async function runSnapshotMode(ctx) { gitMd ? appendGitSection(baseSnap.clipboard_markdown, gitMd) : baseSnap.clipboard_markdown ); process.stdout.write(finalMarkdown); - await recordPick(ctx.match.sessionId, node2.id, wantFork ? "fork" : "continue").catch( - (err) => ctx.logger.warn?.("pick history write failed", { error: String(err) }) - ); + await recordPick(ctx.match.sessionId, node2.id, wantFork ? "fork" : "continue", { + source: ctx.match.source + }).catch((err) => ctx.logger.warn?.("pick history write failed", { error: String(err) })); if (process.stdout.isTTY && process.stderr.isTTY) { const result = await copyToClipboard(finalMarkdown); if (result.ok) { @@ -29702,8 +30201,8 @@ function appendGitSection(snapshotMd, gitMd) { } return snapshotMd + "\n\n" + gitMd + "\n"; } -async function runPicksMode(redactor) { - const all = await listAllPicks(); +async function runPicksMode(redactor, source) { + const all = await listAllPicks({ source }); if (all.length === 0) { process.stderr.write("No picks recorded yet.\n"); return 0; @@ -29712,7 +30211,7 @@ async function runPicksMode(redactor) { let totalPicks = 0; for (const session of all) { lines.push( - `session ${session.sessionId.slice(0, 8)} (${session.picks.length} pick${session.picks.length === 1 ? "" : "s"})` + `${session.source} session ${session.sessionId.slice(0, 8)} (${session.picks.length} pick${session.picks.length === 1 ? "" : "s"})` ); for (const p of session.picks) { const when = p.ts.replace("T", " ").slice(0, 19); @@ -29735,7 +30234,9 @@ async function runUnstarMode(ctx) { console.error(`error: no node matches "${ctx.opts.unstar}". Run --list to see numbers.`); return 2; } - const removed = await removePicksForNode(ctx.match.sessionId, node2.id); + const removed = await removePicksForNode(ctx.match.sessionId, node2.id, { + source: ctx.match.source + }); if (removed === 0) { process.stderr.write(`No picks recorded for ${node2.id} \u2014 nothing to unstar. `); @@ -29794,7 +30295,7 @@ function runDiffMode(ctx) { return 0; } async function runTuiMode(ctx) { - const picks = await readPicks(ctx.match.sessionId); + const picks = await readPicks(ctx.match.sessionId, { source: ctx.match.source }); const result = await runTui(ctx.mindmap, { render: { filter: ctx.opts.filter, @@ -29823,7 +30324,7 @@ var PROTECTED_DUMP_PREFIXES = [ "/Library/LaunchAgents" ]; async function dumpArtifacts(dir, graph, segments, mindmap, redactor) { - const outDir = resolve4(dir); + const outDir = resolve5(dir); if (PROTECTED_DUMP_PREFIXES.some((p) => outDir === p || outDir.startsWith(p + "/"))) { throw new Error(`refusing to dump into protected path: ${outDir}`); } @@ -29836,13 +30337,13 @@ async function dumpArtifacts(dir, graph, segments, mindmap, redactor) { const opts = { encoding: "utf8", mode: 384, flag: "wx" }; await Promise.all([ writeFile2( - resolve4(outDir, "raw-events.json"), + resolve5(outDir, "raw-events.json"), JSON.stringify({ meta: safeMeta, events: safeEvents }, null, 2), opts ), - writeFile2(resolve4(outDir, "graph.json"), JSON.stringify(safeGraph, null, 2), opts), + writeFile2(resolve5(outDir, "graph.json"), JSON.stringify(safeGraph, null, 2), opts), writeFile2( - resolve4(outDir, "segments.json"), + resolve5(outDir, "segments.json"), JSON.stringify( { session_id: graph.meta.sessionId, count: safeSegments.length, segments: safeSegments }, null, @@ -29850,7 +30351,7 @@ async function dumpArtifacts(dir, graph, segments, mindmap, redactor) { ), opts ), - writeFile2(resolve4(outDir, "tree.json"), JSON.stringify(safeTree, null, 2), opts) + writeFile2(resolve5(outDir, "tree.json"), JSON.stringify(safeTree, null, 2), opts) ]); } @@ -29886,23 +30387,92 @@ function createLoggerSync(level = "info") { } // src/utils/picker.ts -import { createInterface as createInterface3 } from "node:readline/promises"; +import { createInterface as createInterface4 } from "node:readline/promises"; + +// src/utils/session_path.ts +import { lstat as lstat3, realpath as realpath2 } from "node:fs/promises"; +import { basename as basename2, dirname as dirname2, extname, resolve as resolve6 } from "node:path"; +async function listSessions(opts = {}) { + if (opts.limit !== void 0 && (!Number.isSafeInteger(opts.limit) || opts.limit < 0)) { + throw new RangeError("session limit must be a nonnegative safe integer"); + } + if (opts.limit === 0) return []; + const sessions = await getSessionSource(opts.source).discover(opts); + sessions.sort(compareSessions); + return opts.limit === void 0 ? sessions : sessions.slice(0, opts.limit); +} +async function sessionFromFile(filePath, source) { + const jsonlPath = await realpath2(resolve6(filePath)); + if (extname(jsonlPath) !== ".jsonl") throw new Error("session file must have a .jsonl extension"); + const info = await lstat3(jsonlPath); + if (!info.isFile()) throw new Error("session file must be a regular file"); + return { + source: await detectSessionSource(jsonlPath, source), + sessionId: basename2(jsonlPath, ".jsonl"), + projectDir: basename2(dirname2(jsonlPath)), + jsonlPath + }; +} +async function locateSession(sessionIdOrPrefix, opts = {}) { + const prefix = sessionIdOrPrefix.toLowerCase(); + if (!isUuidPrefix(prefix) && !isFullUuid(prefix)) { + throw new Error("session id is not a valid UUID or prefix"); + } + const matches = (await listSessions(opts)).filter( + (session) => session.sessionId.toLowerCase().startsWith(prefix) + ); + matches.sort((a, b) => { + if (opts.projectHint) { + const aHit = a.projectDir === opts.projectHint ? 1 : 0; + const bHit = b.projectDir === opts.projectHint ? 1 : 0; + if (aHit !== bHit) return bHit - aHit; + } + return compareSessions(a, b); + }); + return matches.map(toSessionMatch); +} +async function findLatestSession(opts = {}) { + const [latest] = await listSessions({ ...opts, limit: 1 }); + return latest ? toSessionMatch(latest) : null; +} +async function findLatestSessionInProject(cwd, opts = {}) { + return findLatestSession({ ...opts, projectCwd: cwd }); +} +function compareSessions(a, b) { + return b.mtimeMs - a.mtimeMs || (a.jsonlPath < b.jsonlPath ? -1 : a.jsonlPath > b.jsonlPath ? 1 : 0); +} +function toSessionMatch(entry) { + return { + source: entry.source, + sessionId: entry.sessionId, + projectDir: entry.projectDir, + jsonlPath: entry.jsonlPath + }; +} + +// src/utils/picker.ts async function pickSession(opts = {}) { const candidates = await listSessions({ - projectsRoot: opts.projectsRoot, + root: opts.root, + source: opts.source, + projectCwd: opts.projectCwd, limit: opts.limit ?? 10 }); if (candidates.length === 0) return null; const out = opts.output ?? process.stderr; - out.write("Recent Claude Code sessions:\n"); + const redactor = opts.redactor ?? defaultRedactor(); + out.write(`Recent ${opts.source === "codex" ? "Codex" : "Claude Code"} sessions: +`); candidates.forEach((c, i) => { const when = new Date(c.mtimeMs).toISOString().replace("T", " ").slice(0, 16); out.write( - ` ${String(i + 1).padStart(2, " ")}. ${c.sessionId.slice(0, 8)} ${when} ${c.projectDir} + redactor.apply( + ` ${String(i + 1).padStart(2, " ")}. ${c.sessionId.slice(0, 8)} ${when} ${c.projectDir} ` + ) ); }); - const rl = createInterface3({ + const rl = createInterface4({ input: opts.input ?? process.stdin, output: opts.output ?? process.stderr }); @@ -29921,6 +30491,7 @@ async function pickSession(opts = {}) { } const picked = candidates[idx - 1]; return { + source: picked.source, sessionId: picked.sessionId, projectDir: picked.projectDir, jsonlPath: picked.jsonlPath @@ -29946,7 +30517,7 @@ async function main(argv = process.argv) { console.error("error: --tui requires interactive input; use --list or --json instead"); return 2; } - const projectCwd = opts.cwd ? resolve5(opts.cwd) : void 0; + const projectCwd = opts.cwd ? resolve7(opts.cwd) : void 0; const cwd = projectCwd ?? process.cwd(); opts.cwd = cwd; if (!(await stat(cwd)).isDirectory()) { @@ -29959,6 +30530,7 @@ async function main(argv = process.argv) { if (!opts.trace && !opts.verbose) logger = createLoggerSync(config2.log.level); if (opts.sessions) { const entries = await listSessions({ + source: opts.source, projectCwd, limit: opts.limit ?? 20 }); @@ -29966,15 +30538,21 @@ async function main(argv = process.argv) { const safeEntries = redactDeep(entries, redactor); process.stdout.write( opts.json ? JSON.stringify({ sessions: safeEntries }, null, 2) + "\n" : safeEntries.map( - (entry) => `${entry.sessionId} ${new Date(entry.mtimeMs).toISOString()} ${entry.sizeBytes} bytes ${entry.projectDir}` + (entry) => `${entry.source} ${entry.sessionId} ${new Date(entry.mtimeMs).toISOString()} ${entry.sizeBytes} bytes ${entry.projectDir}` ).join("\n") + "\n" ); return 0; } if (opts.picks) { - return runPicksMode(buildRedactor(opts, config2, logger)); + return runPicksMode(buildRedactor(opts, config2, logger), opts.source); } - const resolved = await resolveSession(sessionArg, opts, logger, cwd); + const resolved = await resolveSession( + sessionArg, + opts, + logger, + cwd, + buildRedactor(opts, config2, logger) + ); if (!resolved.ok) { return resolved.reason === "cancelled" ? 130 : 2; } @@ -29999,7 +30577,7 @@ async function main(argv = process.argv) { result.mindmap, result.redactor ); - logger.info(`dumped JSON artifacts`, { dir: resolve5(opts.dumpJson) }); + logger.info(`dumped JSON artifacts`, { dir: resolve7(opts.dumpJson) }); } if (opts.dryRun) { process.stderr.write("Dry-run \u2014 analysis complete, no output written.\n"); @@ -30024,16 +30602,17 @@ async function main(argv = process.argv) { console.error("internal error: no output mode resolved"); return 1; } -async function resolveSession(sessionArg, opts, logger, cwd) { +async function resolveSession(sessionArg, opts, logger, cwd, redactor) { + const selection = { source: opts.source }; if (opts.file) { - return { ok: true, match: await sessionFromFile(opts.file) }; + return { ok: true, match: await sessionFromFile(opts.file, opts.source) }; } if (opts.pick) { if (!process.stdin.isTTY) { console.error("error: --pick requires an interactive terminal; use --sessions instead"); return { ok: false, reason: "not_found" }; } - const picked = await pickSession(); + const picked = await pickSession({ ...selection, redactor }); if (!picked) { console.error("Selection cancelled."); return { ok: false, reason: "cancelled" }; @@ -30041,7 +30620,7 @@ async function resolveSession(sessionArg, opts, logger, cwd) { return { ok: true, match: picked }; } if (opts.latest) { - const latest = await findLatestSession(); + const latest = await findLatestSession(selection); if (!latest) { console.error("error: no sessions found; use --file to open an exported JSONL"); return { ok: false, reason: "not_found" }; @@ -30049,24 +30628,26 @@ async function resolveSession(sessionArg, opts, logger, cwd) { return { ok: true, match: latest }; } if (!sessionArg) { - const inProject = await findLatestSessionInProject(cwd); + const inProject = await findLatestSessionInProject(cwd, selection); if (inProject) { logger.debug("smart default \u2192 this project's latest session", { sessionId: inProject.sessionId }); return { ok: true, match: inProject }; } - const global = await findLatestSession(); + const global = await findLatestSession(selection); if (global) { console.error( - `(no session in this project \u2014 falling back to globally latest: ${global.sessionId.slice(0, 8)} from ${global.projectDir})` + redactor.apply( + `(no session in this project \u2014 falling back to globally latest: ${global.sessionId.slice(0, 8)} from ${global.projectDir})` + ) ); return { ok: true, match: global }; } console.error("error: no sessions found; use --file to open an exported JSONL"); return { ok: false, reason: "not_found" }; } - const matches = await locateSession(sessionArg); + const matches = await locateSession(sessionArg, selection); if (matches.length === 0) { console.error( `error: no session matched "${sessionArg}"; use --sessions to list available sessions` @@ -30076,10 +30657,12 @@ async function resolveSession(sessionArg, opts, logger, cwd) { if (matches.length > 1) { const lines = matches.slice(0, 5).map((m) => ` \u2022 ${m.sessionId} (${m.projectDir})`).join("\n"); console.error( - `error: "${sessionArg}" is ambiguous; ${matches.length} matches: + redactor.apply( + `error: "${sessionArg}" is ambiguous; ${matches.length} matches: ${lines} Re-run with a longer prefix.` + ) ); return { ok: false, reason: "not_found" }; } diff --git a/dist/mcp-server.js b/dist/mcp-server.js index 7ff3b4b..0a2ccd1 100755 --- a/dist/mcp-server.js +++ b/dist/mcp-server.js @@ -420,11 +420,11 @@ var require_codegen = __commonJS({ const rhs = this.rhs === void 0 ? "" : ` = ${this.rhs}`; return `${varKind} ${this.name}${rhs};` + _n; } - optimizeNames(names, constants) { + optimizeNames(names, constants2) { if (!names[this.name.str]) return; if (this.rhs) - this.rhs = optimizeExpr(this.rhs, names, constants); + this.rhs = optimizeExpr(this.rhs, names, constants2); return this; } get names() { @@ -441,10 +441,10 @@ var require_codegen = __commonJS({ render({ _n }) { return `${this.lhs} = ${this.rhs};` + _n; } - optimizeNames(names, constants) { + optimizeNames(names, constants2) { if (this.lhs instanceof code_1.Name && !names[this.lhs.str] && !this.sideEffects) return; - this.rhs = optimizeExpr(this.rhs, names, constants); + this.rhs = optimizeExpr(this.rhs, names, constants2); return this; } get names() { @@ -505,8 +505,8 @@ var require_codegen = __commonJS({ optimizeNodes() { return `${this.code}` ? this : void 0; } - optimizeNames(names, constants) { - this.code = optimizeExpr(this.code, names, constants); + optimizeNames(names, constants2) { + this.code = optimizeExpr(this.code, names, constants2); return this; } get names() { @@ -535,12 +535,12 @@ var require_codegen = __commonJS({ } return nodes.length > 0 ? this : void 0; } - optimizeNames(names, constants) { + optimizeNames(names, constants2) { const { nodes } = this; let i = nodes.length; while (i--) { const n = nodes[i]; - if (n.optimizeNames(names, constants)) + if (n.optimizeNames(names, constants2)) continue; subtractNames(names, n.names); nodes.splice(i, 1); @@ -593,12 +593,12 @@ var require_codegen = __commonJS({ return void 0; return this; } - optimizeNames(names, constants) { + optimizeNames(names, constants2) { var _a3; - this.else = (_a3 = this.else) === null || _a3 === void 0 ? void 0 : _a3.optimizeNames(names, constants); - if (!(super.optimizeNames(names, constants) || this.else)) + this.else = (_a3 = this.else) === null || _a3 === void 0 ? void 0 : _a3.optimizeNames(names, constants2); + if (!(super.optimizeNames(names, constants2) || this.else)) return; - this.condition = optimizeExpr(this.condition, names, constants); + this.condition = optimizeExpr(this.condition, names, constants2); return this; } get names() { @@ -621,10 +621,10 @@ var require_codegen = __commonJS({ render(opts) { return `for(${this.iteration})` + super.render(opts); } - optimizeNames(names, constants) { - if (!super.optimizeNames(names, constants)) + optimizeNames(names, constants2) { + if (!super.optimizeNames(names, constants2)) return; - this.iteration = optimizeExpr(this.iteration, names, constants); + this.iteration = optimizeExpr(this.iteration, names, constants2); return this; } get names() { @@ -660,10 +660,10 @@ var require_codegen = __commonJS({ render(opts) { return `for(${this.varKind} ${this.name} ${this.loop} ${this.iterable})` + super.render(opts); } - optimizeNames(names, constants) { - if (!super.optimizeNames(names, constants)) + optimizeNames(names, constants2) { + if (!super.optimizeNames(names, constants2)) return; - this.iterable = optimizeExpr(this.iterable, names, constants); + this.iterable = optimizeExpr(this.iterable, names, constants2); return this; } get names() { @@ -705,11 +705,11 @@ var require_codegen = __commonJS({ (_b = this.finally) === null || _b === void 0 ? void 0 : _b.optimizeNodes(); return this; } - optimizeNames(names, constants) { + optimizeNames(names, constants2) { var _a3, _b; - super.optimizeNames(names, constants); - (_a3 = this.catch) === null || _a3 === void 0 ? void 0 : _a3.optimizeNames(names, constants); - (_b = this.finally) === null || _b === void 0 ? void 0 : _b.optimizeNames(names, constants); + super.optimizeNames(names, constants2); + (_a3 = this.catch) === null || _a3 === void 0 ? void 0 : _a3.optimizeNames(names, constants2); + (_b = this.finally) === null || _b === void 0 ? void 0 : _b.optimizeNames(names, constants2); return this; } get names() { @@ -1010,7 +1010,7 @@ var require_codegen = __commonJS({ function addExprNames(names, from) { return from instanceof code_1._CodeOrName ? addNames(names, from.names) : names; } - function optimizeExpr(expr, names, constants) { + function optimizeExpr(expr, names, constants2) { if (expr instanceof code_1.Name) return replaceName(expr); if (!canOptimize(expr)) @@ -1025,14 +1025,14 @@ var require_codegen = __commonJS({ return items; }, [])); function replaceName(n) { - const c = constants[n.str]; + const c = constants2[n.str]; if (c === void 0 || names[n.str] !== 1) return n; delete names[n.str]; return c; } function canOptimize(e) { - return e instanceof code_1._Code && e._items.some((c) => c instanceof code_1.Name && names[c.str] === 1 && constants[c.str] !== void 0); + return e instanceof code_1._Code && e._items.some((c) => c instanceof code_1.Name && names[c.str] === 1 && constants2[c.str] !== void 0); } } function subtractNames(names, from) { @@ -2994,7 +2994,7 @@ var require_compile = __commonJS({ const schOrFunc = root.refs[ref]; if (schOrFunc) return schOrFunc; - let _sch = resolve5.call(this, root, ref); + let _sch = resolve7.call(this, root, ref); if (_sch === void 0) { const schema = (_a3 = root.localRefs) === null || _a3 === void 0 ? void 0 : _a3[ref]; const { schemaId } = this.opts; @@ -3021,7 +3021,7 @@ var require_compile = __commonJS({ function sameSchemaEnv(s1, s2) { return s1.schema === s2.schema && s1.root === s2.root && s1.baseId === s2.baseId; } - function resolve5(root, ref) { + function resolve7(root, ref) { let sch; while (typeof (sch = this.refs[ref]) == "string") ref = sch; @@ -3851,7 +3851,7 @@ var require_fast_uri = __commonJS({ } return uri; } - function resolve5(baseURI, relativeURI, options) { + function resolve7(baseURI, relativeURI, options) { const schemelessOptions = options ? Object.assign({ scheme: "null" }, options) : { scheme: "null" }; const { parsed: baseParsed, @@ -4220,7 +4220,7 @@ var require_fast_uri = __commonJS({ var fastUri = { SCHEMES, normalize, - resolve: resolve5, + resolve: resolve7, resolveComponent, equal, serialize, @@ -10531,7 +10531,7 @@ var init_js_yaml = __esm({ // src/mcp/server.ts import { stat } from "node:fs/promises"; -import { resolve as resolve4 } from "node:path"; +import { resolve as resolve6 } from "node:path"; // node_modules/zod/v3/helpers/util.js var util; @@ -18991,9 +18991,9 @@ function partPattern(schema) { return own2; } if (def.options) { - const sources = def.options.map(partPattern); - if (sources.every(Boolean)) - return `^(${sources.map((s) => cleanRegex(s)).join("|")})$`; + const sources2 = def.options.map(partPattern); + if (sources2.every(Boolean)) + return `^(${sources2.map((s) => cleanRegex(s)).join("|")})$`; } return leafPattern(schema); } @@ -19191,7 +19191,7 @@ var recursive = /* @__PURE__ */ new WeakMap(); var NONE = 0; var ASSUMED = 1; var PROVEN = 2; -function isRecursive(inst, stack, resolve5) { +function isRecursive(inst, stack, resolve7) { const cached2 = recursive.get(inst); if (cached2 !== void 0) return cached2 ? PROVEN : NONE; @@ -19201,7 +19201,7 @@ function isRecursive(inst, stack, resolve5) { let result = NONE; const check2 = (child) => { if (result !== PROVEN && child?._zod) { - const answer = isRecursive(child, stack, resolve5); + const answer = isRecursive(child, stack, resolve7); if (answer > result) result = answer; } @@ -19212,7 +19212,7 @@ function isRecursive(inst, stack, resolve5) { const desc = Object.getOwnPropertyDescriptor(sh, key); if (spread && !desc.enumerable) continue; - const child = desc.get ? ASSUMED : desc.value?._zod ? isRecursive(desc.value, stack, resolve5) : NONE; + const child = desc.get ? ASSUMED : desc.value?._zod ? isRecursive(desc.value, stack, resolve7) : NONE; if (child > answer) answer = child; } @@ -19276,7 +19276,7 @@ function isRecursive(inst, stack, resolve5) { break; // `$ZodLazy` caches its inner on the def, so a resolved edge is followed exactly case "lazy": { - const inner = def._cachedInner ?? (resolve5 ? inst._zod.innerType : void 0); + const inner = def._cachedInner ?? (resolve7 ? inst._zod.innerType : void 0); merge2(inner ? isRecursive(inner, stack, false) : ASSUMED); break; } @@ -29728,8 +29728,8 @@ function _stringFormat(Class2, format, fnOrRegex, _params = {}) { } // node_modules/zod/v4/core/to-json-schema.js -function assignProps(target, ...sources) { - for (const source of sources) { +function assignProps(target, ...sources2) { + for (const source of sources2) { for (const key of Reflect.ownKeys(source)) { if (Object.prototype.propertyIsEnumerable.call(source, key)) { assignProp(target, key, source[key]); @@ -37706,7 +37706,7 @@ var Protocol = class { return; } const pollInterval = task2.pollInterval ?? this._options?.defaultTaskPollInterval ?? 1e3; - await new Promise((resolve5) => setTimeout(resolve5, pollInterval)); + await new Promise((resolve7) => setTimeout(resolve7, pollInterval)); options?.signal?.throwIfAborted(); } } catch (error62) { @@ -37723,7 +37723,7 @@ var Protocol = class { */ request(request, resultSchema, options) { const { relatedRequestId, resumptionToken, onresumptiontoken, task, relatedTask } = options ?? {}; - return new Promise((resolve5, reject) => { + return new Promise((resolve7, reject) => { const earlyReject = (error62) => { reject(error62); }; @@ -37801,7 +37801,7 @@ var Protocol = class { if (!parseResult.success) { reject(parseResult.error); } else { - resolve5(parseResult.data); + resolve7(parseResult.data); } } catch (error62) { reject(error62); @@ -38063,12 +38063,12 @@ var Protocol = class { } } catch { } - return new Promise((resolve5, reject) => { + return new Promise((resolve7, reject) => { if (signal.aborted) { reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled")); return; } - const timeoutId = setTimeout(resolve5, interval); + const timeoutId = setTimeout(resolve7, interval); signal.addEventListener("abort", () => { clearTimeout(timeoutId); reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled")); @@ -39199,7 +39199,7 @@ var McpServer = class { let task = createTaskResult.task; const pollInterval = task.pollInterval ?? 5e3; while (task.status !== "completed" && task.status !== "failed" && task.status !== "cancelled") { - await new Promise((resolve5) => setTimeout(resolve5, pollInterval)); + await new Promise((resolve7) => setTimeout(resolve7, pollInterval)); const updatedTask = await extra.taskStore.getTask(taskId); if (!updatedTask) { throw new McpError(ErrorCode.InternalError, `Task ${taskId} not found during polling`); @@ -39863,12 +39863,12 @@ var StdioServerTransport = class { this.onclose?.(); } send(message) { - return new Promise((resolve5) => { + return new Promise((resolve7) => { const json2 = serializeMessage(message); if (this._stdout.write(json2)) { - resolve5(); + resolve7(); } else { - this._stdout.once("drain", resolve5); + this._stdout.once("drain", resolve7); } }); } @@ -40400,7 +40400,7 @@ function topN(counts, n) { } // src/cli/pipeline.ts -import { createHash as createHash2 } from "node:crypto"; +import { createHash as createHash3 } from "node:crypto"; // src/cache/disk.ts import { createHash } from "node:crypto"; @@ -40692,7 +40692,7 @@ function errMsg(err) { return status === null ? "network or client error" : `HTTP ${status}`; } function defaultSleep(ms) { - return new Promise((resolve5) => setTimeout(resolve5, ms)); + return new Promise((resolve7) => setTimeout(resolve7, ms)); } // src/utils/git.ts @@ -40721,7 +40721,7 @@ async function getGitContext(cwd) { }; } function runGit(cwd, args) { - return new Promise((resolve5) => { + return new Promise((resolve7) => { let settled = false; const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"] }); let stdout = ""; @@ -40733,7 +40733,7 @@ function runGit(cwd, args) { child.kill(); } catch { } - resolve5({ ok: false, stdout: "" }); + resolve7({ ok: false, stdout: "" }); }, TIMEOUT_MS); child.stdout?.on("data", (chunk) => { if (capped) return; @@ -40751,13 +40751,13 @@ function runGit(cwd, args) { if (settled) return; settled = true; clearTimeout(timer); - resolve5({ ok: false, stdout: "" }); + resolve7({ ok: false, stdout: "" }); }); child.on("close", (code) => { if (settled) return; settled = true; clearTimeout(timer); - resolve5({ ok: capped || code === 0, stdout }); + resolve7({ ok: capped || code === 0, stdout }); }); }); } @@ -41248,6 +41248,13 @@ function applyResult(entry, res, graph, jsonlPath, redactor, stats, logger2) { entry.node.context_snapshot_fork = buildForkSnapshot(snapInput); } +// src/sources/index.ts +import { open as open3 } from "node:fs/promises"; + +// src/sources/claude.ts +import { homedir as homedir3 } from "node:os"; +import { join as join3, resolve as resolve3 } from "node:path"; + // src/reader/jsonl.ts import { createReadStream as createReadStream2 } from "node:fs"; import { createInterface } from "node:readline"; @@ -41529,6 +41536,606 @@ function coerceRawEvent(obj, invalid) { } } +// src/sources/files.ts +import { lstat, readdir } from "node:fs/promises"; +var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +function isFullUuid(value) { + return UUID_RE.test(value); +} +function isUuidPrefix(value) { + return /^[0-9a-f-]+$/i.test(value) && value.length >= 4 && value.length <= 36; +} +function isMissingPath(error62) { + const code = error62?.code; + return code === "ENOENT" || code === "ENOTDIR"; +} +async function lstatIfPresent(path) { + try { + return await lstat(path); + } catch (error62) { + if (isMissingPath(error62)) return null; + throw error62; + } +} +async function readDirectory(path) { + try { + return await readdir(path, { withFileTypes: true }); + } catch (error62) { + if (isMissingPath(error62)) return []; + throw error62; + } +} +async function isSameDirectory(path, original) { + const current = await lstatIfPresent(path); + return current !== null && current.isDirectory() && current.dev === original.dev && current.ino === original.ino; +} + +// src/sources/claude.ts +function getProjectsRoot() { + return resolve3(process.env.CLAUDE_CONFIG_DIR || join3(homedir3(), ".claude"), "projects"); +} +function encodeProjectPath(absPath) { + return absPath.replace(/[^a-zA-Z0-9]/g, "-"); +} +async function discover(opts) { + const root = resolve3(opts.root ?? getProjectsRoot()); + const rootInfo = await lstatIfPresent(root); + if (!rootInfo?.isDirectory()) return []; + const projectDirs = opts.projectCwd === void 0 ? (await readDirectory(root)).filter((entry) => entry.isDirectory()).map((entry) => entry.name) : [encodeProjectPath(opts.projectCwd)]; + const sessions = []; + for (const projectDir of projectDirs) { + if (!projectDir) continue; + const projectPath = join3(root, projectDir); + const projectInfo = await lstatIfPresent(projectPath); + if (!projectInfo?.isDirectory()) continue; + const projectSessions = []; + for (const file2 of await readDirectory(projectPath)) { + if (!file2.isFile() || !file2.name.endsWith(".jsonl")) continue; + const sessionId = file2.name.slice(0, -".jsonl".length); + if (!isFullUuid(sessionId)) continue; + const jsonlPath = join3(projectPath, file2.name); + const info = await lstatIfPresent(jsonlPath); + if (!info?.isFile()) continue; + projectSessions.push({ + source: "claude", + sessionId, + projectDir, + jsonlPath, + mtimeMs: info.mtimeMs, + sizeBytes: info.size + }); + } + if (await isSameDirectory(projectPath, projectInfo)) { + for (const session of projectSessions) sessions.push(session); + } + } + return await isSameDirectory(root, rootInfo) ? sessions : []; +} +var claudeSource = { + id: "claude", + discover, + accepts: (record2) => typeof record2.uuid === "string" && typeof record2.type === "string" || record2.type === "permission-mode" || typeof record2.sessionId === "string" && typeof record2.type === "string", + read: readJsonl +}; + +// src/sources/codex.ts +import { constants } from "node:fs"; +import { open as open2 } from "node:fs/promises"; +import { homedir as homedir4 } from "node:os"; +import { join as join4, resolve as resolve4 } from "node:path"; + +// src/reader/codex.ts +import { createHash as createHash2 } from "node:crypto"; +import { createReadStream as createReadStream3 } from "node:fs"; +import { createInterface as createInterface2 } from "node:readline"; +function isRecord3(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function textField(obj, key, invalid, required2 = true) { + const value = obj[key]; + if (typeof value === "string") return value; + if (required2 || value !== void 0) invalid(`${key} must be a string`); + return void 0; +} +function identifier(obj, key, invalid) { + const value = textField(obj, key, invalid); + if (value !== void 0 && !value.trim()) { + invalid(`${key} must be nonempty`); + return void 0; + } + return value; +} +function publicText(value, allowed, invalid) { + if (!Array.isArray(value)) { + invalid("content must be an array"); + return []; + } + const blocks = []; + for (const block of value) { + if (!isRecord3(block) || typeof block.type !== "string" || !block.type.trim()) { + invalid("content blocks require a nonempty string type"); + continue; + } + if (!allowed.includes(block.type)) continue; + const text2 = textField(block, "text", invalid); + if (text2 !== void 0) blocks.push({ type: "text", text: text2 }); + } + return blocks; +} +function blockText(blocks) { + return blocks.map((block) => "text" in block && typeof block.text === "string" ? block.text : "").join("\n"); +} +function patchInput(name, input2) { + if (name.split(".").at(-1) !== "apply_patch") return input2; + const object3 = isRecord3(input2) ? input2 : void 0; + const patch = typeof input2 === "string" ? input2 : object3?.input ?? object3?.patch; + if (typeof patch !== "string") return input2; + const paths = /* @__PURE__ */ new Set(); + if (Array.isArray(object3?.paths)) { + for (const path of object3.paths) if (typeof path === "string") paths.add(path); + } + for (const line of patch.split("\n")) { + const match = /^\*\*\* (?:(?:Add|Update|Delete) File|Move to): (.+?)\r?$/.exec(line); + if (match && match[1].trim()) paths.add(match[1].trim()); + } + return { ...object3 ?? { input: patch }, paths: [...paths] }; +} +function toolCall(payload, invalid) { + const name = identifier(payload, "name", invalid); + const id = identifier(payload, "call_id", invalid); + let input2; + if (payload.type === "function_call") { + const argumentsText = textField(payload, "arguments", invalid); + if (argumentsText === void 0) return null; + try { + input2 = JSON.parse(argumentsText); + } catch { + invalid("arguments must contain valid JSON"); + return null; + } + } else { + input2 = textField(payload, "input", invalid); + if (input2 === void 0) return null; + } + return name && id ? { type: "tool_use", tool_use: { id, name, input: patchInput(name, input2) } } : null; +} +function localShell(payload, invalid) { + const id = identifier(payload, payload.call_id === void 0 ? "id" : "call_id", invalid); + if (!isRecord3(payload.action)) { + invalid("action must be an object"); + return null; + } + const action = payload.action; + const type = identifier(action, "type", invalid); + if (type !== "exec") return null; + if (!Array.isArray(action.command) || action.command.length === 0 || !action.command.every((part) => typeof part === "string")) { + invalid("command must be a nonempty array of strings"); + return null; + } + const input2 = { ...action }; + if (action.working_directory === null) { + delete input2.working_directory; + } else if (action.working_directory !== void 0 && typeof action.working_directory !== "string") { + invalid("working_directory must be a string"); + delete input2.working_directory; + } + if (action.timeout_ms === null) { + delete input2.timeout_ms; + } else if (action.timeout_ms !== void 0 && (typeof action.timeout_ms !== "number" || !Number.isFinite(action.timeout_ms) || action.timeout_ms < 0)) { + invalid("timeout_ms must be a nonnegative finite number"); + delete input2.timeout_ms; + } + return id ? { type: "tool_use", tool_use: { id, name: "local_shell", input: input2 } } : null; +} +function responseItem(payload, invalid) { + const type = identifier(payload, "type", invalid); + switch (type) { + case "message": { + const role = identifier(payload, "role", invalid); + const content = publicText(payload.content, ["input_text", "output_text"], invalid); + return (role === "user" || role === "assistant") && Array.isArray(payload.content) ? { type: role, message: { role, content } } : null; + } + case "function_call": + case "custom_tool_call": + return toolCall(payload, invalid); + case "function_call_output": + case "custom_tool_call_output": { + const id = identifier(payload, "call_id", invalid); + if (typeof payload.output !== "string" && !Array.isArray(payload.output)) { + invalid("output must be a string or an array"); + return null; + } + const content = typeof payload.output === "string" ? payload.output : publicText(payload.output, ["input_text", "output_text"], invalid); + return id ? { type: "tool_result", tool_result: { tool_use_id: id, content } } : null; + } + case "local_shell_call": + return localShell(payload, invalid); + case "reasoning": { + const summary = blockText(publicText(payload.summary, ["summary_text"], invalid)); + return summary ? { type: "system", payload: { type: "reasoning", summary } } : null; + } + default: + return null; + } +} +function permissionMode(payload, invalid) { + for (const key of ["permissionMode", "permission_mode"]) { + if (payload[key] !== void 0) return textField(payload, key, invalid); + } + return typeof payload.approval_policy === "string" ? payload.approval_policy : void 0; +} +function recordUuid(raw, lineNo) { + const hex3 = createHash2("sha256").update(`codex\0${lineNo}\0`).update(raw).digest("hex"); + const variant = (parseInt(hex3[16], 16) & 3 | 8).toString(16); + return `${hex3.slice(0, 8)}-${hex3.slice(8, 12)}-8${hex3.slice(13, 16)}-${variant}${hex3.slice(17, 20)}-${hex3.slice(20, 32)}`; +} +function suppressFallbacks(candidates) { + let role; + let turnId = ""; + let boundary = 0; + let suppressed = 0; + const pending = /* @__PURE__ */ new Map(); + for (const candidate of candidates) { + const origin = candidate.origin; + if (!origin) continue; + if (boundary !== origin.boundary || (!turnId || !origin.turnId) && (role !== origin.role || turnId !== origin.turnId)) { + pending.clear(); + } + role = origin.role; + turnId = origin.turnId; + boundary = origin.boundary; + const key = JSON.stringify([turnId, role, origin.text]); + const queue = pending.get(key); + if (queue && queue.kind !== origin.kind) { + const other = queue.items[queue.next++]; + if (origin.kind === "canonical" && (candidate.event.type === "user" || candidate.event.type === "assistant") && (other.event.type === "user" || other.event.type === "assistant")) { + other.event.message = candidate.event.message; + } + candidate.suppressed = true; + suppressed += 1; + if (queue.next === queue.items.length) pending.delete(key); + } else if (queue) { + queue.items.push(candidate); + } else { + pending.set(key, { kind: origin.kind, items: [candidate], next: 0 }); + } + } + return suppressed; +} +async function readCodex(path, opts = {}) { + const { logger: logger2, strict = false } = opts; + const stream = createReadStream3(path, { encoding: "utf8" }); + const lines = createInterface2({ input: stream, crlfDelay: Infinity }); + const meta3 = { sessionId: "", permissionMode: "" }; + const context = { cwd: "", gitBranch: "", version: "", entrypoint: "", isSidechain: false }; + const candidates = []; + let turnId = ""; + let boundary = 0; + let malformedCount = 0; + let skippedMetaCount = 0; + let lineNo = 0; + let sawMeta = false; + try { + for await (const raw of lines) { + lineNo += 1; + if (!raw.trim()) continue; + const issues = /* @__PURE__ */ new Set(); + const invalid = (reason) => { + if (strict) throw new Error(`codex jsonl error at line ${lineNo} \u2014 ${reason}`); + issues.add(reason); + }; + let parsed; + try { + parsed = JSON.parse(raw.trim()); + } catch { + invalid("invalid JSON"); + } + let event = null; + let origin; + let timestamp = ""; + let metadata = true; + if (parsed !== void 0) { + if (!isRecord3(parsed)) { + invalid("expected an object"); + } else { + const type = identifier(parsed, "type", invalid); + if (type && ["session_meta", "turn_context", "response_item", "event_msg", "compacted"].includes( + type + )) { + timestamp = textField(parsed, "timestamp", invalid) ?? ""; + if (typeof parsed.timestamp === "string" && !Number.isFinite(Date.parse(timestamp))) { + invalid("timestamp must be a valid date"); + } + if (parsed.ordinal !== void 0 && (typeof parsed.ordinal !== "number" || !Number.isSafeInteger(parsed.ordinal) || parsed.ordinal < 0)) { + invalid("ordinal must be a nonnegative safe integer"); + } + if (!isRecord3(parsed.payload)) { + invalid("payload must be an object"); + } else { + const payload = parsed.payload; + switch (type) { + case "session_meta": { + const id = identifier(payload, "id", invalid); + if (id && !isFullUuid(id)) invalid("session id must be a UUID"); + if (payload.session_id !== void 0 && (typeof payload.session_id !== "string" || !isFullUuid(payload.session_id))) { + invalid("root session id must be a UUID"); + } + if (id && isFullUuid(id)) { + if (meta3.sessionId && id.toLowerCase() !== meta3.sessionId.toLowerCase()) { + invalid("session metadata id changed"); + } else { + meta3.sessionId = id; + } + } + const cwd = identifier(payload, "cwd", invalid); + if (cwd !== void 0) context.cwd = cwd; + const version2 = textField(payload, "cli_version", invalid, false); + if (version2 !== void 0) context.version = version2; + if (payload.git !== void 0 && payload.git !== null) { + if (!isRecord3(payload.git)) invalid("git must be an object"); + else if (payload.git.branch !== null) { + const branch = textField(payload.git, "branch", invalid, false); + if (branch !== void 0) context.gitBranch = branch; + } + } + context.entrypoint = typeof payload.source === "string" ? payload.source : ""; + context.isSidechain = payload.source === "subagent" || isRecord3(payload.source) && Object.hasOwn(payload.source, "subagent"); + const permission = permissionMode(payload, invalid); + if (permission !== void 0) meta3.permissionMode = permission; + metadata = sawMeta; + sawMeta = true; + break; + } + case "turn_context": { + const cwd = identifier(payload, "cwd", invalid); + if (cwd !== void 0) context.cwd = cwd; + turnId = ""; + if (payload.turn_id !== void 0) { + const id = identifier(payload, "turn_id", invalid); + if (id !== void 0) turnId = id; + } + const permission = permissionMode(payload, invalid); + if (permission !== void 0) meta3.permissionMode = permission; + break; + } + case "response_item": + if (typeof payload.type === "string" && (payload.type.endsWith("_call") || payload.type.endsWith("_call_output"))) { + boundary += 1; + } + event = responseItem(payload, invalid); + if (event?.type === "user" || event?.type === "assistant") { + origin = { + kind: "canonical", + role: event.type, + turnId, + boundary, + text: blockText(event.message.content) + }; + } + break; + case "event_msg": { + const eventType = identifier(payload, "type", invalid); + if (payload.call_id !== void 0) boundary += 1; + if (payload.turn_id !== void 0 && typeof payload.turn_id === "string") + turnId = payload.turn_id; + if (eventType === "user_message" || eventType === "agent_message") { + const text2 = textField(payload, "message", invalid); + if (text2 !== void 0) { + const role = eventType === "user_message" ? "user" : "assistant"; + event = { type: role, message: { role, content: [{ type: "text", text: text2 }] } }; + origin = { kind: "fallback", role, text: text2, turnId, boundary }; + } + } + break; + } + case "compacted": { + boundary += 1; + const message = textField(payload, "message", invalid); + if (message !== void 0) + event = { type: "system", payload: { type: "compaction", message } }; + break; + } + } + } + } + } + } + if (event) { + candidates.push({ + event: { + ...context, + uuid: recordUuid(raw, lineNo), + parentUuid: null, + timestamp, + sessionId: meta3.sessionId, + userType: "", + ...event + }, + origin + }); + } else if (metadata && issues.size === 0) { + skippedMetaCount += 1; + } + if (issues.size > 0) { + malformedCount += 1; + logger2?.warn("malformed codex jsonl line; recovered valid fields where possible", { + lineNo, + reasons: [...issues] + }); + } + } + } finally { + lines.close(); + stream.destroy(); + } + skippedMetaCount += suppressFallbacks(candidates); + const events = []; + let parentUuid = null; + for (const candidate of candidates) { + if (candidate.suppressed) continue; + candidate.event.parentUuid = parentUuid; + events.push(candidate.event); + parentUuid = candidate.event.uuid; + } + return { meta: meta3, events, malformedCount, skippedMetaCount }; +} + +// src/sources/codex.ts +var ROLLOUT_NAME = /^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}-\d{2})?-([0-9a-fA-F-]{36})\.jsonl$/; +var DATE_DIRECTORIES = [/^\d{4}$/, /^(?:0[1-9]|1[0-2])$/, /^(?:0[1-9]|[12]\d|3[01])$/]; +var HEADER_LIMIT = 1024 * 1024; +function isRecord4(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +async function sessionEntry(jsonlPath, filenameId) { + const original = await lstatIfPresent(jsonlPath); + if (!original?.isFile()) return null; + let handle; + try { + handle = await open2( + jsonlPath, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK + ); + const info = await handle.stat(); + if (!info.isFile() || info.dev !== original.dev || info.ino !== original.ino) return null; + const chunks = []; + let length = 0; + let finished = false; + while (length < HEADER_LIMIT) { + const buffer = Buffer.allocUnsafe(Math.min(16 * 1024, HEADER_LIMIT - length)); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, null); + if (bytesRead === 0) { + finished = true; + break; + } + const newline = buffer.subarray(0, bytesRead).indexOf(10); + const used = newline === -1 ? bytesRead : newline; + chunks.push(buffer.subarray(0, used)); + length += used; + if (newline !== -1) { + finished = true; + break; + } + } + if (!finished) return null; + let header; + try { + header = JSON.parse(Buffer.concat(chunks, length).toString("utf8").trim()); + } catch { + return null; + } + if (!isRecord4(header) || header.type !== "session_meta" || !isRecord4(header.payload)) + return null; + const meta3 = header.payload; + const id = meta3.id; + if (typeof id !== "string" || !isFullUuid(id) || id.toLowerCase() !== filenameId.toLowerCase()) + return null; + if (meta3.session_id !== void 0 && (typeof meta3.session_id !== "string" || !isFullUuid(meta3.session_id))) + return null; + if (typeof meta3.cwd !== "string" || !meta3.cwd.trim()) return null; + if (meta3.source === "subagent" || isRecord4(meta3.source) && Object.hasOwn(meta3.source, "subagent")) + return null; + const current = await lstatIfPresent(jsonlPath); + if (!current?.isFile() || current.dev !== info.dev || current.ino !== info.ino) return null; + return { + source: "codex", + sessionId: id, + projectDir: meta3.cwd, + jsonlPath, + mtimeMs: info.mtimeMs, + sizeBytes: info.size + }; + } catch (error62) { + if (isMissingPath(error62) || error62?.code === "ELOOP") + return null; + throw error62; + } finally { + await handle?.close(); + } +} +async function discover2(options) { + const root = resolve4( + options.root ?? resolve4(process.env.CODEX_HOME || join4(homedir4(), ".codex"), "sessions") + ); + const projectCwd = options.projectCwd === void 0 ? void 0 : resolve4(options.projectCwd); + async function visit3(directory, depth) { + const original = await lstatIfPresent(directory); + if (!original?.isDirectory()) return []; + const sessions = []; + for (const entry of await readDirectory(directory)) { + if (depth < DATE_DIRECTORIES.length) { + if (entry.isDirectory() && DATE_DIRECTORIES[depth].test(entry.name)) { + for (const session of await visit3(join4(directory, entry.name), depth + 1)) + sessions.push(session); + } + } else if (entry.isFile()) { + const match = ROLLOUT_NAME.exec(entry.name); + if (!match || !isFullUuid(match[1])) continue; + const session = await sessionEntry(join4(directory, entry.name), match[1]); + if (session && (projectCwd === void 0 || resolve4(session.projectDir) === projectCwd)) { + sessions.push(session); + } + } + } + return await isSameDirectory(directory, original) ? sessions : []; + } + return visit3(root, 0); +} +var codexSource = { + id: "codex", + discover: discover2, + accepts: (record2) => typeof record2.type === "string" && ["session_meta", "turn_context", "response_item", "event_msg", "compacted"].includes( + record2.type + ) && isRecord4(record2.payload), + read: readCodex +}; + +// src/sources/index.ts +var sources = [claudeSource, codexSource]; +function getSessionSource(id = "claude") { + const source = sources.find((candidate) => candidate.id === id); + if (!source) throw new Error("Unsupported session source."); + return source; +} +async function detectSessionSource(path, selected) { + const file2 = await open3(path, "r"); + const buffer = Buffer.alloc(1024 * 1024); + let bytes = 0; + try { + while (bytes < buffer.length) { + const chunk = await file2.read(buffer, bytes, buffer.length - bytes, bytes); + if (chunk.bytesRead === 0) break; + bytes += chunk.bytesRead; + } + } finally { + await file2.close(); + } + const text2 = buffer.toString("utf8", 0, bytes); + const lines = text2.split("\n"); + if (bytes === buffer.length) lines.pop(); + for (const line of lines) { + if (!line.trim()) continue; + let record2; + try { + record2 = JSON.parse(line.trim()); + } catch { + continue; + } + if (!record2 || typeof record2 !== "object" || Array.isArray(record2)) continue; + const source = sources.find( + (candidate) => candidate.accepts(record2) + ); + if (!source) continue; + if (selected && source.id !== selected) { + throw new Error("Session file does not match the selected source."); + } + return source.id; + } + if (selected) return getSessionSource(selected).id; + if (bytes < buffer.length && !text2.trim()) return "claude"; + throw new Error("Cannot identify session format; select --source explicitly."); +} + // src/tree/builder.ts function buildMindMap(graph, segments, opts) { const generatedAt = opts.generatedAt ?? (/* @__PURE__ */ new Date()).toISOString(); @@ -41677,6 +42284,7 @@ function buildMindMap(graph, segments, opts) { (e) => e.type === "user" || e.type === "assistant" ).length; return { + source: opts.source ?? "claude", session_id: sessionId, project_path: selectedEvents[0]?.cwd ?? "", generated_at: generatedAt, @@ -41782,10 +42390,8 @@ function deriveSegmentLabel(seg, events, redactor) { const topFile = seg.dominant_files[0]; const topTool = seg.dominant_tools[0]; const eventCount = seg.event_uuids.length; - if (topFile && topTool) return truncate3(`${basename(topFile)} (${topTool})`, 60); - if (topFile) return truncate3(basename(topFile), 60); - if (topTool) return truncate3(topTool, 60); - return `${eventCount} events`; + const label = topFile && topTool ? `${basename(topFile)} (${topTool})` : topFile ? basename(topFile) : topTool ?? `${eventCount} events`; + return truncate3(redactor ? redactor.apply(label) : label, 60); } function looksLikeSystemNoise(text2) { const t = text2.trim(); @@ -41809,7 +42415,7 @@ function deriveSegmentSummary(seg) { return `events ${seg.start_index}\u2013${seg.end_index}${reasons}${files ? ` \xB7 files: ${files}` : ""}${tools ? ` \xB7 tools: ${tools}` : ""}`; } function deriveSessionTitle(events, sessionId, override, redactor) { - if (override) return truncate3(override, 60); + if (override) return truncate3(redactor ? redactor.apply(override) : override, 60); const firstSignificant = firstSignificantUserText(events); const firstUserText = firstSignificant ?? firstUserMessageText(events); if (firstUserText) { @@ -42161,139 +42767,6 @@ function luhnValid(digits) { return sum % 10 === 0; } -// src/utils/session_path.ts -import { lstat, readdir, realpath } from "node:fs/promises"; -import { homedir as homedir3 } from "node:os"; -import { basename as basename2, dirname, extname, join as join3, resolve as resolve3 } from "node:path"; -function getProjectsRoot() { - return resolve3(process.env.CLAUDE_CONFIG_DIR || join3(homedir3(), ".claude"), "projects"); -} -function encodeProjectPath(absPath) { - return absPath.replace(/[^a-zA-Z0-9]/g, "-"); -} -var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -var UUID_LOOSE = /^[0-9a-f-]+$/i; -function isFullUuid(s) { - return UUID_RE.test(s); -} -function isUuidPrefix(s) { - return UUID_LOOSE.test(s) && s.length >= 4 && s.length <= 36; -} -async function listSessions(opts = {}) { - if (opts.limit !== void 0 && (!Number.isSafeInteger(opts.limit) || opts.limit < 0)) { - throw new RangeError("session limit must be a nonnegative safe integer"); - } - if (opts.limit === 0) return []; - const root = resolve3(opts.projectsRoot ?? getProjectsRoot()); - const rootInfo = await lstatIfPresent(root); - if (!rootInfo?.isDirectory()) return []; - const projectDirs = opts.projectCwd === void 0 ? (await readDirectory(root)).filter((entry) => entry.isDirectory()).map((entry) => entry.name) : [encodeProjectPath(opts.projectCwd)]; - const sessions = []; - for (const projectDir of projectDirs) { - if (!projectDir) continue; - const projectPath = join3(root, projectDir); - const projectInfo = await lstatIfPresent(projectPath); - if (!projectInfo?.isDirectory()) continue; - const projectSessions = []; - for (const file2 of await readDirectory(projectPath)) { - if (!file2.isFile() || !file2.name.endsWith(".jsonl")) continue; - const sessionId = file2.name.slice(0, -".jsonl".length); - if (!isFullUuid(sessionId)) continue; - const jsonlPath = join3(projectPath, file2.name); - const info = await lstatIfPresent(jsonlPath); - if (!info?.isFile()) continue; - projectSessions.push({ - sessionId, - projectDir, - jsonlPath, - mtimeMs: info.mtimeMs, - sizeBytes: info.size - }); - } - if (await isSameDirectory(projectPath, projectInfo)) { - for (const session of projectSessions) sessions.push(session); - } - } - if (!await isSameDirectory(root, rootInfo)) return []; - sessions.sort(compareSessions); - return opts.limit === void 0 ? sessions : sessions.slice(0, opts.limit); -} -async function sessionFromFile(filePath) { - const jsonlPath = await realpath(resolve3(filePath)); - if (extname(jsonlPath) !== ".jsonl") { - throw new Error("session file must have a .jsonl extension"); - } - const info = await lstat(jsonlPath); - if (!info.isFile()) { - throw new Error("session file must be a regular file"); - } - return { - sessionId: basename2(jsonlPath, ".jsonl"), - projectDir: basename2(dirname(jsonlPath)), - jsonlPath - }; -} -async function locateSession(sessionIdOrPrefix, opts = {}) { - const prefix = sessionIdOrPrefix.toLowerCase(); - if (!isUuidPrefix(prefix) && !isFullUuid(prefix)) { - throw new Error(`session id "${sessionIdOrPrefix}" is not a valid UUID or prefix`); - } - const matches = (await listSessions({ projectsRoot: opts.projectsRoot })).filter( - (session) => session.sessionId.toLowerCase().startsWith(prefix) - ); - matches.sort((a, b) => { - if (opts.projectHint) { - const aHit = a.projectDir === opts.projectHint ? 1 : 0; - const bHit = b.projectDir === opts.projectHint ? 1 : 0; - if (aHit !== bHit) return bHit - aHit; - } - return compareSessions(a, b); - }); - return matches.map(toSessionMatch); -} -async function findLatestSession(projectsRoot = getProjectsRoot()) { - const [latest] = await listSessions({ projectsRoot, limit: 1 }); - return latest ? toSessionMatch(latest) : null; -} -async function findLatestSessionInProject(cwd, projectsRoot = getProjectsRoot()) { - const [latest] = await listSessions({ projectsRoot, projectCwd: cwd, limit: 1 }); - return latest ? toSessionMatch(latest) : null; -} -function compareSessions(a, b) { - return b.mtimeMs - a.mtimeMs || (a.jsonlPath < b.jsonlPath ? -1 : a.jsonlPath > b.jsonlPath ? 1 : 0); -} -function toSessionMatch(entry) { - return { - sessionId: entry.sessionId, - projectDir: entry.projectDir, - jsonlPath: entry.jsonlPath - }; -} -function isMissingPath(error62) { - const code = error62?.code; - return code === "ENOENT" || code === "ENOTDIR"; -} -async function lstatIfPresent(path) { - try { - return await lstat(path); - } catch (error62) { - if (isMissingPath(error62)) return null; - throw error62; - } -} -async function readDirectory(path) { - try { - return await readdir(path, { withFileTypes: true }); - } catch (error62) { - if (isMissingPath(error62)) return []; - throw error62; - } -} -async function isSameDirectory(path, original) { - const current = await lstatIfPresent(path); - return current !== null && current.isDirectory() && current.dev === original.dev && current.ino === original.ino; -} - // src/cli/pipeline.ts var SPEC_VERSION = "v0.3"; async function runPipeline(deps) { @@ -42306,6 +42779,7 @@ async function runPipeline(deps) { const cacheHash = await computeInputHash({ jsonlPath: match.jsonlPath, configJson: JSON.stringify({ + source: match.source, config: config2, llm: opts.llm !== false, model: opts.model, @@ -42316,12 +42790,14 @@ async function runPipeline(deps) { specVersion: SPEC_VERSION }); progress("[1/5] Parsing JSONL... "); - const { meta: meta3, events, malformedCount, skippedMetaCount } = await readJsonl(match.jsonlPath, { + const { meta: meta3, events, malformedCount, skippedMetaCount } = await getSessionSource( + match.source + ).read(match.jsonlPath, { logger: logger2, strict: opts.strict }); if (!isFullUuid(meta3.sessionId)) { - meta3.sessionId = createHash2("sha256").update(match.jsonlPath).digest("hex").slice(0, 32); + meta3.sessionId = createHash3("sha256").update(match.jsonlPath).digest("hex").slice(0, 32); } progress(`\u2714 ${events.length} events`); if (malformedCount > 0) progress(` (${malformedCount} malformed)`); @@ -42334,6 +42810,7 @@ async function runPipeline(deps) { } if (events.length === 0) { const emptyMindmap = { + source: match.source, session_id: meta3.sessionId, project_path: "", generated_at: (/* @__PURE__ */ new Date()).toISOString(), @@ -42386,6 +42863,7 @@ async function runPipeline(deps) { `); progress("[4/5] Building mindmap... "); const mindmap = buildMindMap(graph, segments, { + source: match.source, jsonlPath: match.jsonlPath, specVersion: SPEC_VERSION, redactor @@ -42809,7 +43287,7 @@ function createLoggerSync(level = "info") { } // src/utils/safe_path.ts -import { realpath as realpath2 } from "node:fs/promises"; +import { realpath } from "node:fs/promises"; import { isAbsolute } from "node:path"; async function safeGitCwd(eventsCwd, callerCwd) { for (const candidate of [eventsCwd, callerCwd]) { @@ -42817,7 +43295,7 @@ async function safeGitCwd(eventsCwd, callerCwd) { continue; } try { - const real = await realpath2(candidate); + const real = await realpath(candidate); if (isAbsolute(real)) return real; } catch { } @@ -42825,56 +43303,134 @@ async function safeGitCwd(eventsCwd, callerCwd) { return null; } +// src/utils/session_path.ts +import { lstat as lstat2, realpath as realpath2 } from "node:fs/promises"; +import { basename as basename2, dirname, extname, resolve as resolve5 } from "node:path"; +async function listSessions(opts = {}) { + if (opts.limit !== void 0 && (!Number.isSafeInteger(opts.limit) || opts.limit < 0)) { + throw new RangeError("session limit must be a nonnegative safe integer"); + } + if (opts.limit === 0) return []; + const sessions = await getSessionSource(opts.source).discover(opts); + sessions.sort(compareSessions); + return opts.limit === void 0 ? sessions : sessions.slice(0, opts.limit); +} +async function sessionFromFile(filePath, source) { + const jsonlPath = await realpath2(resolve5(filePath)); + if (extname(jsonlPath) !== ".jsonl") throw new Error("session file must have a .jsonl extension"); + const info = await lstat2(jsonlPath); + if (!info.isFile()) throw new Error("session file must be a regular file"); + return { + source: await detectSessionSource(jsonlPath, source), + sessionId: basename2(jsonlPath, ".jsonl"), + projectDir: basename2(dirname(jsonlPath)), + jsonlPath + }; +} +async function locateSession(sessionIdOrPrefix, opts = {}) { + const prefix = sessionIdOrPrefix.toLowerCase(); + if (!isUuidPrefix(prefix) && !isFullUuid(prefix)) { + throw new Error("session id is not a valid UUID or prefix"); + } + const matches = (await listSessions(opts)).filter( + (session) => session.sessionId.toLowerCase().startsWith(prefix) + ); + matches.sort((a, b) => { + if (opts.projectHint) { + const aHit = a.projectDir === opts.projectHint ? 1 : 0; + const bHit = b.projectDir === opts.projectHint ? 1 : 0; + if (aHit !== bHit) return bHit - aHit; + } + return compareSessions(a, b); + }); + return matches.map(toSessionMatch); +} +async function findLatestSession(opts = {}) { + const [latest] = await listSessions({ ...opts, limit: 1 }); + return latest ? toSessionMatch(latest) : null; +} +async function findLatestSessionInProject(cwd, opts = {}) { + return findLatestSession({ ...opts, projectCwd: cwd }); +} +function compareSessions(a, b) { + return b.mtimeMs - a.mtimeMs || (a.jsonlPath < b.jsonlPath ? -1 : a.jsonlPath > b.jsonlPath ? 1 : 0); +} +function toSessionMatch(entry) { + return { + source: entry.source, + sessionId: entry.sessionId, + projectDir: entry.projectDir, + jsonlPath: entry.jsonlPath + }; +} + // src/utils/picks.ts import { randomBytes } from "node:crypto"; -import { appendFile, mkdir as mkdir2, readFile as readFile3 } from "node:fs/promises"; -import { homedir as homedir4 } from "node:os"; -import { dirname as dirname2, join as join4 } from "node:path"; -var PICKS_ROOT = join4(homedir4(), ".cache", "agent-tree", "picks"); +import { appendFile, lstat as lstat3, mkdir as mkdir2, readFile as readFile3, readdir as readdir2 } from "node:fs/promises"; +import { homedir as homedir5 } from "node:os"; +import { dirname as dirname2, join as join5 } from "node:path"; +var PICKS_ROOT = join5(homedir5(), ".cache", "agent-tree", "picks"); +var PICK_SOURCES = ["claude", "codex"]; var SESSION_ID_RE = /^[0-9a-f-]{4,40}$/i; -function picksFileFor(sessionId, root = PICKS_ROOT) { - if (!SESSION_ID_RE.test(sessionId)) { +function validateSource(source) { + if (source !== "claude" && source !== "codex") { + throw new Error(`refusing to compose picks path for invalid source "${source}"`); + } +} +function picksFileFor(sessionId, root = PICKS_ROOT, source = "claude") { + validateSource(source); + if (typeof sessionId !== "string" || !SESSION_ID_RE.test(sessionId)) { throw new Error(`refusing to compose picks path for invalid sessionId "${sessionId}"`); } - return join4(root, `${sessionId}.jsonl`); + return join5(root, source, `${sessionId}.jsonl`); } async function recordPick(sessionId, nodeId, mode, opts = {}) { - const file2 = picksFileFor(sessionId, opts.root); + const file2 = picksFileFor(sessionId, opts.root, opts.source); await mkdir2(dirname2(file2), { recursive: true, mode: 448 }); const entry = { node_id: nodeId, mode, ts: (/* @__PURE__ */ new Date()).toISOString() }; await appendFile(file2, JSON.stringify(entry) + "\n", "utf8"); } async function listAllPicks(opts = {}) { + if (opts.source !== void 0) validateSource(opts.source); + const sources2 = opts.source === void 0 ? PICK_SOURCES : [opts.source]; const root = opts.root ?? PICKS_ROOT; - const { readdir: readdir2 } = await import("node:fs/promises"); - let files; try { - files = await readdir2(root); + if (!(await lstat3(root)).isDirectory()) return []; } catch { return []; } const out = []; - for (const fname of files) { - if (!fname.endsWith(".jsonl")) continue; - const sessionId = fname.slice(0, -".jsonl".length); - const filePath = join4(root, fname); - let raw; + for (const source of sources2) { + const directory = join5(root, source); + let files; try { - raw = await readFile3(filePath, "utf8"); + if (!(await lstat3(directory)).isDirectory()) continue; + files = await readdir2(directory, { withFileTypes: true }); } catch { continue; } - const picks = []; - for (const line of raw.split("\n")) { - const trimmed = line.trim(); - if (!trimmed) continue; + for (const file2 of files) { + if (!file2.isFile() || !file2.name.endsWith(".jsonl")) continue; + const sessionId = file2.name.slice(0, -".jsonl".length); + if (!SESSION_ID_RE.test(sessionId)) continue; + let raw; try { - const parsed = JSON.parse(trimmed); - if (parsed.node_id) picks.push(parsed); + raw = await readFile3(join5(directory, file2.name), "utf8"); } catch { + continue; + } + const picks = []; + for (const line of raw.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const parsed = JSON.parse(trimmed); + if (parsed.node_id) picks.push(parsed); + } catch { + } } + if (picks.length > 0) out.push({ source, sessionId, picks }); } - if (picks.length > 0) out.push({ sessionId, picks }); } out.sort((a, b) => { const ta = a.picks[a.picks.length - 1]?.ts ?? ""; @@ -42884,7 +43440,7 @@ async function listAllPicks(opts = {}) { return out; } async function removePicksForNode(sessionId, nodeId, opts = {}) { - const file2 = picksFileFor(sessionId, opts.root); + const file2 = picksFileFor(sessionId, opts.root, opts.source); let raw; try { raw = await readFile3(file2, "utf8"); @@ -42912,16 +43468,15 @@ async function removePicksForNode(sessionId, nodeId, opts = {}) { const { writeFile: writeFile2, rename } = await import("node:fs/promises"); const rand = randomBytes(4).toString("hex"); const tmp = `${file2}.tmp-${process.pid}-${Date.now()}-${rand}`; - await writeFile2( - tmp, - kept.length > 0 ? kept.join("\n") + "\n" : "", - { encoding: "utf8", mode: 384 } - ); + await writeFile2(tmp, kept.length > 0 ? kept.join("\n") + "\n" : "", { + encoding: "utf8", + mode: 384 + }); await rename(tmp, file2); return removed; } async function readPicks(sessionId, opts = {}) { - const file2 = picksFileFor(sessionId, opts.root); + const file2 = picksFileFor(sessionId, opts.root, opts.source); const index = { modesByNode: /* @__PURE__ */ new Map(), total: 0 }; let raw; try { @@ -42948,14 +43503,16 @@ async function readPicks(sessionId, opts = {}) { } // src/version.ts -var VERSION = true ? "0.2.1" : JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version; +var VERSION = true ? "0.3.0" : JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version; // src/mcp/server.ts var logger = createLoggerSync("warn"); +var sourceInput = external_exports.enum(["claude", "codex"]).optional().describe("Session source. Discovery defaults to claude; file imports auto-detect."); var sessionInput = { + source: sourceInput, sessionId: external_exports.string().optional().describe("UUID prefix; omit for the latest session in cwd."), - file: external_exports.string().optional().describe("Exported Claude Code JSONL path; mutually exclusive with sessionId."), - cwd: external_exports.string().min(1).transform((cwd) => resolve4(cwd)).describe("Caller project directory for session selection and configuration.") + file: external_exports.string().optional().describe("Claude Code or Codex JSONL path; mutually exclusive with sessionId."), + cwd: external_exports.string().min(1).transform((cwd) => resolve6(cwd)).describe("Caller project directory for session selection and configuration.") }; var readOnly = { readOnlyHint: true, destructiveHint: false, openWorldHint: false }; function text(value) { @@ -42971,17 +43528,17 @@ function safely(handler) { } }; } -async function resolveMatch({ sessionId, file: file2, cwd }) { +async function resolveMatch({ sessionId, file: file2, cwd, source }) { if (file2) { if (sessionId) throw new Error("Use either sessionId or file, not both."); - return sessionFromFile(file2); + return sessionFromFile(file2, source); } if (sessionId) { - const matches = await locateSession(sessionId); + const matches = await locateSession(sessionId, { source }); if (matches.length > 1) throw new Error("Ambiguous session id; use a longer UUID prefix."); if (matches.length === 1) return matches[0]; } else { - const match = await findLatestSessionInProject(cwd) ?? await findLatestSession(); + const match = await findLatestSessionInProject(cwd, { source }) ?? await findLatestSession({ source }); if (match) return match; } throw new Error("No matching session found."); @@ -42995,6 +43552,7 @@ function createServer() { const { config: config2 } = await loadConfig({ projectCwd: input2.cwd, logger }); const info = await stat(match.jsonlPath); const key = JSON.stringify([ + match.source, match.jsonlPath, info.mtimeMs, info.ctimeMs, @@ -43027,25 +43585,26 @@ function createServer() { server.registerTool( "agent_tree_sessions", { - description: "List recent Claude Code sessions without parsing their contents. Returns ids, paths, modification times and sizes.", + description: "List recent sessions for one source (default claude). Codex reads metadata headers only. Returns sources, ids, paths, modification times and sizes.", inputSchema: { - cwd: external_exports.string().min(1).transform((cwd) => resolve4(cwd)).optional().describe("Restrict discovery to this project. Omit to search all projects."), + source: sourceInput, + cwd: external_exports.string().min(1).transform((cwd) => resolve6(cwd)).optional().describe("Restrict discovery to this project. Omit to search all projects."), limit: external_exports.number().int().min(1).max(1e3).default(20) }, annotations: readOnly }, - safely(async ({ cwd, limit }) => { + safely(async ({ cwd, limit, source }) => { if (cwd && !(await stat(cwd)).isDirectory()) throw new Error("cwd must name a directory."); const { config: config2 } = await loadConfig({ projectCwd: cwd, logger }); const redactor = buildRedactor({}, config2, logger); - const sessions = redactDeep(await listSessions({ projectCwd: cwd, limit }), redactor); + const sessions = redactDeep(await listSessions({ source, projectCwd: cwd, limit }), redactor); return { ...text(JSON.stringify({ sessions }, null, 2)), structuredContent: { sessions } }; }) ); server.registerTool( "agent_tree_list", { - description: "Render a numbered tree of a Claude Code session, or export its complete redacted mindmap as JSON.", + description: "Render a numbered tree of a Claude Code or Codex session, or export its complete redacted mindmap as JSON.", inputSchema: { ...sessionInput, phasesOnly: external_exports.boolean().optional().describe("Hide sub-actions; show phase headers only."), @@ -43064,7 +43623,7 @@ function createServer() { return { ...text(JSON.stringify(mindmap, null, 2)), structuredContent: { mindmap } }; } if (result.isEmpty) return text("Session is empty."); - const picks = await readPicks(match.sessionId); + const picks = await readPicks(match.sessionId, { source: match.source }); const tree = renderTextTree(result.mindmap, { filter: input2.filter, groupConsecutive: true, @@ -43077,7 +43636,7 @@ function createServer() { (${picks.modesByNode.size} starred nodes, ${picks.total} total picks)` : ""; return text( result.redactor.apply( - `Session ${match.sessionId.slice(0, 8)} (${match.projectDir}) + `${match.source} session ${match.sessionId.slice(0, 8)} (${match.projectDir}) ${tree.text}${footer}` ) @@ -43110,7 +43669,7 @@ ${tree.text}${footer}` const index = markdown.indexOf("## Full session reference"); markdown = index >= 0 ? markdown.slice(0, index) + gitMd + "\n\n" + markdown.slice(index) : markdown + "\n\n" + gitMd + "\n"; } - await recordPick(match.sessionId, node2.id, input2.mode).catch( + await recordPick(match.sessionId, node2.id, input2.mode, { source: match.source }).catch( (error62) => logger.warn("pick history write failed", { error: redactor.apply(String(error62)) }) ); return text(redactor.apply(markdown)); @@ -43120,16 +43679,18 @@ ${tree.text}${footer}` "agent_tree_picks", { description: "List every recorded pick across every session: session, node, mode and timestamp.", - inputSchema: {}, + inputSchema: { source: sourceInput }, annotations: readOnly }, - safely(async () => { - const all = await listAllPicks(); + safely(async ({ source }) => { + const all = await listAllPicks({ source }); if (all.length === 0) return text("No picks recorded yet."); const lines = []; let total = 0; for (const session of all) { - lines.push(`session ${session.sessionId.slice(0, 8)} (${session.picks.length} picks)`); + lines.push( + `${session.source} session ${session.sessionId.slice(0, 8)} (${session.picks.length} picks)` + ); for (const pick3 of session.picks) { lines.push(` ${pick3.ts} ${pick3.mode} ${pick3.node_id}`); total++; @@ -43205,7 +43766,7 @@ ${tree.text}${footer}` if (result.isEmpty) throw new Error("Session is empty."); const node2 = lookupSnapshot(result.mindmap, input2.nodeId, renderTextTree(result.mindmap)); if (!node2) throw new Error("No matching node."); - const removed = await removePicksForNode(match.sessionId, node2.id); + const removed = await removePicksForNode(match.sessionId, node2.id, { source: match.source }); return text( removed === 0 ? `No picks recorded for ${node2.id}.` : `Unstarred ${node2.id}; removed ${removed} pick entries.` ); diff --git a/package-lock.json b/package-lock.json index 32d3a92..a05005e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@seungwoolee/agent-tree", - "version": "0.2.1", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@seungwoolee/agent-tree", - "version": "0.2.1", + "version": "0.3.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.32.0", diff --git a/package.json b/package.json index 2a1409f..ba7abd9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@seungwoolee/agent-tree", - "version": "0.2.1", - "description": "Discover, navigate, export and resume Claude Code sessions from your terminal or MCP client.", + "version": "0.3.0", + "description": "Discover, navigate and export Claude Code and Codex sessions, with resume prompts for your terminal or MCP client.", "license": "MIT", "author": "Seungwoo Lee", "homepage": "https://github.com/lifrary/agent-tree", @@ -68,6 +68,7 @@ "keywords": [ "claude", "claude-code", + "codex", "mindmap", "session", "visualizer", diff --git a/skills/agent-tree/SKILL.md b/skills/agent-tree/SKILL.md index a93d872..b512d5d 100644 --- a/skills/agent-tree/SKILL.md +++ b/skills/agent-tree/SKILL.md @@ -1,20 +1,20 @@ --- name: agent-tree -description: Use when the user asks to "map a session", "show me the tree", "agent-tree", "/agent-tree", "resume from a node", "fork from this session", find recent sessions, or inspect a portable Claude Code JSONL export. Renders the session as a numbered file-tree and emits a continue/fork resume block on selection. Six MCP tools (agent_tree_sessions / agent_tree_list / agent_tree_snapshot / agent_tree_picks / agent_tree_diff / agent_tree_unstar) and a CLI fallback. -version: 0.2.1 +description: Use when the user asks to "map a session", "show me the tree", "agent-tree", "/agent-tree", "resume from a node", "fork from this session", find recent sessions, or inspect a Claude Code or Codex JSONL export. Renders the session as a numbered file-tree and emits a continue/fork resume block on selection. Six MCP tools and a CLI fallback. +version: 0.3.0 --- # agent-tree skill -In-session mindmap for a previous Claude Code session. The skill renders a +In-session mindmap for a previous Claude Code or Codex session. The skill renders a numbered text tree directly in chat, the user picks a number, and you paste -the resume context so they can drop it into a fresh `claude` session. +the resume context so they can drop it into a fresh coding-agent session. This is a terminal-only tool — everything happens inside the current Claude Code conversation (no browser, no HTML). Structured JSON export is also -available. Requires Node.js ≥22.13.0. This skill describes upcoming 0.2.1; -the npm registry may still serve an earlier release. Only Claude Code JSONL -is supported, not Codex or Gemini native session formats. +available. Requires Node.js ≥22.13.0. The npm registry may still serve an +earlier release; check the installed version. Claude Code JSONL and Codex +rollout JSONL are supported; Gemini native session formats are not. ## When to invoke @@ -42,24 +42,27 @@ registered (no per-call CLI subprocess), fall back to the CLI otherwise. When the `agent-tree` MCP server is connected (via plugin install): -- `agent_tree_sessions({ cwd?, limit? })` → redacted recent-session catalog; default limit 20, integer 1–1000 -- `agent_tree_list({ cwd, sessionId?, file?, phasesOnly?, filter?, format? })` → numbered text tree by default, or complete redacted mindmap with `format: "json"` -- `agent_tree_snapshot({ cwd, nodeId, mode?, sessionId?, file? })` → resume markdown and records the pick; mode defaults to `continue` -- `agent_tree_picks({})` → lists every recorded pick across every session -- `agent_tree_diff({ cwd, from, to, sessionId?, file? })` → summarises what happened between two nodes -- `agent_tree_unstar({ cwd, nodeId, sessionId?, file? })` → removes the ⭐ from a node +- `agent_tree_sessions({ source?, cwd?, limit? })` → redacted recent-session catalog; default limit 20, integer 1–1000 +- `agent_tree_list({ source?, cwd, sessionId?, file?, phasesOnly?, filter?, format? })` → numbered text tree by default, or complete redacted mindmap with `format: "json"` +- `agent_tree_snapshot({ source?, cwd, nodeId, mode?, sessionId?, file? })` → resume markdown and records the pick; mode defaults to `continue` +- `agent_tree_picks({ source? })` → lists recorded picks across both sources, or the selected source +- `agent_tree_diff({ source?, cwd, from, to, sessionId?, file? })` → summarises what happened between two nodes +- `agent_tree_unstar({ source?, cwd, nodeId, sessionId?, file? })` → removes a star from a node Per-session tools require the caller's `cwd` for project discovery and configuration. Choose `sessionId` or `file`, never both; use an absolute path for portable exports. Omit both for the project's latest session, with a global fallback if none exists. `agent_tree_sessions` searches all projects -when `cwd` is omitted; `agent_tree_picks` takes `{}` only. +when `cwd` is omitted. `source` is `claude` or `codex`: discovery defaults +to Claude, file imports auto-detect, and picks without a filter span both. +Keep the same source when following up on a discovered session. JSON list output rejects nonempty `filter` and `phasesOnly: true`. Catalog responses contain `structuredContent: { sessions: [...] }`; JSON list responses contain `structuredContent: { mindmap: {...} }`, plus serialized -JSON text. Each catalog entry has `sessionId`, `projectDir`, `jsonlPath`, +JSON text. Each catalog entry has `source`, `sessionId`, `projectDir`, `jsonlPath`, `mtimeMs`, and `sizeBytes`. Treat JSON as data, not numbered display rows. +JSON mindmaps also carry top-level `source`. MCP always uses heuristic labels and makes no LLM calls. ### CLI fallback @@ -76,6 +79,8 @@ agent-tree --sessions --limit 20 --json agent-tree --cwd /path/to/project --sessions agent-tree --file /path/to/export.jsonl --no-llm --list agent-tree --file /path/to/export.jsonl --no-llm --strict --json +agent-tree --source codex --cwd /path/to/project --sessions +agent-tree --source codex --no-llm --snapshot --mode fork ``` The CLI is `agent-tree` (alias `atree`), installed globally via npm. If the @@ -89,6 +94,10 @@ Selectors and output modes are mutually exclusive; invalid combinations exit with code 2. `--strict` rejects malformed JSONL instead of recovering; it is an analysis flag, not a catalog option. +`--source claude|codex` selects discovery and pick-history filtering. Explicit +files auto-detect their source; a recognized header that disagrees with an +explicit source is an error. Unknown/headerless files need an explicit source. + ### Step 1 — pick a session Default to the caller's project, not the globally latest session. Discover @@ -113,13 +122,26 @@ preflight counts and reserves before paid calls; a failed count makes no paid labeling call for that segment. Reservations are not refunded after failure, and output charges are separate: this is not a monetary cap. -Discovery uses `/projects/`, only regular +Claude discovery uses `/projects/`, only regular UUID-named `.jsonl` files, and excludes agent/subagent and symlink entries. Project encoding replaces every non-ASCII-alphanumeric character with `-`. An imported export without a valid session UUID gets a stable path-derived 32-hex local history identity; continue selecting it with `--file`, not that identity as a UUID prefix. +Codex discovery uses `/sessions/YYYY/MM/DD/`, reads +only rollout metadata headers, and excludes subagent sessions and symlinks. +Archived sessions and subagent exports can be opened explicitly with `--file`. +Project matching uses the metadata cwd. Tool calls/results and public reasoning +summaries are normalized; duplicate message notifications are not extra turns. +Compaction is represented as a system event without replaying replacement +history. Encrypted reasoning is not decoded. Session-source adapters do not +reconstruct cross-session fork ancestry. + +Stars are stored separately in +`~/.cache/agent-tree/picks//.jsonl`. Flat-directory stars +from earlier releases are not read or migrated. + `--list` is the skill-friendly mode: it prints a numbered ASCII tree to stdout. **Show this output verbatim to the user** in a fenced code block: @@ -175,8 +197,9 @@ any commentary inside the fence: After the fence, tell the user: - The snapshot is now visible above. -- They should open a new `claude` session and paste it as the first message. -- The new session will resume from that point with the chosen mode. +- They should open a new Claude Code or Codex session and paste it as the first message. +- This supplies context only: it does not invoke native resume/fork commands, + restore files, or guarantee the agent will reproduce the original execution. ### Step 3 — handle errors