import { closeSync, copyFileSync, createReadStream, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readlinkSync, readSync, realpathSync, statSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import path from "node:path"; import { createInterface } from "node:readline"; import { spawnSync } from "node:child_process"; import { repoRoot } from "./config"; import type { RenderedCliResult } from "./output"; type CodexTraceAction = "help" | "list" | "collect" | "show" | "grep" | "active"; type CodexTraceKind = "session-jsonl" | "history-jsonl" | "shell-snapshot" | "log" | "trace-named" | "jsonl" | "sqlite-db" | "sqlite-sidecar" | "unrecognized"; type CodexTraceOutput = "text" | "wide" | "name" | "json" | "yaml"; interface CodexTraceOptions { action: CodexTraceAction; root: string; outputDir: string | null; file: string | null; session: string | null; limit: number; maxDepth: number; maxFileBytes: number; tailBytes: number; includeSqlite: boolean; includeSensitive: boolean; includeCache: boolean; dryRun: boolean; full: boolean; allowOutsideRoot: boolean; pattern: string | null; fixed: boolean; deep: boolean; allFiles: boolean; messagesOnly: boolean; toolsOnly: boolean; failedOnly: boolean; includeOutput: boolean; tool: string | null; includeSystem: boolean; contextChars: number; since: string | null; fileLimit: number; output: CodexTraceOutput; } interface CodexTraceCandidate { path: string; relativePath: string; kind: CodexTraceKind; bytes: number; mtimeMs: number; included: boolean; skippedReason: string | null; } interface ToolCallInfo { name: string | null; input: string | null; line: number; timestamp: string | null; } const defaultLimit = 30; const maxLimit = 500; const defaultMaxDepth = 8; const maxDepthLimit = 16; const defaultMaxFileBytes = 25 * 1024 * 1024; const maxFileBytesLimit = 512 * 1024 * 1024; const defaultTailBytes = 12_000; const maxTailBytes = 1_000_000; const defaultContextChars = 700; const maxContextChars = 4_000; const defaultFileLimit = 8; const maxFileLimit = 50; export function codexTraceHelp(): Record { return { ok: true, command: "codex trace", output: "text", description: "Collect bounded local Codex trace artifacts. Defaults to ~/.codex and can scan another root with --root .", usage: [ "bun scripts/cli.ts codex trace list [--root ~/.codex] [--limit 30]", "bun scripts/cli.ts codex trace active [--root ~/.codex]", "bun scripts/cli.ts codex trace grep --session --pattern 'playwright|auth-login-failed' [--since ISO]", "bun scripts/cli.ts codex trace grep --session --messages --pattern 'Playwright|web-probe'", "bun scripts/cli.ts codex trace grep --session --failed-only [--tool exec_command]", "bun scripts/cli.ts codex trace grep --pattern 'playwright|auth-login-failed' [--file sessions/...jsonl] [--since ISO]", "bun scripts/cli.ts codex trace collect [--root ~/.codex] [--output .state/codex-trace/] [--limit 30]", "bun scripts/cli.ts codex trace show --session [--root ~/.codex] [--tail-bytes 12000]", ], safety: [ "Default scan includes sessions/*.jsonl, history.jsonl, shell_snapshots, *.log, and trace-named text files.", "auth.json, config.toml, cache/.tmp/generated_images, and sqlite files are skipped by default.", "grep parses JSONL records, skips bootstrap/system payloads by default, and returns bounded match-centered summaries instead of whole lines.", "Use --include-sqlite or --include-sensitive only for reviewed local diagnostics; values are not printed unless show reads a text file.", "collect copies bounded files and writes a manifest; it does not compress, upload, or delete source files.", ], options: { "--root ": "Codex home or another trace root. Defaults to ~/.codex.", "--output ": "Collect destination. Defaults to .state/codex-trace/.", "--limit ": `Maximum included files, default ${defaultLimit}, max ${maxLimit}.`, "--max-depth ": `Recursive scan depth, default ${defaultMaxDepth}, max ${maxDepthLimit}.`, "--max-file-bytes ": "Skip files larger than this during collect/list inclusion.", "--include-sqlite": "Include *.sqlite and sqlite sidecars if under max size.", "--include-sensitive": "Allow auth/config-looking files. Off by default.", "--include-cache": "Traverse cache, .tmp, generated_images, plugins, and skills directories. Off by default.", "--dry-run": "For collect, report the plan without copying files.", "--file ": "For show, file path relative to --root or an absolute path under --root.", "--session ": "For show/grep, resolve a Codex session by full or short session id instead of copying the JSONL path.", "--pattern ": "For grep, pattern to match against structured event text.", "--fixed": "For grep, treat --pattern as a literal string.", "--deep": "For grep, parse every JSONL line. Default uses a raw-line prefilter for speed.", "--all-files": "For grep without --file/--session, scan all included trace files instead of active/recent sessions only.", "--messages": "For grep, only return user/assistant message events.", "--tools": "For grep, only return tool calls/outputs.", "--failed-only": "For grep, only return failed tool outputs with folded error summaries.", "--tool ": "For grep, restrict tool calls/outputs by tool name such as exec_command or imagegen.", "--include-output": "For grep, allow matched tool output snippets. Default folds tool output and shows input/error summary.", "--since ": "For grep, skip JSONL events before the timestamp string.", "--context-chars ": `For grep, max summary characters per match, default ${defaultContextChars}, max ${maxContextChars}.`, "--file-limit ": `For grep without --file, max target files, default ${defaultFileLimit}, max ${maxFileLimit}.`, "--include-system": "For grep, include session_meta/system instruction records. Off by default.", "-o json|yaml|name|wide": "Output mode. Default is concise text/table.", "--tail-bytes ": `For show, tail bytes to return, default ${defaultTailBytes}, max ${maxTailBytes}.`, "--full": "For show, read from the beginning up to --max-file-bytes instead of tailing.", }, }; } export async function runCodexTraceCommand(args: string[]): Promise | RenderedCliResult> { const options = parseCodexTraceOptions(args); if (options.action === "help") return renderCodexTraceResult(options, "codex trace help", codexTraceHelp()); if (options.action === "active") return codexTraceActive(options); if (options.action === "show") return codexTraceShow(options); if (options.action === "grep") return codexTraceGrep(options); const candidates = scanCodexTraceRoot(options); if (options.action === "list") return codexTraceList(options, candidates); return codexTraceCollect(options, candidates); } function parseCodexTraceOptions(args: string[]): CodexTraceOptions { const rawAction = args[0]; const action: CodexTraceAction = rawAction === undefined || rawAction === "help" || rawAction === "--help" || rawAction === "-h" ? "help" : rawAction === "list" || rawAction === "collect" || rawAction === "show" || rawAction === "grep" || rawAction === "active" ? rawAction : (() => { throw new Error(`codex trace action must be one of: list, active, grep, collect, show, help; got ${rawAction}`); })(); const optionArgs = action === "help" && rawAction === undefined ? args : args.slice(1); assertKnownOptions(optionArgs, { flags: ["--dry-run", "--full", "--include-sqlite", "--include-sensitive", "--include-cache", "--allow-outside-root", "--include-system", "--fixed", "--deep", "--all-files", "--messages", "--tools", "--failed-only", "--include-output", "--wide", "--help", "-h"], values: ["--root", "--output", "--file", "--session", "--limit", "--max-depth", "--max-file-bytes", "--tail-bytes", "--pattern", "--tool", "--since", "--context-chars", "--file-limit", "-o", "--format"], }); if (optionArgs.includes("--help") || optionArgs.includes("-h")) { return { action: "help", root: defaultCodexRoot(), outputDir: null, file: null, session: null, limit: defaultLimit, maxDepth: defaultMaxDepth, maxFileBytes: defaultMaxFileBytes, tailBytes: defaultTailBytes, includeSqlite: false, includeSensitive: false, includeCache: false, dryRun: false, full: false, allowOutsideRoot: false, pattern: null, fixed: false, deep: false, allFiles: false, messagesOnly: false, toolsOnly: false, failedOnly: false, includeOutput: false, tool: null, includeSystem: false, contextChars: defaultContextChars, since: null, fileLimit: defaultFileLimit, output: "text", }; } const root = resolveUserPath(optionValue(optionArgs, "--root") ?? defaultCodexRoot()); const positionalPattern = action === "grep" && optionArgs[0] !== undefined && !optionArgs[0].startsWith("-") ? optionArgs[0] : null; const output = parseOutputMode(optionValue(optionArgs, "-o") ?? optionValue(optionArgs, "--format"), hasFlag(optionArgs, "--wide")); const file = optionValue(optionArgs, "--file") ?? null; const session = optionValue(optionArgs, "--session") ?? null; if (file !== null && session !== null) throw new Error("codex trace accepts either --file or --session, not both"); const messagesOnly = hasFlag(optionArgs, "--messages"); const toolsOnly = hasFlag(optionArgs, "--tools") || hasFlag(optionArgs, "--failed-only") || optionValue(optionArgs, "--tool") !== undefined; if (messagesOnly && toolsOnly) throw new Error("codex trace grep accepts either --messages or tool filters, not both"); return { action, root, outputDir: optionValue(optionArgs, "--output") === undefined ? null : resolveUserPath(optionValue(optionArgs, "--output") ?? ""), file, session, limit: positiveIntegerOption(optionArgs, "--limit", defaultLimit, maxLimit), maxDepth: positiveIntegerOption(optionArgs, "--max-depth", defaultMaxDepth, maxDepthLimit), maxFileBytes: bytesOption(optionArgs, "--max-file-bytes", defaultMaxFileBytes, maxFileBytesLimit), tailBytes: positiveIntegerOption(optionArgs, "--tail-bytes", defaultTailBytes, maxTailBytes), includeSqlite: hasFlag(optionArgs, "--include-sqlite"), includeSensitive: hasFlag(optionArgs, "--include-sensitive"), includeCache: hasFlag(optionArgs, "--include-cache"), dryRun: hasFlag(optionArgs, "--dry-run"), full: hasFlag(optionArgs, "--full"), allowOutsideRoot: hasFlag(optionArgs, "--allow-outside-root"), pattern: optionValue(optionArgs, "--pattern") ?? positionalPattern, fixed: hasFlag(optionArgs, "--fixed"), deep: hasFlag(optionArgs, "--deep"), allFiles: hasFlag(optionArgs, "--all-files"), messagesOnly, toolsOnly, failedOnly: hasFlag(optionArgs, "--failed-only"), includeOutput: hasFlag(optionArgs, "--include-output") || output === "wide", tool: optionValue(optionArgs, "--tool") ?? null, includeSystem: hasFlag(optionArgs, "--include-system"), contextChars: positiveIntegerOption(optionArgs, "--context-chars", defaultContextChars, maxContextChars), since: optionValue(optionArgs, "--since") ?? null, fileLimit: positiveIntegerOption(optionArgs, "--file-limit", defaultFileLimit, maxFileLimit), output, }; } function codexTraceList(options: CodexTraceOptions, candidates: CodexTraceCandidate[]): Record | RenderedCliResult { const selected = selectedCandidates(options, candidates); const raw = { ok: true, command: "codex trace list", root: options.root, rootPresent: existsSync(options.root), policy: policySummary(options), summary: candidateSummary(candidates, selected), files: selected.map(candidateRow), omitted: Math.max(0, candidates.filter((item) => item.included).length - selected.length), skipped: skippedSummary(candidates), commands: { collect: `bun scripts/cli.ts codex trace collect --root ${shellWord(options.root)} --limit ${options.limit}`, show: "bun scripts/cli.ts codex trace show --file ", }, valuesPrinted: false, }; return renderCodexTraceResult(options, "codex trace list", raw); } function codexTraceCollect(options: CodexTraceOptions, candidates: CodexTraceCandidate[]): Record | RenderedCliResult { const selected = selectedCandidates(options, candidates); const outputDir = options.outputDir ?? path.join(repoRoot, ".state", "codex-trace", timestampForPath()); const copied: Record[] = []; if (!options.dryRun) mkdirSync(outputDir, { recursive: true, mode: 0o700 }); for (const candidate of selected) { const target = path.join(outputDir, candidate.relativePath); if (!options.dryRun) { mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 }); copyFileSync(candidate.path, target); } copied.push({ source: candidate.relativePath, target: path.relative(outputDir, target), kind: candidate.kind, bytes: candidate.bytes, mtime: new Date(candidate.mtimeMs).toISOString(), }); } const manifest = { generatedAt: new Date().toISOString(), command: "codex trace collect", root: options.root, outputDir, dryRun: options.dryRun, policy: policySummary(options), summary: candidateSummary(candidates, selected), files: copied, skipped: skippedSummary(candidates), valuesPrinted: false, }; if (!options.dryRun) writeFileSync(path.join(outputDir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n", { mode: 0o600 }); const raw = { ok: true, command: "codex trace collect", root: options.root, outputDir, dryRun: options.dryRun, copiedCount: copied.length, copiedBytes: selected.reduce((sum, item) => sum + item.bytes, 0), manifest: options.dryRun ? null : path.join(outputDir, "manifest.json"), policy: policySummary(options), files: copied.slice(0, Math.min(copied.length, options.limit)), skipped: skippedSummary(candidates), valuesPrinted: false, }; return renderCodexTraceResult(options, "codex trace collect", raw); } function codexTraceShow(options: CodexTraceOptions): Record | RenderedCliResult { const rootReal = safeRealpath(options.root); if (rootReal === null) throw new Error(`codex trace root does not exist: ${options.root}`); if (options.file === null && options.session === null) throw new Error("codex trace show requires --file or --session "); const target = options.session === null ? candidateFromFile(rootReal, options.root, options.file ?? "") : resolveSessionCandidate(rootReal, options.root, options.session); const fileReal = safeRealpath(target.path); if (fileReal === null) throw new Error(`codex trace file does not exist: ${target.path}`); if (!options.allowOutsideRoot && !isWithin(rootReal, fileReal)) { throw new Error(`codex trace show refuses file outside --root; pass --root accordingly or --allow-outside-root for reviewed local diagnostics`); } const stats = statSync(fileReal); const relativePath = path.relative(rootReal, fileReal); const basename = path.basename(fileReal); const kind = classifyTraceKind(relativePath, basename); if (!options.includeSensitive && isSensitivePath(normalizePath(relativePath), basename)) { throw new Error("codex trace show refuses sensitive-looking files by default; pass --include-sensitive for reviewed local diagnostics"); } const textReadable = isTextReadableTrace(kind, fileReal); const readBytes = options.full ? Math.min(stats.size, options.maxFileBytes) : Math.min(stats.size, options.tailBytes); const start = options.full ? 0 : Math.max(0, stats.size - readBytes); const content = textReadable ? redactTraceText(readFileSlice(fileReal, start, readBytes)) : null; const raw = { ok: true, command: "codex trace show", root: options.root, file: fileReal, relativePath, sessionId: extractSessionId(relativePath), kind, bytes: stats.size, mtime: stats.mtime.toISOString(), contentPolicy: { textReadable, mode: options.full ? "head" : "tail", start, bytesReturned: content === null ? 0 : Buffer.byteLength(content), redacted: true, fullRequested: options.full, truncated: options.full ? stats.size > options.maxFileBytes : stats.size > options.tailBytes, }, content, valuesPrinted: content !== null, }; return renderCodexTraceResult(options, "codex trace show", raw); } function codexTraceActive(options: CodexTraceOptions): Record | RenderedCliResult { const rootReal = safeRealpath(options.root); const procDir = "/proc"; if (rootReal === null) { const raw = { ok: true, command: "codex trace active", root: options.root, rootPresent: false, supported: existsSync(procDir), sessions: [], valuesPrinted: false, }; return renderCodexTraceResult(options, "codex trace active", raw); } const sessions = activeCodexSessionFiles(rootReal).slice(0, options.limit); const raw = { ok: true, command: "codex trace active", root: options.root, rootPresent: true, supported: existsSync(procDir), count: sessions.length, sessions, next: { grep: "bun scripts/cli.ts codex trace grep --session --pattern ", collect: "bun scripts/cli.ts codex trace collect --limit 30", }, valuesPrinted: false, }; return renderCodexTraceResult(options, "codex trace active", raw); } async function codexTraceGrep(options: CodexTraceOptions): Promise | RenderedCliResult> { if ((options.pattern === null || options.pattern.length === 0) && !options.failedOnly) { throw new Error("codex trace grep requires --pattern , a positional pattern, or --failed-only"); } const matcher = options.pattern === null || options.pattern.length === 0 ? null : makeMatcher(options.pattern, options.fixed); const rootReal = safeRealpath(options.root); if (rootReal === null) throw new Error(`codex trace root does not exist: ${options.root}`); const files = grepTargetFiles(options, rootReal); const results: Record[] = []; let linesScanned = 0; let matchedTotal = 0; for (const file of files) { const report = await grepJsonlFile(file, rootReal, matcher, options, results, options.limit); linesScanned += report.linesScanned; matchedTotal += report.matchedTotal; if (results.length >= options.limit) break; } const raw = { ok: true, command: "codex trace grep", root: options.root, pattern: options.pattern, fixed: options.fixed, since: options.since, policy: { limit: options.limit, fileLimit: options.fileLimit, contextChars: options.contextChars, includeSystem: options.includeSystem, messagesOnly: options.messagesOnly, toolsOnly: options.toolsOnly, failedOnly: options.failedOnly, includeOutput: options.includeOutput, tool: options.tool, valuesRedacted: true, }, filesScanned: files.length, linesScanned, matchedTotal, matchesReturned: results.length, truncated: matchedTotal > results.length, files: files.map((file) => file.relativePath), results, valuesPrinted: results.length > 0, }; return renderCodexTraceResult(options, "codex trace grep", raw); } function renderCodexTraceResult(options: CodexTraceOptions, command: string, raw: Record): Record | RenderedCliResult { const ok = raw.ok !== false; if (options.output === "json") return renderedCliResult(ok, command, `${JSON.stringify(raw, null, 2)}\n`, "application/json"); if (options.output === "yaml") return renderedCliResult(ok, command, `${Bun.YAML.stringify(raw)}\n`, "application/yaml"); if (options.output === "name") return renderedCliResult(ok, command, renderCodexTraceNames(raw)); if (command === "codex trace help" || raw.command === "codex trace") return renderedCliResult(ok, command, renderCodexTraceHelp(raw)); if (raw.command === "codex trace active") return renderedCliResult(ok, command, renderCodexTraceActive(raw, options)); if (raw.command === "codex trace grep") return renderedCliResult(ok, command, renderCodexTraceGrep(raw, options)); if (raw.command === "codex trace list") return renderedCliResult(ok, command, renderCodexTraceList(raw, options)); if (raw.command === "codex trace collect") return renderedCliResult(ok, command, renderCodexTraceCollect(raw, options)); if (raw.command === "codex trace show") return renderedCliResult(ok, command, renderCodexTraceShow(raw)); return raw; } function renderedCliResult(ok: boolean, command: string, renderedText: string, contentType: RenderedCliResult["contentType"] = "text/plain"): RenderedCliResult { return { ok, command, renderedText: renderedText.endsWith("\n") ? renderedText : `${renderedText}\n`, contentType }; } function renderCodexTraceHelp(raw: Record): string { const usage = stringArray(raw.usage); const safety = stringArray(raw.safety); const options = asRecord(raw.options) ?? {}; const lines = [ "Codex trace", String(raw.description ?? ""), "", "Usage:", ...usage.map((line) => ` ${line}`), "", "Safety:", ...safety.map((line) => ` - ${line}`), "", "Options:", ...Object.entries(options).map(([key, value]) => ` ${key.padEnd(24)} ${String(value)}`), ]; return lines.filter((line, index) => line.length > 0 || lines[index - 1]?.length !== 0).join("\n"); } function renderCodexTraceActive(raw: Record, options: CodexTraceOptions): string { const sessions = recordArray(raw.sessions); if (sessions.length === 0) return "No active Codex session files found."; const rows = sessions.map((session) => [ String(session.pid ?? "-"), String(session.fd ?? "-"), ageFromIso(stringValue(session.mtime)), formatBytes(numberValue(session.bytes)), String(session.sessionId ?? "-"), truncateMiddle(String(session.path ?? "-"), options.output === "wide" ? 140 : 82), ]); return [ renderTable(["PID", "FD", "AGE", "SIZE", "SESSION-ID", "FILE"], rows), "", "Next:", " bun scripts/cli.ts codex trace grep --session --pattern ", ].join("\n"); } function renderCodexTraceGrep(raw: Record, options: CodexTraceOptions): string { const results = recordArray(raw.results); const header = `Matched ${String(raw.matchesReturned ?? 0)}/${String(raw.matchedTotal ?? 0)} events in ${String(raw.filesScanned ?? 0)} file(s), scanned ${String(raw.linesScanned ?? 0)} lines${raw.truncated === true ? " (truncated)" : ""}.`; if (results.length === 0) return `${header}\nNo matching trace events found.`; const rows = results.map((result) => [ shortIso(stringValue(result.timestamp)), `${truncateMiddle(String(result.file ?? "-"), options.output === "wide" ? 96 : 42)}:${String(result.line ?? "-")}`, truncateMiddle(traceResultItemLabel(result), 28), signalLabel(asRecord(result.signals)), truncateOneLine(String(result.summary ?? ""), options.output === "wide" ? 220 : 96), ]); return [ header, renderTable(["TIME", "LOCATION", "ITEM", "SIGNALS", "SUMMARY"], rows), ].join("\n"); } function renderCodexTraceList(raw: Record, options: CodexTraceOptions): string { const files = recordArray(raw.files); if (files.length === 0) return `No trace files found under ${String(raw.root ?? "-")}.`; const rows = files.map((file) => [ String(file.sessionId ?? "-"), truncateMiddle(String(file.path ?? "-"), options.output === "wide" ? 140 : 82), String(file.kind ?? "-"), formatBytes(numberValue(file.bytes)), ageFromIso(stringValue(file.mtime)), ]); const skipped = asRecord(raw.skipped) ?? {}; const skippedText = Object.entries(skipped).map(([key, value]) => `${key}=${String(value)}`).join(" "); return [ `Root: ${String(raw.root ?? "-")}`, renderTable(["SESSION-ID", "PATH", "KIND", "SIZE", "AGE"], rows), skippedText.length > 0 ? `Skipped: ${skippedText}` : "", Number(raw.omitted ?? 0) > 0 ? `Omitted: ${String(raw.omitted)}` : "", ].filter((line) => line.length > 0).join("\n"); } function renderCodexTraceCollect(raw: Record, options: CodexTraceOptions): string { const files = recordArray(raw.files); const headline = raw.dryRun === true ? "Dry-run collect" : "Collected"; const rows = files.map((file) => [ truncateMiddle(String(file.source ?? "-"), options.output === "wide" ? 120 : 72), String(file.kind ?? "-"), formatBytes(numberValue(file.bytes)), ]); return [ `${headline}: ${String(raw.copiedCount ?? 0)} file(s), ${formatBytes(numberValue(raw.copiedBytes))}`, `Output: ${String(raw.outputDir ?? "-")}`, raw.manifest === null || raw.manifest === undefined ? "" : `Manifest: ${String(raw.manifest)}`, rows.length > 0 ? renderTable(["SOURCE", "KIND", "SIZE"], rows) : "", ].filter((line) => line.length > 0).join("\n"); } function renderCodexTraceShow(raw: Record): string { const policy = asRecord(raw.contentPolicy) ?? {}; const content = stringValue(raw.content); const lines = [ `Name: ${String(raw.relativePath ?? raw.file ?? "-")}`, `Session: ${String(raw.sessionId ?? "-")}`, `Kind: ${String(raw.kind ?? "-")}`, `Size: ${formatBytes(numberValue(raw.bytes))}`, `Mode: ${String(policy.mode ?? "-")} bytes=${String(policy.bytesReturned ?? 0)} truncated=${String(policy.truncated ?? false)}`, ]; if (content !== null) lines.push("---", content); else lines.push("Content: "); return lines.join("\n"); } function renderCodexTraceNames(raw: Record): string { const sessions = recordArray(raw.sessions); if (sessions.length > 0) { return sessions .map((session) => String(session.sessionId ?? session.path ?? "")) .filter((value) => value.length > 0) .map((value) => `session/${value}`) .join("\n"); } const files = recordArray(raw.files); if (files.length > 0) { return files.map((file) => { const sessionId = stringValue(file.sessionId); return sessionId === null ? String(file.path ?? file.source ?? file.relativePath ?? "") : `session/${sessionId}`; }).filter((line) => line.length > 0).join("\n"); } const results = recordArray(raw.results); if (results.length > 0) return results.map((result) => `${String(result.file ?? "")}:${String(result.line ?? "")}`).join("\n"); return ""; } function traceResultItemLabel(result: Record): string { const eventClass = stringValue(result.class); const tool = stringValue(result.tool); if (eventClass === "tool-call" || eventClass === "tool-output") { return tool === null ? eventClass : `${eventClass}/${tool}`; } return eventClass ?? String(result.item ?? result.type ?? "-"); } function scanCodexTraceRoot(options: CodexTraceOptions): CodexTraceCandidate[] { if (!existsSync(options.root)) return []; const root = realpathSync(options.root); const candidates: CodexTraceCandidate[] = []; const walk = (dir: string, depth: number) => { if (depth > options.maxDepth) return; let entries; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { const fullPath = path.join(dir, entry.name); const relativePath = path.relative(root, fullPath); const normalizedRelative = normalizePath(relativePath); if (entry.isSymbolicLink()) continue; if (entry.isDirectory()) { if (shouldSkipDirectory(normalizedRelative, entry.name, options)) continue; walk(fullPath, depth + 1); continue; } if (!entry.isFile()) continue; let stats; try { stats = lstatSync(fullPath); } catch { continue; } const kind = classifyTraceKind(normalizedRelative, entry.name); const skippedReason = skipReason(normalizedRelative, entry.name, kind, stats.size, options); candidates.push({ path: fullPath, relativePath, kind, bytes: stats.size, mtimeMs: stats.mtimeMs, included: skippedReason === null, skippedReason, }); } }; walk(root, 0); return candidates.sort((left, right) => right.mtimeMs - left.mtimeMs || left.relativePath.localeCompare(right.relativePath)); } function activeCodexSessionFiles(rootReal: string): Record[] { const procDir = "/proc"; if (!existsSync(procDir)) return []; const rows = new Map>(); let procEntries; try { procEntries = readdirSync(procDir, { withFileTypes: true }); } catch { return []; } for (const procEntry of procEntries) { if (!procEntry.isDirectory() || !/^\d+$/u.test(procEntry.name)) continue; const pid = Number(procEntry.name); const fdDir = path.join(procDir, procEntry.name, "fd"); let fdEntries; try { fdEntries = readdirSync(fdDir, { withFileTypes: true }); } catch { continue; } const cmdline = readProcCmdline(pid); if (!isCodexSessionProcess(cmdline)) continue; for (const fdEntry of fdEntries) { let target; try { target = readlinkSync(path.join(fdDir, fdEntry.name)); } catch { continue; } const deletedSuffix = " (deleted)"; if (target.endsWith(deletedSuffix)) target = target.slice(0, -deletedSuffix.length); if (!target.endsWith(".jsonl") || !isWithin(rootReal, target) || !normalizePath(path.relative(rootReal, target)).startsWith("sessions/")) continue; const realTarget = safeRealpath(target); if (realTarget === null || !isWithin(rootReal, realTarget)) continue; let stats; try { stats = statSync(realTarget); } catch { continue; } const relativePath = path.relative(rootReal, realTarget); const sessionId = extractSessionId(relativePath); const key = `${pid}:${realTarget}`; rows.set(key, { pid, fd: fdEntry.name, path: relativePath, sessionId, bytes: stats.size, mtime: stats.mtime.toISOString(), command: truncateOneLine(cmdline, 180), grepCommand: sessionId === null ? `bun scripts/cli.ts codex trace grep --file ${shellWord(relativePath)} --pattern ` : `bun scripts/cli.ts codex trace grep --session ${shellWord(sessionId)} --pattern `, showCommand: sessionId === null ? `bun scripts/cli.ts codex trace show --file ${shellWord(relativePath)}` : `bun scripts/cli.ts codex trace show --session ${shellWord(sessionId)}`, }); } } return [...rows.values()].sort((left, right) => String(right.mtime).localeCompare(String(left.mtime)) || Number(left.pid) - Number(right.pid)); } function grepTargetFiles(options: CodexTraceOptions, rootReal: string): CodexTraceCandidate[] { if (options.session !== null) return [resolveSessionCandidate(rootReal, options.root, options.session)]; if (options.file !== null) { return [candidateFromFile(rootReal, options.root, options.file, options.allowOutsideRoot)]; } const active = activeCodexSessionFiles(rootReal) .map((row) => String(row.path)) .filter((relativePath) => relativePath.length > 0) .map((relativePath) => { const filePath = path.join(rootReal, relativePath); const stats = statSync(filePath); return { path: filePath, relativePath, kind: classifyTraceKind(relativePath, path.basename(filePath)), bytes: stats.size, mtimeMs: stats.mtimeMs, included: true, skippedReason: null, } satisfies CodexTraceCandidate; }); const scanned = options.allFiles ? scanCodexTraceRoot(options).filter((candidate) => candidate.included && isTextReadableTrace(candidate.kind, candidate.path)) : recentSessionCandidates(rootReal, options, options.fileLimit); const byPath = new Map(); for (const candidate of [...active, ...scanned]) { if (!isTextReadableTrace(candidate.kind, candidate.path)) continue; byPath.set(candidate.path, candidate); } return [...byPath.values()].sort((left, right) => right.mtimeMs - left.mtimeMs).slice(0, options.fileLimit); } function candidateFromFile(rootReal: string, root: string, file: string, allowOutsideRoot = false): CodexTraceCandidate { const filePath = resolveTraceFile(root, file); const fileReal = safeRealpath(filePath); if (fileReal === null) throw new Error(`codex trace file does not exist: ${filePath}`); if (!allowOutsideRoot && !isWithin(rootReal, fileReal)) throw new Error("codex trace refuses file outside --root"); const stats = statSync(fileReal); const relativePath = path.relative(rootReal, fileReal); return { path: fileReal, relativePath, kind: classifyTraceKind(relativePath, path.basename(fileReal)), bytes: stats.size, mtimeMs: stats.mtimeMs, included: true, skippedReason: null, }; } function resolveSessionCandidate(rootReal: string, root: string, rawSession: string): CodexTraceCandidate { const session = rawSession.replace(/^session\//u, "").trim(); if (session.length === 0) throw new Error("codex trace --session requires a non-empty session id"); if (session.includes("/") || session.endsWith(".jsonl")) { return candidateFromFile(rootReal, root, session); } const scanned = (sessionJsonlCandidatesByToken(rootReal, session) ?? sessionJsonlCandidates(rootReal)) .filter((candidate) => sessionCandidateMatches(candidate.relativePath, session)); const byPath = new Map(); for (const candidate of scanned) byPath.set(candidate.path, candidate); const matches = [...byPath.values()].sort((left, right) => right.mtimeMs - left.mtimeMs); if (matches.length === 0) throw new Error(`codex trace session not found under ${root}: ${session}`); const exact = matches.filter((candidate) => sessionCandidateExact(candidate.relativePath, session)); if (exact.length === 1) return exact[0] as CodexTraceCandidate; const prefix = matches.filter((candidate) => { const id = extractSessionId(candidate.relativePath); return id !== null && id.startsWith(session.toLowerCase()); }); if (prefix.length === 1) return prefix[0] as CodexTraceCandidate; if (matches.length === 1) return matches[0] as CodexTraceCandidate; const choices = matches.slice(0, 8).map((candidate) => ` ${extractSessionId(candidate.relativePath) ?? "-"} ${candidate.relativePath}`).join("\n"); throw new Error(`codex trace session id is ambiguous: ${session}\n${choices}`); } function recentSessionCandidates(rootReal: string, options: CodexTraceOptions, limit: number): CodexTraceCandidate[] { return sessionJsonlCandidates(rootReal) .filter((candidate) => candidate.bytes <= options.maxFileBytes || candidate.mtimeMs > Date.now() - 7 * 24 * 60 * 60 * 1000) .sort((left, right) => right.mtimeMs - left.mtimeMs) .slice(0, limit); } function sessionJsonlCandidates(rootReal: string): CodexTraceCandidate[] { const sessionsRoot = path.join(rootReal, "sessions"); if (!existsSync(sessionsRoot)) return []; const fast = sessionJsonlCandidatesFast(rootReal, sessionsRoot); if (fast !== null) return fast; const candidates: CodexTraceCandidate[] = []; const walk = (dir: string, depth: number) => { if (depth > 8) return; let entries; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isSymbolicLink()) continue; if (entry.isDirectory()) { walk(fullPath, depth + 1); continue; } if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue; let stats; try { stats = lstatSync(fullPath); } catch { continue; } const relativePath = path.relative(rootReal, fullPath); candidates.push({ path: fullPath, relativePath, kind: "session-jsonl", bytes: stats.size, mtimeMs: stats.mtimeMs, included: true, skippedReason: null, }); } }; walk(sessionsRoot, 0); return candidates; } function sessionJsonlCandidatesByToken(rootReal: string, token: string): CodexTraceCandidate[] | null { if (!/^[A-Za-z0-9._-]+$/u.test(token)) return null; const sessionsRoot = path.join(rootReal, "sessions"); if (!existsSync(sessionsRoot)) return []; return sessionJsonlCandidatesFromRg(rootReal, sessionsRoot, `*${token}*.jsonl`); } function sessionJsonlCandidatesFast(rootReal: string, sessionsRoot: string): CodexTraceCandidate[] | null { return sessionJsonlCandidatesFromRg(rootReal, sessionsRoot, "*.jsonl"); } function sessionJsonlCandidatesFromRg(rootReal: string, sessionsRoot: string, glob: string): CodexTraceCandidate[] | null { const result = spawnSync("rg", ["--files", "--glob", glob, sessionsRoot], { encoding: "utf8", maxBuffer: 8 * 1024 * 1024 }); if (result.error !== undefined || result.status !== 0) return null; const candidates: CodexTraceCandidate[] = []; for (const line of (result.stdout ?? "").split("\n")) { if (line.length === 0) continue; let stats; try { stats = lstatSync(line); } catch { continue; } const relativePath = path.relative(rootReal, line); candidates.push({ path: line, relativePath, kind: "session-jsonl", bytes: stats.size, mtimeMs: stats.mtimeMs, included: true, skippedReason: null, }); } return candidates; } async function grepJsonlFile( candidate: CodexTraceCandidate, rootReal: string, matcher: RegExp | null, options: CodexTraceOptions, results: Record[], limit: number, ): Promise<{ linesScanned: number; matchedTotal: number }> { const fast = grepJsonlFileFast(candidate, rootReal, matcher, options, results, limit); if (fast !== null) return fast; let linesScanned = 0; let matchedTotal = 0; const toolCalls = new Map(); const toolMatcher = options.tool === null ? null : makeMatcher(options.tool, false); const input = createReadStream(candidate.path, { encoding: "utf8" }); const reader = createInterface({ input, crlfDelay: Infinity }); try { for await (const line of reader) { linesScanned += 1; const rawLineMatch = matcher === null ? false : matcher.test(line); if (matcher !== null) matcher.lastIndex = 0; if (!rawLineMatch && !options.deep && !shouldInspectTraceLine(line, options)) continue; const event = parseJsonlEvent(line); if (event === null) { if (matcher === null) continue; if (!rawLineMatch) continue; matchedTotal += 1; if (results.length < limit) { results.push({ file: candidate.relativePath, line: linesScanned, timestamp: null, type: "raw", fields: ["raw"], summary: truncateOneLine(redactTraceText(line), options.contextChars), signals: traceSignals(line), }); } continue; } const eventClass = traceEventClass(event); if (eventClass === "tool-call") { const callId = traceCallId(event); if (callId !== null) { toolCalls.set(callId, { name: traceToolName(event, null), input: summarizeToolInput(event, Math.max(options.contextChars, 1000)), line: linesScanned, timestamp: typeof event.timestamp === "string" ? event.timestamp : null, }); } } if (!options.includeSystem && event.type === "session_meta") continue; if (!options.includeSystem && isBootstrapTraceEvent(event)) continue; if (options.since !== null && typeof event.timestamp === "string" && event.timestamp < options.since) continue; const callInfo = traceCallInfo(event, toolCalls); const toolName = traceToolName(event, callInfo); const failed = eventClass === "tool-output" && isFailedToolOutput(event); if (!traceEventPassesKindFilters(eventClass, toolName, failed, toolMatcher, options)) continue; const fields = traceEventFields(event); const matchedFields: string[] = []; const matchedValues: string[] = []; if (matcher === null) { matchedFields.push(failed ? "failed-output" : eventClass); matchedValues.push(toolOutputText(event) ?? summarizeTraceEvent(event, Math.max(options.contextChars, 1000))); } else { for (const field of fields) { const match = matcher.exec(field.value); if (match !== null) { matchedFields.push(field.name); matchedValues.push(snippetAroundMatch(field.value, match.index, Math.max(options.contextChars, 1000))); } matcher.lastIndex = 0; } } if (matchedFields.length === 0) continue; matchedTotal += 1; if (results.length >= limit) continue; const summary = summarizeMatchedTraceEvent(event, fields, matcher, options, callInfo, failed); results.push({ file: path.relative(rootReal, candidate.path), line: linesScanned, timestamp: typeof event.timestamp === "string" ? event.timestamp : null, type: event.type, class: eventClass, item: traceEventItem(event), tool: toolName, failed, fields: matchedFields, summary, signals: traceSignals(matchedValues.join(" ")), }); } } finally { reader.close(); input.destroy(); } return { linesScanned, matchedTotal }; } function grepJsonlFileFast( candidate: CodexTraceCandidate, rootReal: string, matcher: RegExp | null, options: CodexTraceOptions, results: Record[], limit: number, ): { linesScanned: number; matchedTotal: number } | null { if (options.deep || !isTextReadableTrace(candidate.kind, candidate.path)) return null; const rgPattern = fastGrepPattern(options); if (rgPattern === null) return null; const rgMaxCount = options.since === null ? Math.max(limit * 50, 200) : Math.max(limit * 200, 2000); const rg = runRipgrepLines(candidate.path, rgPattern.pattern, rgPattern.fixed, rgMaxCount); if (rg === null) return null; let matchedTotal = 0; const toolCalls = preloadToolCallsForMatches(candidate.path, rg, options); const callCache = new Map(); const toolMatcher = options.tool === null ? null : makeMatcher(options.tool, false); for (const match of rg) { const event = parseJsonlEvent(match.text); if (event === null) continue; const eventClass = traceEventClass(event); if (eventClass === "tool-call") { const callId = traceCallId(event); if (callId !== null) { toolCalls.set(callId, { name: traceToolName(event, null), input: summarizeToolInput(event, Math.max(options.contextChars, 1000)), line: match.line, timestamp: typeof event.timestamp === "string" ? event.timestamp : null, }); } } if (!options.includeSystem && event.type === "session_meta") continue; if (!options.includeSystem && isBootstrapTraceEvent(event)) continue; if (options.since !== null && typeof event.timestamp === "string" && event.timestamp < options.since) continue; let callInfo = traceCallInfo(event, toolCalls); const callId = traceCallId(event); if (callInfo === null && eventClass === "tool-output" && callId !== null) { if (!callCache.has(callId)) callCache.set(callId, findToolCallInfoByCallId(candidate.path, callId, match.line, options)); callInfo = callCache.get(callId) ?? null; } const toolName = traceToolName(event, callInfo); const failed = eventClass === "tool-output" && isFailedToolOutput(event); if (!traceEventPassesKindFilters(eventClass, toolName, failed, toolMatcher, options)) continue; const fields = traceEventFields(event); const matchedFields: string[] = []; const matchedValues: string[] = []; if (matcher === null) { matchedFields.push(failed ? "failed-output" : eventClass); matchedValues.push(toolOutputText(event) ?? summarizeTraceEvent(event, Math.max(options.contextChars, 1000))); } else { for (const field of fields) { const fieldMatch = matcher.exec(field.value); if (fieldMatch !== null) { matchedFields.push(field.name); matchedValues.push(snippetAroundMatch(field.value, fieldMatch.index, Math.max(options.contextChars, 1000))); } matcher.lastIndex = 0; } } if (matchedFields.length === 0) continue; matchedTotal += 1; if (results.length >= limit) continue; results.push({ file: path.relative(rootReal, candidate.path), line: match.line, timestamp: typeof event.timestamp === "string" ? event.timestamp : null, type: event.type, class: eventClass, item: traceEventItem(event), tool: toolName, failed, fields: matchedFields, summary: summarizeMatchedTraceEvent(event, fields, matcher, options, callInfo, failed), signals: traceSignals(matchedValues.join(" ")), }); } return { linesScanned: rg.length, matchedTotal }; } function fastGrepPattern(options: CodexTraceOptions): { pattern: string; fixed: boolean } | null { if (options.pattern !== null && options.pattern.length > 0) return { pattern: options.pattern, fixed: options.fixed }; if (options.failedOnly) { return { pattern: "Process exited with code [1-9][0-9]*|ok=false|\"ok\"\\s*:\\s*false|status=(blocked|failed|error)|\"status\"\\s*:\\s*\"(blocked|failed|error)\"|probe\\.error=auth-login-failed|auth-login-failed|Internal Server Error|Service Unavailable|Traceback|Unhandled exception|Exception:|Error:", fixed: false, }; } return null; } function runRipgrepLines(filePath: string, pattern: string, fixed: boolean, maxCount: number): Array<{ line: number; text: string }> | null { return runRipgrepLinesMany(filePath, [pattern], fixed, maxCount); } function runRipgrepLinesMany(filePath: string, patterns: string[], fixed: boolean, maxCount: number): Array<{ line: number; text: string }> | null { if (patterns.length === 0) return []; const args = ["--line-number", "--color", "never", "--ignore-case", "--max-count", String(maxCount)]; if (fixed) args.push("--fixed-strings"); for (const pattern of patterns) args.push("-e", pattern); args.push("--", filePath); const result = spawnSync("rg", args, { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }); if (result.error !== undefined) return null; if (result.status !== 0 && result.status !== 1) return null; const stdout = result.stdout ?? ""; if (stdout.length === 0) return []; const rows: Array<{ line: number; text: string }> = []; for (const rawLine of stdout.split("\n")) { if (rawLine.length === 0) continue; const separator = rawLine.indexOf(":"); if (separator <= 0) continue; const line = Number(rawLine.slice(0, separator)); if (!Number.isInteger(line) || line <= 0) continue; rows.push({ line, text: rawLine.slice(separator + 1) }); } return rows; } function findToolCallInfoByCallId(filePath: string, callId: string, outputLine: number, options: CodexTraceOptions): ToolCallInfo | null { const matches = runRipgrepLines(filePath, callId, true, 20); if (matches === null) return null; for (const match of matches) { if (match.line >= outputLine) continue; const event = parseJsonlEvent(match.text); if (event === null || traceEventClass(event) !== "tool-call") continue; return { name: traceToolName(event, null), input: summarizeToolInput(event, Math.max(options.contextChars, 1000)), line: match.line, timestamp: typeof event.timestamp === "string" ? event.timestamp : null, }; } return null; } function preloadToolCallsForMatches(filePath: string, matches: Array<{ line: number; text: string }>, options: CodexTraceOptions): Map { const callIds = new Set(); for (const match of matches) { const event = parseJsonlEvent(match.text); if (event === null || traceEventClass(event) !== "tool-output") continue; const callId = traceCallId(event); if (callId !== null) callIds.add(callId); } if (callIds.size === 0) return new Map(); return findToolCallInfosByCallIds(filePath, [...callIds], options); } function findToolCallInfosByCallIds(filePath: string, callIds: string[], options: CodexTraceOptions): Map { const calls = new Map(); const matches = runRipgrepLinesMany(filePath, callIds, true, Math.max(callIds.length * 4, 20)); if (matches === null) return calls; for (const match of matches) { const event = parseJsonlEvent(match.text); if (event === null || traceEventClass(event) !== "tool-call") continue; const callId = traceCallId(event); if (callId === null) continue; calls.set(callId, { name: traceToolName(event, null), input: summarizeToolInput(event, Math.max(options.contextChars, 1000)), line: match.line, timestamp: typeof event.timestamp === "string" ? event.timestamp : null, }); } return calls; } function selectedCandidates(options: CodexTraceOptions, candidates: CodexTraceCandidate[]): CodexTraceCandidate[] { return candidates.filter((item) => item.included).slice(0, options.limit); } function extractSessionId(relativePath: string): string | null { const basename = path.basename(relativePath).replace(/\.jsonl$/iu, ""); const matches = basename.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/giu); return matches === null || matches.length === 0 ? null : (matches[matches.length - 1] ?? null)?.toLowerCase() ?? null; } function sessionCandidateMatches(relativePath: string, rawSession: string): boolean { const session = rawSession.toLowerCase(); const basename = path.basename(relativePath).replace(/\.jsonl$/iu, "").toLowerCase(); const normalized = normalizePath(relativePath).toLowerCase(); const id = extractSessionId(relativePath); return sessionCandidateExact(relativePath, session) || (id !== null && (id.startsWith(session) || id.includes(session))) || basename.includes(session) || normalized.includes(session); } function sessionCandidateExact(relativePath: string, rawSession: string): boolean { const session = rawSession.toLowerCase(); const basename = path.basename(relativePath).replace(/\.jsonl$/iu, "").toLowerCase(); const normalized = normalizePath(relativePath).toLowerCase(); const id = extractSessionId(relativePath); return id === session || basename === session || normalized === session || normalized === `sessions/${session}`; } function classifyTraceKind(relativePath: string, basename: string): CodexTraceKind { const normalized = normalizePath(relativePath); if (/^sessions\/.+\.jsonl$/u.test(normalized)) return "session-jsonl"; if (basename === "history.jsonl") return "history-jsonl"; if (normalized.startsWith("shell_snapshots/")) return "shell-snapshot"; if (/\.log$/iu.test(basename)) return "log"; if (/trace/iu.test(normalized) && /\.(jsonl?|log|txt|md)$/iu.test(basename)) return "trace-named"; if (/\.jsonl$/iu.test(basename)) return "jsonl"; if (/\.sqlite$/iu.test(basename)) return "sqlite-db"; if (/\.sqlite-(?:wal|shm)$/iu.test(basename)) return "sqlite-sidecar"; return "unrecognized"; } function skipReason(relativePath: string, basename: string, kind: CodexTraceKind, bytes: number, options: CodexTraceOptions): string | null { if (kind === "unrecognized") return "unrecognized"; if (!options.includeSensitive && isSensitivePath(relativePath, basename)) return "sensitive-default-excluded"; if ((kind === "sqlite-db" || kind === "sqlite-sidecar") && !options.includeSqlite) return "sqlite-default-excluded"; if (bytes > options.maxFileBytes) return "over-max-file-bytes"; return null; } function shouldSkipDirectory(relativePath: string, basename: string, options: CodexTraceOptions): boolean { if (options.includeCache) return false; if (basename === "cache" || basename === ".tmp" || basename === "generated_images" || basename === "plugins" || basename === "skills") return true; return relativePath.startsWith("cache/") || relativePath.startsWith(".tmp/") || relativePath.startsWith("generated_images/"); } function isSensitivePath(relativePath: string, basename: string): boolean { return /^auth\.json(?:\.|$)/iu.test(basename) || /^config\.toml(?:\.|$)/iu.test(basename) || /(^|\/)(auth|credential|secret|token|key)(\/|\.|-|_)/iu.test(relativePath); } function isTextReadableTrace(kind: CodexTraceKind, filePath: string): boolean { if (kind === "sqlite-db" || kind === "sqlite-sidecar") return false; return /\.(jsonl?|log|txt|md|toml|sh)$/iu.test(path.basename(filePath)); } function candidateRow(candidate: CodexTraceCandidate): Record { const sessionId = extractSessionId(candidate.relativePath); return { path: candidate.relativePath, sessionId, kind: candidate.kind, bytes: candidate.bytes, mtime: new Date(candidate.mtimeMs).toISOString(), showCommand: sessionId === null ? `bun scripts/cli.ts codex trace show --file ${shellWord(candidate.relativePath)}` : `bun scripts/cli.ts codex trace show --session ${shellWord(sessionId)}`, }; } function candidateSummary(candidates: CodexTraceCandidate[], selected: CodexTraceCandidate[]): Record { const included = candidates.filter((item) => item.included); const byKind: Record = {}; for (const candidate of candidates) byKind[candidate.kind] = (byKind[candidate.kind] ?? 0) + 1; return { scannedFiles: candidates.length, includedCandidates: included.length, selectedFiles: selected.length, selectedBytes: selected.reduce((sum, item) => sum + item.bytes, 0), byKind, }; } function skippedSummary(candidates: CodexTraceCandidate[]): Record { const skipped: Record = {}; for (const candidate of candidates) { if (candidate.skippedReason === null) continue; skipped[candidate.skippedReason] = (skipped[candidate.skippedReason] ?? 0) + 1; } return skipped; } function policySummary(options: CodexTraceOptions): Record { return { limit: options.limit, maxDepth: options.maxDepth, maxFileBytes: options.maxFileBytes, includeSqlite: options.includeSqlite, includeSensitive: options.includeSensitive, includeCache: options.includeCache, grepDefaultScope: options.allFiles ? "all-included-files" : "active-and-recent-sessions", grepParseMode: options.deep ? "deep-json-parse" : "raw-prefilter", skippedByDefault: ["auth/config", "sqlite", "cache/.tmp/generated_images/plugins/skills"], }; } function parseJsonlEvent(line: string): Record | null { try { const parsed = JSON.parse(line); return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as Record : null; } catch { return null; } } function isBootstrapTraceEvent(event: Record): boolean { const payload = asRecord(event.payload); if (event.type === "session_meta") return true; if (event.type !== "response_item") return false; if (payload?.type !== "message") return false; const content = Array.isArray(payload.content) ? payload.content : []; const text = content .map((item) => stringValue(asRecord(item)?.text)) .filter((value): value is string => value !== null) .join("\n"); return text.includes("") || text.includes("") || text.includes("# AGENTS.md instructions") || text.includes("You are Codex, a coding agent") || text.includes("Knowledge cutoff:"); } function traceEventFields(event: Record): Array<{ name: string; value: string }> { const payload = asRecord(event.payload); const fields: Array<{ name: string; value: string }> = []; addField(fields, "timestamp", event.timestamp); addField(fields, "type", event.type); addField(fields, "payload.type", payload?.type); addField(fields, "payload.role", payload?.role); addField(fields, "payload.name", payload?.name); addField(fields, "message", payload?.message); addField(fields, "arguments", payload?.arguments); addField(fields, "output", payload?.output); const content = Array.isArray(payload?.content) ? payload.content : []; for (const [index, item] of content.entries()) { const record = asRecord(item); addField(fields, `content.${index}.type`, record?.type); addField(fields, `content.${index}.text`, record?.text); } return fields; } function summarizeTraceEvent(event: Record, maxChars: number): string { const payload = asRecord(event.payload); const eventType = stringValue(event.type); const payloadType = stringValue(payload?.type); if (eventType === "response_item" && payloadType === "function_call") { const name = stringValue(payload?.name) ?? "function_call"; const args = parseJsonObject(stringValue(payload?.arguments) ?? ""); const cmd = stringValue(args?.cmd); return truncateOneLine(redactTraceText(cmd === null ? `${name} ${stringValue(payload?.arguments) ?? ""}` : `${name}: ${cmd}`), maxChars); } if (eventType === "response_item" && payloadType === "function_call_output") { const callId = stringValue(payload?.call_id); const output = stringValue(payload?.output) ?? ""; return truncateOneLine(redactTraceText(`function_call_output${callId === null ? "" : ` ${callId}`}: ${output}`), maxChars); } if (eventType === "response_item" && payloadType === "message") { const content = Array.isArray(payload?.content) ? payload.content : []; const texts = content.map((item) => stringValue(asRecord(item)?.text)).filter((value): value is string => value !== null); return truncateOneLine(redactTraceText(texts.join(" ")), maxChars); } if (eventType === "event_msg") { return truncateOneLine(redactTraceText(stringValue(payload?.message) ?? JSON.stringify(payload ?? {})), maxChars); } return truncateOneLine(redactTraceText(JSON.stringify(event)), maxChars); } function summarizeMatchedTraceEvent( event: Record, fields: Array<{ name: string; value: string }>, matcher: RegExp | null, options: CodexTraceOptions, callInfo: ToolCallInfo | null, failed: boolean, ): string { const maxChars = options.contextChars; const eventClass = traceEventClass(event); if (eventClass === "tool-call") { const toolName = traceToolName(event, callInfo) ?? "tool"; return truncateOneLine(`tool-call ${toolName} input: ${summarizeToolInput(event, maxChars)}`, maxChars + 120); } if (eventClass === "tool-output") { const toolName = traceToolName(event, callInfo) ?? "tool"; const input = callInfo?.input ?? ""; const output = toolOutputText(event) ?? ""; if (failed) { const error = failureExcerpt(output, maxChars); return truncateOneLine(`tool-output ${toolName} failed error: ${error} input: ${truncateOneLine(input, 220)}`, maxChars + 180); } if (!options.includeOutput) { return truncateOneLine(`tool-output ${toolName} folded input: ${input}`, maxChars + 120); } } const item = traceEventItem(event); for (const field of fields) { if (matcher === null) break; const match = matcher.exec(field.value); matcher.lastIndex = 0; if (match === null || match.index < 0) continue; const snippet = redactTraceText(snippetAroundMatch(field.value, match.index, maxChars)); return truncateOneLine(`${item === null ? String(event.type ?? "event") : item} ${field.name}: ${snippet}`, maxChars + 120); } return summarizeTraceEvent(event, maxChars); } function snippetAroundMatch(value: string, matchIndex: number, maxChars: number): string { const halfWindow = Math.max(40, Math.floor(maxChars / 2)); const start = Math.max(0, matchIndex - halfWindow); const end = Math.min(value.length, start + maxChars); const prefix = start > 0 ? "…" : ""; const suffix = end < value.length ? "…" : ""; return `${prefix}${value.slice(start, end)}${suffix}`; } function traceEventItem(event: Record): string | null { const payload = asRecord(event.payload); return stringValue(payload?.name) ?? stringValue(payload?.type) ?? stringValue(payload?.role); } function shouldInspectTraceLine(line: string, options: CodexTraceOptions): boolean { if (line.includes('"function_call"')) return true; if (options.failedOnly || options.toolsOnly || options.tool !== null) return line.includes('"function_call"') || line.includes('"function_call_output"'); if (options.messagesOnly) return line.includes('"message"'); return false; } function traceEventClass(event: Record): "message" | "tool-call" | "tool-output" | "event" { const payload = asRecord(event.payload); if (event.type === "response_item" && payload?.type === "message") return "message"; if (event.type === "response_item" && payload?.type === "function_call") return "tool-call"; if (event.type === "response_item" && payload?.type === "function_call_output") return "tool-output"; return "event"; } function traceCallId(event: Record): string | null { const payload = asRecord(event.payload); return stringValue(payload?.call_id) ?? stringValue(payload?.id); } function traceCallInfo(event: Record, toolCalls: Map): ToolCallInfo | null { const callId = traceCallId(event); return callId === null ? null : toolCalls.get(callId) ?? null; } function traceToolName(event: Record, callInfo: ToolCallInfo | null): string | null { const payload = asRecord(event.payload); return stringValue(payload?.name) ?? callInfo?.name ?? null; } function summarizeToolInput(event: Record, maxChars: number): string { const payload = asRecord(event.payload); const argsText = stringValue(payload?.arguments) ?? ""; const args = parseJsonObject(argsText); const cmd = stringValue(args?.cmd); const prompt = stringValue(args?.prompt); const rendered = cmd ?? prompt ?? argsText; return truncateOneLine(redactTraceText(rendered.length === 0 ? "" : rendered), maxChars); } function toolOutputText(event: Record): string | null { const payload = asRecord(event.payload); return stringValue(payload?.output); } function isFailedToolOutput(event: Record): boolean { const output = toolOutputText(event) ?? ""; return /Process exited with code (?!0\b)\d+/iu.test(output) || /(?:^|\n)\s*ok=false\s*(?:\n|$)/iu.test(output) || /(?:^|\n)\s*status=(?:blocked|failed|error)\s*(?:\n|$)/iu.test(output) || /(?:^|\n)\s*probe\.error=auth-login-failed\s*(?:\n|$)/iu.test(output) || /(?:^|\n)\s*probe\.auth\.status(?:Text)?=(?:5\d\d|Internal Server Error|Service Unavailable)\s*(?:\n|$)/iu.test(output) || /Output:\s*\n\s*\{\s*\n\s*"ok"\s*:\s*false/iu.test(output) || /"error"\s*:\s*"auth-login-failed"/iu.test(output) || /"status"\s*:\s*"(?:blocked|failed|error)"/iu.test(output) || /"statusText"\s*:\s*"(?:Internal Server Error|Service Unavailable)"/iu.test(output) || /(?:^|\n)(?:Traceback|Unhandled exception|Exception:|Error:)[^\n]*/iu.test(output); } function failureExcerpt(output: string, maxChars: number): string { const patterns = [ /Process exited with code (?!0\b)\d+[^\n]*/iu, /(?:^|\n)\s*probe\.error=[^\n]+/iu, /(?:^|\n)\s*probe\.auth\.statusText=[^\n]+/iu, /"error"\s*:\s*"[^"]+"/iu, /"name"\s*:\s*"(?:TimeoutError|[^"]*Error)"/iu, /"message"\s*:\s*"[^"]*(?:Timeout|Error|failed|blocked)[^"]*"/iu, /"statusText"\s*:\s*"[^"]+"/iu, /auth-login-failed[^\n,}]*/iu, /Internal Server Error|Service Unavailable|Traceback[^\n]*/iu, /Error:[^\n]*/iu, ]; for (const pattern of patterns) { const match = pattern.exec(output); if (match !== null) return truncateOneLine(redactTraceText(match[0] ?? ""), maxChars); } return truncateOneLine(redactTraceText(output), maxChars); } function traceEventPassesKindFilters( eventClass: "message" | "tool-call" | "tool-output" | "event", toolName: string | null, failed: boolean, toolMatcher: RegExp | null, options: CodexTraceOptions, ): boolean { if (options.messagesOnly && eventClass !== "message") return false; if (options.toolsOnly && eventClass !== "tool-call" && eventClass !== "tool-output") return false; if (options.failedOnly && (eventClass !== "tool-output" || !failed)) return false; if (toolMatcher !== null) { const matched = toolName !== null && toolMatcher.test(toolName); toolMatcher.lastIndex = 0; if (!matched) return false; } if (eventClass === "tool-output" && !failed && !options.includeOutput && !options.failedOnly) return false; return true; } function traceSignals(text: string): Record { return { authLoginFailed: /auth-login-failed/iu.test(text), status503: /status["']?\s*[:=]\s*503|Service Unavailable/iu.test(text), sshHint: /UNIDESK_SSH_HINT|UNIDESK_SSH_TCP_POOL_HINT/iu.test(text), transTimeoutHint: /UNIDESK_TRAN_TIMEOUT_HINT/iu.test(text), sshTiming: /UNIDESK_SSH_TIMING/iu.test(text), downloadProgress: /unidesk\.ssh\.download\.progress/iu.test(text), }; } function addField(fields: Array<{ name: string; value: string }>, name: string, value: unknown): void { const text = stringValue(value); if (text !== null && text.length > 0) fields.push({ name, value: text }); } function asRecord(value: unknown): Record | null { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : null; } function stringValue(value: unknown): string | null { return typeof value === "string" ? value : null; } function numberValue(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } function stringArray(value: unknown): string[] { return Array.isArray(value) ? value.map((item) => String(item)) : []; } function recordArray(value: unknown): Record[] { return Array.isArray(value) ? value.map(asRecord).filter((item): item is Record => item !== null) : []; } function parseJsonObject(value: string): Record | null { if (value.trim().length === 0) return null; try { const parsed = JSON.parse(value); return asRecord(parsed); } catch { return null; } } function readFileSlice(filePath: string, start: number, length: number): string { if (length <= 0) return ""; const fd = openSync(filePath, "r"); try { const buffer = Buffer.alloc(length); const bytesRead = readSync(fd, buffer, 0, length, start); return buffer.subarray(0, bytesRead).toString("utf8"); } finally { closeSync(fd); } } function redactTraceText(text: string): string { return text .replace(/sk-[A-Za-z0-9_-]{16,}/gu, "sk-") .replace(/\b(?:ghp|github_pat)_[A-Za-z0-9_]{16,}/gu, "") .replace(/((?:api[_-]?key|token|authorization|password|secret)["']?\s*[:=]\s*["']?)[^"',\s}]+/giu, "$1"); } function truncateOneLine(value: string, maxChars: number): string { const oneLine = value.replace(/\s+/gu, " ").trim(); return oneLine.length <= maxChars ? oneLine : `${oneLine.slice(0, Math.max(0, maxChars - 1))}…`; } function truncateMiddle(value: string, maxChars: number): string { if (value.length <= maxChars) return value; if (maxChars <= 8) return value.slice(0, maxChars); const keep = maxChars - 1; const left = Math.ceil(keep * 0.58); const right = keep - left; return `${value.slice(0, left)}…${value.slice(value.length - right)}`; } function renderTable(headers: string[], rows: string[][]): string { const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => visibleLength(row[index] ?? "")))); const renderRow = (row: string[]) => row.map((cell, index) => padRight(cell, widths[index] ?? 0)).join(" ").trimEnd(); return [renderRow(headers), ...rows.map(renderRow)].join("\n"); } function padRight(value: string, width: number): string { const length = visibleLength(value); return length >= width ? value : `${value}${" ".repeat(width - length)}`; } function visibleLength(value: string): number { return [...value].length; } function formatBytes(value: number | null): string { if (value === null) return "-"; const units = ["B", "Ki", "Mi", "Gi"]; let size = value; let unitIndex = 0; while (size >= 1024 && unitIndex < units.length - 1) { size /= 1024; unitIndex += 1; } return unitIndex === 0 ? `${value}B` : `${size.toFixed(size >= 10 ? 0 : 1)}${units[unitIndex]}`; } function ageFromIso(value: string | null): string { if (value === null) return "-"; const time = Date.parse(value); if (!Number.isFinite(time)) return "-"; const seconds = Math.max(0, Math.floor((Date.now() - time) / 1000)); if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}m`; const hours = Math.floor(minutes / 60); if (hours < 48) return `${hours}h`; return `${Math.floor(hours / 24)}d`; } function shortIso(value: string | null): string { if (value === null) return "-"; return value.replace(/\.\d{3}Z$/u, "Z"); } function signalLabel(signals: Record | null): string { if (signals === null) return "-"; const labels = [ signals.authLoginFailed === true ? "auth" : "", signals.status503 === true ? "503" : "", signals.sshHint === true ? "ssh-hint" : "", signals.transTimeoutHint === true ? "timeout" : "", signals.sshTiming === true ? "timing" : "", signals.downloadProgress === true ? "download" : "", ].filter((label) => label.length > 0); return labels.length === 0 ? "-" : labels.join(","); } function resolveTraceFile(root: string, file: string): string { if (file === "~" || file.startsWith("~/")) return resolveUserPath(file); if (path.isAbsolute(file)) return file; return path.resolve(root, file); } function readProcCmdline(pid: number): string { try { const raw = readFileSync(path.join("/proc", String(pid), "cmdline"), "utf8"); return raw.split("\0").filter(Boolean).join(" "); } catch { return ""; } } function isCodexSessionProcess(cmdline: string): boolean { const [executable] = cmdline.split(/\s+/u).filter(Boolean); if (executable === undefined) return false; return path.basename(executable) === "codex"; } function makeMatcher(pattern: string, fixed: boolean): RegExp { const source = fixed ? escapeRegExp(pattern) : pattern; try { return new RegExp(source, "iu"); } catch (error) { if (fixed) throw error; return new RegExp(escapeRegExp(pattern), "iu"); } } function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); } function parseOutputMode(raw: string | undefined, wide: boolean): CodexTraceOutput { if (wide) return "wide"; if (raw === undefined) return "text"; if (raw === "json" || raw === "yaml" || raw === "name" || raw === "wide") return raw; throw new Error(`codex trace output mode must be one of: json, yaml, name, wide; got ${raw}`); } function defaultCodexRoot(): string { return path.join(homedir(), ".codex"); } function resolveUserPath(value: string): string { if (value === "~") return homedir(); if (value.startsWith("~/")) return path.join(homedir(), value.slice(2)); return path.resolve(value); } function safeRealpath(value: string): string | null { try { return realpathSync(value); } catch { return null; } } function isWithin(root: string, target: string): boolean { const relative = path.relative(root, target); return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); } function normalizePath(value: string): string { return value.split(path.sep).join("/"); } function timestampForPath(): string { return new Date().toISOString().replace(/[:.]/gu, "-"); } function shellWord(value: string): string { return JSON.stringify(value); } function hasFlag(args: string[], name: string): boolean { return args.includes(name); } function optionValue(args: string[], name: string): string | undefined { const index = args.indexOf(name); if (index === -1) return undefined; const value = args[index + 1]; if (value === undefined || value.length === 0 || value.startsWith("--")) throw new Error(`${name} requires a value`); return value; } function positiveIntegerOption(args: string[], name: string, defaultValue: number, maxValue: number): number { const raw = optionValue(args, name); if (raw === undefined) return defaultValue; const value = Number(raw); if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`); return Math.min(value, maxValue); } function bytesOption(args: string[], name: string, defaultValue: number, maxValue: number): number { const raw = optionValue(args, name); if (raw === undefined) return defaultValue; const match = /^(\d+)(?:\s*(b|kib|kb|mib|mb|gib|gb))?$/iu.exec(raw.trim()); if (match === null) throw new Error(`${name} must be a byte count such as 12000000 or 25MiB`); const base = Number(match[1]); const unit = (match[2] ?? "b").toLowerCase(); const multiplier = unit === "b" ? 1 : unit === "kb" || unit === "kib" ? 1024 : unit === "mb" || unit === "mib" ? 1024 * 1024 : 1024 * 1024 * 1024; return Math.min(base * multiplier, maxValue); } function assertKnownOptions(args: string[], spec: { flags: string[]; values: string[] }): void { const flags = new Set(spec.flags); const values = new Set(spec.values); for (let index = 0; index < args.length; index += 1) { const arg = args[index] ?? ""; if (!arg.startsWith("--") && arg !== "-h") continue; if (flags.has(arg)) continue; if (values.has(arg)) { index += 1; continue; } throw new Error(`unknown codex trace option: ${arg}`); } }