feat: improve codex trace filtering
This commit is contained in:
@@ -94,12 +94,15 @@ HWLAB Code Agent / CaseRun follow-up 的日常派单也归入 AgentRun 资源原
|
||||
```bash
|
||||
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 --file sessions/2026/06/16/<session>.jsonl [--root ~/.codex] [--tail-bytes 12000]
|
||||
bun scripts/cli.ts codex trace show --session <id> [--root ~/.codex] [--tail-bytes 12000]
|
||||
```
|
||||
|
||||
默认只收集 `sessions/*.jsonl`、`history.jsonl`、`shell_snapshots`、`*.log` 和 trace 命名文本;`auth.json`、`config.toml`、sqlite、cache、`.tmp`、generated images、plugins 和 skills 默认跳过。`active` 从 `/proc` 找正在被 Codex 进程打开的 session JSONL,不依赖外部 `lsof`。`grep` 按 JSONL 结构解析并输出有界摘要/signals,不打印整行,优先用于排查 `playwright`、`auth-login-failed`、`UNIDESK_*HINT` 等高噪声 trace。默认输出是类似 k8s 的简洁 text/table;脚本消费时显式加 `-o json|yaml|name|wide`。需要本地审阅敏感/数据库文件时必须显式加 `--include-sensitive` 或 `--include-sqlite`;`collect` 只复制有界文件并写 manifest,不压缩、不上传、不删除源文件。
|
||||
默认只收集 `sessions/*.jsonl`、`history.jsonl`、`shell_snapshots`、`*.log` 和 trace 命名文本;`auth.json`、`config.toml`、sqlite、cache、`.tmp`、generated images、plugins 和 skills 默认跳过。`active` 从 `/proc` 找正在被 Codex 进程打开的 session JSONL,不依赖外部 `lsof`,并输出可复制的 `SESSION-ID`。`grep/show` 优先用 `--session <id>` 定位,避免复制长 JSONL 路径;session id 支持完整 UUID、短前缀和文件名片段,歧义时会提示候选。`grep` 默认只扫 active/recent session,并用 `rg`/raw-line prefilter 先定位候选行再解析 JSONL;需要全量文件域时显式加 `--all-files`,需要跳过 raw 预筛、逐行深度解析 decoded 字段时显式加 `--deep`。排查对话脉络优先 `--messages --pattern ...`,排查工具链优先 `--tools --tool <name>`,只看失败调用用 `--failed-only [--tool <name>]` 且可以不带 pattern。默认输出优先 message 和 tool-call input;tool output 默认折叠,只给失败状态、错误摘要和对应输入,不打印整段 transcript;需要查看命中输出片段时显式加 `--include-output` 或 `-o wide`。它优先用于排查 `playwright`、`auth-login-failed`、`UNIDESK_*HINT` 等高噪声 trace。默认输出是类似 k8s 的简洁 text/table;脚本消费时显式加 `-o json|yaml|name|wide`。需要本地审阅敏感/数据库文件时必须显式加 `--include-sensitive` 或 `--include-sqlite`;`collect` 只复制有界文件并写 manifest,不压缩、不上传、不删除源文件。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+608
-48
@@ -2,6 +2,7 @@ import { closeSync, copyFileSync, createReadStream, existsSync, lstatSync, mkdir
|
||||
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";
|
||||
|
||||
@@ -14,6 +15,7 @@ interface CodexTraceOptions {
|
||||
root: string;
|
||||
outputDir: string | null;
|
||||
file: string | null;
|
||||
session: string | null;
|
||||
limit: number;
|
||||
maxDepth: number;
|
||||
maxFileBytes: number;
|
||||
@@ -26,6 +28,13 @@ interface CodexTraceOptions {
|
||||
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;
|
||||
@@ -43,6 +52,13 @@ interface CodexTraceCandidate {
|
||||
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;
|
||||
@@ -65,9 +81,12 @@ export function codexTraceHelp(): Record<string, unknown> {
|
||||
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 --file sessions/2026/06/16/<session>.jsonl [--root ~/.codex] [--tail-bytes 12000]",
|
||||
"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.",
|
||||
@@ -87,8 +106,16 @@ export function codexTraceHelp(): Record<string, unknown> {
|
||||
"--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}.`,
|
||||
@@ -119,8 +146,8 @@ function parseCodexTraceOptions(args: string[]): CodexTraceOptions {
|
||||
: (() => { 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", "--wide", "--help", "-h"],
|
||||
values: ["--root", "--output", "--file", "--limit", "--max-depth", "--max-file-bytes", "--tail-bytes", "--pattern", "--since", "--context-chars", "--file-limit", "-o", "--format"],
|
||||
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 {
|
||||
@@ -128,6 +155,7 @@ function parseCodexTraceOptions(args: string[]): CodexTraceOptions {
|
||||
root: defaultCodexRoot(),
|
||||
outputDir: null,
|
||||
file: null,
|
||||
session: null,
|
||||
limit: defaultLimit,
|
||||
maxDepth: defaultMaxDepth,
|
||||
maxFileBytes: defaultMaxFileBytes,
|
||||
@@ -140,6 +168,13 @@ function parseCodexTraceOptions(args: string[]): CodexTraceOptions {
|
||||
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,
|
||||
@@ -150,11 +185,18 @@ function parseCodexTraceOptions(args: string[]): CodexTraceOptions {
|
||||
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: optionValue(optionArgs, "--file") ?? null,
|
||||
file,
|
||||
session,
|
||||
limit: positiveIntegerOption(optionArgs, "--limit", defaultLimit, maxLimit),
|
||||
maxDepth: positiveIntegerOption(optionArgs, "--max-depth", defaultMaxDepth, maxDepthLimit),
|
||||
maxFileBytes: bytesOption(optionArgs, "--max-file-bytes", defaultMaxFileBytes, maxFileBytesLimit),
|
||||
@@ -167,6 +209,13 @@ function parseCodexTraceOptions(args: string[]): CodexTraceOptions {
|
||||
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,
|
||||
@@ -246,12 +295,14 @@ function codexTraceCollect(options: CodexTraceOptions, candidates: CodexTraceCan
|
||||
}
|
||||
|
||||
function codexTraceShow(options: CodexTraceOptions): Record<string, unknown> | RenderedCliResult {
|
||||
if (options.file === null) throw new Error("codex trace show requires --file <path>");
|
||||
const filePath = resolveTraceFile(options.root, options.file);
|
||||
const rootReal = safeRealpath(options.root);
|
||||
const fileReal = safeRealpath(filePath);
|
||||
if (rootReal === null) throw new Error(`codex trace root does not exist: ${options.root}`);
|
||||
if (fileReal === null) throw new Error(`codex trace file does not exist: ${filePath}`);
|
||||
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`);
|
||||
}
|
||||
@@ -272,6 +323,7 @@ function codexTraceShow(options: CodexTraceOptions): Record<string, unknown> | R
|
||||
root: options.root,
|
||||
file: fileReal,
|
||||
relativePath,
|
||||
sessionId: extractSessionId(relativePath),
|
||||
kind,
|
||||
bytes: stats.size,
|
||||
mtime: stats.mtime.toISOString(),
|
||||
@@ -315,7 +367,7 @@ function codexTraceActive(options: CodexTraceOptions): Record<string, unknown> |
|
||||
count: sessions.length,
|
||||
sessions,
|
||||
next: {
|
||||
grep: "bun scripts/cli.ts codex trace grep --pattern <regex> --file <relative-path>",
|
||||
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,
|
||||
@@ -324,8 +376,10 @@ function codexTraceActive(options: CodexTraceOptions): Record<string, unknown> |
|
||||
}
|
||||
|
||||
async function codexTraceGrep(options: CodexTraceOptions): Promise<Record<string, unknown> | RenderedCliResult> {
|
||||
if (options.pattern === null || options.pattern.length === 0) throw new Error("codex trace grep requires --pattern <regex> or a positional pattern");
|
||||
const matcher = makeMatcher(options.pattern, options.fixed);
|
||||
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);
|
||||
@@ -350,6 +404,11 @@ async function codexTraceGrep(options: CodexTraceOptions): Promise<Record<string
|
||||
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,
|
||||
@@ -410,13 +469,14 @@ function renderCodexTraceActive(raw: Record<string, unknown>, options: CodexTrac
|
||||
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"], rows),
|
||||
renderTable(["PID", "FD", "AGE", "SIZE", "SESSION-ID", "FILE"], rows),
|
||||
"",
|
||||
"Next:",
|
||||
" bun scripts/cli.ts codex trace grep --file <session> --pattern <regex>",
|
||||
" bun scripts/cli.ts codex trace grep --session <session-id> --pattern <regex>",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
@@ -427,7 +487,7 @@ function renderCodexTraceGrep(raw: Record<string, unknown>, options: CodexTraceO
|
||||
const rows = results.map((result) => [
|
||||
shortIso(stringValue(result.timestamp)),
|
||||
`${truncateMiddle(String(result.file ?? "-"), options.output === "wide" ? 96 : 42)}:${String(result.line ?? "-")}`,
|
||||
truncateMiddle(String(result.item ?? result.type ?? "-"), 24),
|
||||
truncateMiddle(traceResultItemLabel(result), 28),
|
||||
signalLabel(asRecord(result.signals)),
|
||||
truncateOneLine(String(result.summary ?? ""), options.output === "wide" ? 220 : 96),
|
||||
]);
|
||||
@@ -441,6 +501,7 @@ function renderCodexTraceList(raw: Record<string, unknown>, options: CodexTraceO
|
||||
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)),
|
||||
@@ -450,7 +511,7 @@ function renderCodexTraceList(raw: Record<string, unknown>, options: CodexTraceO
|
||||
const skippedText = Object.entries(skipped).map(([key, value]) => `${key}=${String(value)}`).join(" ");
|
||||
return [
|
||||
`Root: ${String(raw.root ?? "-")}`,
|
||||
renderTable(["PATH", "KIND", "SIZE", "AGE"], rows),
|
||||
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");
|
||||
@@ -477,6 +538,7 @@ function renderCodexTraceShow(raw: Record<string, unknown>): string {
|
||||
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)}`,
|
||||
@@ -488,14 +550,34 @@ function renderCodexTraceShow(raw: Record<string, unknown>): string {
|
||||
|
||||
function renderCodexTraceNames(raw: Record<string, unknown>): string {
|
||||
const sessions = recordArray(raw.sessions);
|
||||
if (sessions.length > 0) return sessions.map((session) => `session/${String(session.path ?? "")}`).join("\n");
|
||||
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) => String(file.path ?? file.source ?? file.relativePath ?? "")).filter((line) => line.length > 0).join("\n");
|
||||
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);
|
||||
@@ -583,16 +665,22 @@ function activeCodexSessionFiles(rootReal: string): Record<string, unknown>[] {
|
||||
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: `bun scripts/cli.ts codex trace grep --file ${shellWord(relativePath)} --pattern <regex>`,
|
||||
showCommand: `bun scripts/cli.ts codex trace show --file ${shellWord(relativePath)}`,
|
||||
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)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -600,22 +688,9 @@ function activeCodexSessionFiles(rootReal: string): Record<string, unknown>[] {
|
||||
}
|
||||
|
||||
function grepTargetFiles(options: CodexTraceOptions, rootReal: string): CodexTraceCandidate[] {
|
||||
if (options.session !== null) return [resolveSessionCandidate(rootReal, options.root, options.session)];
|
||||
if (options.file !== null) {
|
||||
const filePath = resolveTraceFile(options.root, options.file);
|
||||
const fileReal = safeRealpath(filePath);
|
||||
if (fileReal === null) throw new Error(`codex trace grep file does not exist: ${filePath}`);
|
||||
if (!options.allowOutsideRoot && !isWithin(rootReal, fileReal)) throw new Error("codex trace grep 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,
|
||||
}];
|
||||
return [candidateFromFile(rootReal, options.root, options.file, options.allowOutsideRoot)];
|
||||
}
|
||||
const active = activeCodexSessionFiles(rootReal)
|
||||
.map((row) => String(row.path))
|
||||
@@ -633,7 +708,9 @@ function grepTargetFiles(options: CodexTraceOptions, rootReal: string): CodexTra
|
||||
skippedReason: null,
|
||||
} satisfies CodexTraceCandidate;
|
||||
});
|
||||
const scanned = scanCodexTraceRoot(options).filter((candidate) => candidate.included && isTextReadableTrace(candidate.kind, candidate.path));
|
||||
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;
|
||||
@@ -642,26 +719,162 @@ function grepTargetFiles(options: CodexTraceOptions, rootReal: string): CodexTra
|
||||
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,
|
||||
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) {
|
||||
const rawMatch = matcher.test(line);
|
||||
matcher.lastIndex = 0;
|
||||
if (!rawMatch) continue;
|
||||
if (matcher === null) continue;
|
||||
if (!rawLineMatch) continue;
|
||||
matchedTotal += 1;
|
||||
if (results.length < limit) {
|
||||
results.push({
|
||||
@@ -676,29 +889,54 @@ async function grepJsonlFile(
|
||||
}
|
||||
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[] = [];
|
||||
for (const field of fields) {
|
||||
if (matcher.test(field.value)) {
|
||||
matchedFields.push(field.name);
|
||||
matchedValues.push(field.value);
|
||||
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;
|
||||
}
|
||||
matcher.lastIndex = 0;
|
||||
}
|
||||
if (matchedFields.length === 0) continue;
|
||||
matchedTotal += 1;
|
||||
if (results.length >= limit) continue;
|
||||
const summary = summarizeMatchedTraceEvent(event, fields, matcher, options.contextChars);
|
||||
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(" ")),
|
||||
@@ -711,10 +949,202 @@ async function grepJsonlFile(
|
||||
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";
|
||||
@@ -754,12 +1184,16 @@ function isTextReadableTrace(kind: CodexTraceKind, filePath: string): boolean {
|
||||
}
|
||||
|
||||
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: `bun scripts/cli.ts codex trace show --file ${shellWord(candidate.relativePath)}`,
|
||||
showCommand: sessionId === null
|
||||
? `bun scripts/cli.ts codex trace show --file ${shellWord(candidate.relativePath)}`
|
||||
: `bun scripts/cli.ts codex trace show --session ${shellWord(sessionId)}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -793,6 +1227,8 @@ function policySummary(options: CodexTraceOptions): Record<string, unknown> {
|
||||
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"],
|
||||
};
|
||||
}
|
||||
@@ -869,13 +1305,39 @@ function summarizeTraceEvent(event: Record<string, unknown>, maxChars: number):
|
||||
return truncateOneLine(redactTraceText(JSON.stringify(event)), maxChars);
|
||||
}
|
||||
|
||||
function summarizeMatchedTraceEvent(event: Record<string, unknown>, fields: Array<{ name: string; value: string }>, matcher: RegExp, maxChars: number): string {
|
||||
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 = snippetAroundMatch(redactTraceText(field.value), match.index, maxChars);
|
||||
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);
|
||||
@@ -895,6 +1357,104 @@ function traceEventItem(event: Record<string, unknown>): string | null {
|
||||
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),
|
||||
|
||||
+6
-3
@@ -390,9 +390,12 @@ function codexHelp(): unknown {
|
||||
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 --file sessions/2026/06/16/<session>.jsonl [--root ~/.codex] [--tail-bytes 12000]",
|
||||
"bun scripts/cli.ts codex trace show --session <id> [--root ~/.codex] [--tail-bytes 12000]",
|
||||
"bun scripts/cli.ts codex deploy <commitId> # disabled legacy deployment entry",
|
||||
"bun scripts/cli.ts agentrun get tasks --queue commander --limit 20",
|
||||
"bun scripts/cli.ts agentrun describe aipodspec/Artificer",
|
||||
@@ -444,8 +447,8 @@ function codexHelp(): unknown {
|
||||
defaultRoot: "~/.codex",
|
||||
defaultIncludes: ["sessions/*.jsonl", "history.jsonl", "shell_snapshots", "*.log", "trace-named text files"],
|
||||
defaultExcludes: ["auth/config", "sqlite", "cache/.tmp/generated_images/plugins/skills"],
|
||||
active: "codex trace active finds open Codex session JSONL files from /proc without requiring lsof.",
|
||||
grep: "codex trace grep parses JSONL records and returns bounded summaries/signals instead of printing whole matching lines.",
|
||||
active: "codex trace active finds open Codex session JSONL files from /proc without requiring lsof and prints copyable session ids.",
|
||||
grep: "codex trace grep supports --session <id>, --messages, --tools, --tool <name>, and --failed-only. It defaults to active/recent sessions, uses rg/raw prefiltering for speed, prioritizes messages/tool inputs, and folds tool outputs unless --include-output or -o wide is explicit.",
|
||||
output: "Default output is concise text/table; use -o json|yaml|name|wide for machine or wider output.",
|
||||
collectOutput: ".state/codex-trace/<timestamp>/manifest.json",
|
||||
note: "Use --root <dir> to scan another local Codex trace directory; collect copies bounded files locally and never uploads or deletes source files.",
|
||||
|
||||
Reference in New Issue
Block a user