1717 lines
74 KiB
TypeScript
1717 lines
74 KiB
TypeScript
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<string, unknown> {
|
|
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 <dir>.",
|
|
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 <id> --pattern 'playwright|auth-login-failed' [--since ISO]",
|
|
"bun scripts/cli.ts codex trace grep --session <id> --messages --pattern 'Playwright|web-probe'",
|
|
"bun scripts/cli.ts codex trace grep --session <id> --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/<timestamp>] [--limit 30]",
|
|
"bun scripts/cli.ts codex trace show --session <id> [--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 <dir>": "Codex home or another trace root. Defaults to ~/.codex.",
|
|
"--output <dir>": "Collect destination. Defaults to .state/codex-trace/<timestamp>.",
|
|
"--limit <n>": `Maximum included files, default ${defaultLimit}, max ${maxLimit}.`,
|
|
"--max-depth <n>": `Recursive scan depth, default ${defaultMaxDepth}, max ${maxDepthLimit}.`,
|
|
"--max-file-bytes <n|25MiB>": "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 <path>": "For show, file path relative to --root or an absolute path under --root.",
|
|
"--session <id>": "For show/grep, resolve a Codex session by full or short session id instead of copying the JSONL path.",
|
|
"--pattern <regex>": "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 <name|regex>": "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 <ISO>": "For grep, skip JSONL events before the timestamp string.",
|
|
"--context-chars <n>": `For grep, max summary characters per match, default ${defaultContextChars}, max ${maxContextChars}.`,
|
|
"--file-limit <n>": `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 <n>": `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<Record<string, unknown> | 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<string, unknown> | 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 <relative-path>",
|
|
},
|
|
valuesPrinted: false,
|
|
};
|
|
return renderCodexTraceResult(options, "codex trace list", raw);
|
|
}
|
|
|
|
function codexTraceCollect(options: CodexTraceOptions, candidates: CodexTraceCandidate[]): Record<string, unknown> | RenderedCliResult {
|
|
const selected = selectedCandidates(options, candidates);
|
|
const outputDir = options.outputDir ?? path.join(repoRoot, ".state", "codex-trace", timestampForPath());
|
|
const copied: Record<string, unknown>[] = [];
|
|
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<string, unknown> | 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 <path> or --session <id>");
|
|
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<string, unknown> | 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 <session-id> --pattern <regex>",
|
|
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<Record<string, unknown> | RenderedCliResult> {
|
|
if ((options.pattern === null || options.pattern.length === 0) && !options.failedOnly) {
|
|
throw new Error("codex trace grep requires --pattern <regex>, 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<string, unknown>[] = [];
|
|
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<string, unknown>): Record<string, unknown> | 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, unknown>): 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<string, unknown>, 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 <session-id> --pattern <regex>",
|
|
].join("\n");
|
|
}
|
|
|
|
function renderCodexTraceGrep(raw: Record<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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, unknown>): 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: <not text-readable>");
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function renderCodexTraceNames(raw: Record<string, unknown>): 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, unknown>): 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<string, unknown>[] {
|
|
const procDir = "/proc";
|
|
if (!existsSync(procDir)) return [];
|
|
const rows = new Map<string, Record<string, unknown>>();
|
|
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 <regex>`
|
|
: `bun scripts/cli.ts codex trace grep --session ${shellWord(sessionId)} --pattern <regex>`,
|
|
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<string, CodexTraceCandidate>();
|
|
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<string, CodexTraceCandidate>();
|
|
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<string, unknown>[],
|
|
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<string, ToolCallInfo>();
|
|
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<string, unknown>[],
|
|
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<string, ToolCallInfo | null>();
|
|
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<string, ToolCallInfo> {
|
|
const callIds = new Set<string>();
|
|
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<string, ToolCallInfo>();
|
|
return findToolCallInfosByCallIds(filePath, [...callIds], options);
|
|
}
|
|
|
|
function findToolCallInfosByCallIds(filePath: string, callIds: string[], options: CodexTraceOptions): Map<string, ToolCallInfo> {
|
|
const calls = new Map<string, ToolCallInfo>();
|
|
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<string, unknown> {
|
|
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<string, unknown> {
|
|
const included = candidates.filter((item) => item.included);
|
|
const byKind: Record<string, number> = {};
|
|
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<string, number> {
|
|
const skipped: Record<string, number> = {};
|
|
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<string, unknown> {
|
|
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<string, unknown> | null {
|
|
try {
|
|
const parsed = JSON.parse(line);
|
|
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isBootstrapTraceEvent(event: Record<string, unknown>): 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("<permissions instructions>")
|
|
|| text.includes("<environment_context>")
|
|
|| text.includes("# AGENTS.md instructions")
|
|
|| text.includes("You are Codex, a coding agent")
|
|
|| text.includes("Knowledge cutoff:");
|
|
}
|
|
|
|
function traceEventFields(event: Record<string, unknown>): 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<string, unknown>, 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<string, unknown>,
|
|
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 ?? "<input unavailable>";
|
|
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, unknown>): 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<string, unknown>): "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, unknown>): string | null {
|
|
const payload = asRecord(event.payload);
|
|
return stringValue(payload?.call_id) ?? stringValue(payload?.id);
|
|
}
|
|
|
|
function traceCallInfo(event: Record<string, unknown>, toolCalls: Map<string, ToolCallInfo>): ToolCallInfo | null {
|
|
const callId = traceCallId(event);
|
|
return callId === null ? null : toolCalls.get(callId) ?? null;
|
|
}
|
|
|
|
function traceToolName(event: Record<string, unknown>, callInfo: ToolCallInfo | null): string | null {
|
|
const payload = asRecord(event.payload);
|
|
return stringValue(payload?.name) ?? callInfo?.name ?? null;
|
|
}
|
|
|
|
function summarizeToolInput(event: Record<string, unknown>, 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 ? "<empty>" : rendered), maxChars);
|
|
}
|
|
|
|
function toolOutputText(event: Record<string, unknown>): string | null {
|
|
const payload = asRecord(event.payload);
|
|
return stringValue(payload?.output);
|
|
}
|
|
|
|
function isFailedToolOutput(event: Record<string, unknown>): 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<string, boolean> {
|
|
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<string, unknown> | null {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : 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<string, unknown>[] {
|
|
return Array.isArray(value) ? value.map(asRecord).filter((item): item is Record<string, unknown> => item !== null) : [];
|
|
}
|
|
|
|
function parseJsonObject(value: string): Record<string, unknown> | 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-<redacted>")
|
|
.replace(/\b(?:ghp|github_pat)_[A-Za-z0-9_]{16,}/gu, "<github-token:redacted>")
|
|
.replace(/((?:api[_-]?key|token|authorization|password|secret)["']?\s*[:=]\s*["']?)[^"',\s}]+/giu, "$1<redacted>");
|
|
}
|
|
|
|
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<string, unknown> | 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}`);
|
|
}
|
|
}
|