feat: add local codex trace collector
This commit is contained in:
@@ -87,6 +87,20 @@ HWLAB Code Agent / CaseRun follow-up 的日常派单也归入 AgentRun 资源原
|
||||
|
||||
---
|
||||
|
||||
## 本地 Codex Trace 采集
|
||||
|
||||
`bun scripts/cli.ts codex trace ...` 是本机 Codex 运行痕迹的只读诊断入口,默认扫描 `~/.codex`,也可以用 `--root <dir>` 指向其他 Codex home 或已拷贝的 trace 目录。它不提交新任务、不续跑 session,也不恢复旧 Code Queue 写入口。
|
||||
|
||||
```bash
|
||||
bun scripts/cli.ts codex trace list [--root ~/.codex] [--limit 30]
|
||||
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]
|
||||
```
|
||||
|
||||
默认只收集 `sessions/*.jsonl`、`history.jsonl`、`shell_snapshots`、`*.log` 和 trace 命名文本;`auth.json`、`config.toml`、sqlite、cache、`.tmp`、generated images、plugins 和 skills 默认跳过。需要本地审阅敏感/数据库文件时必须显式加 `--include-sensitive` 或 `--include-sqlite`;`collect` 只复制有界文件并写 manifest,不压缩、不上传、不删除源文件。
|
||||
|
||||
---
|
||||
|
||||
## 冻结的旧写入口
|
||||
|
||||
以下命令必须返回 `ok=false`、`frozen=true`、`degradedReason=legacy-code-queue-frozen`,并提示 AgentRun 替代命令:
|
||||
|
||||
@@ -8122,6 +8122,10 @@ function codexResumeTask(taskId: string, args: string[], fetcher: CodexResponseF
|
||||
|
||||
export async function runCodeQueueCommand(config: UniDeskConfig, args: string[]): Promise<unknown> {
|
||||
const [action = "task", taskIdArg] = args;
|
||||
if (action === "trace") {
|
||||
const { runCodexTraceCommand } = await import("./codex-trace");
|
||||
return runCodexTraceCommand(args.slice(1));
|
||||
}
|
||||
if (action === "submit" || action === "enqueue") {
|
||||
return legacyCodeQueueFrozenMutation(`codex ${action}`);
|
||||
}
|
||||
@@ -8180,5 +8184,5 @@ export async function runCodeQueueCommand(config: UniDeskConfig, args: string[])
|
||||
const taskId = requireTaskId(taskIdArg, `codex ${action}`);
|
||||
return codexSteerTraceConfirm(taskId, args.slice(2));
|
||||
}
|
||||
throw new Error("codex command must be one of: submit, enqueue, task, summary, show, tasks, overview, unread, terminal-unread, output, judge, read, mark-read, dev-ready, health, skills-sync, execution-plane, exec-plane, runtime-plane, pr-preflight, runtime-preflight, queues, queue list, queue create, queue merge, move, steer, resume, steer-confirm, interrupt, cancel");
|
||||
throw new Error("codex command must be one of: trace, submit, enqueue, task, summary, show, tasks, overview, unread, terminal-unread, output, judge, read, mark-read, dev-ready, health, skills-sync, execution-plane, exec-plane, runtime-plane, pr-preflight, runtime-preflight, queues, queue list, queue create, queue merge, move, steer, resume, steer-confirm, interrupt, cancel");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
import { closeSync, copyFileSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { repoRoot } from "./config";
|
||||
|
||||
type CodexTraceAction = "help" | "list" | "collect" | "show";
|
||||
type CodexTraceKind = "session-jsonl" | "history-jsonl" | "shell-snapshot" | "log" | "trace-named" | "jsonl" | "sqlite-db" | "sqlite-sidecar" | "unrecognized";
|
||||
|
||||
interface CodexTraceOptions {
|
||||
action: CodexTraceAction;
|
||||
root: string;
|
||||
outputDir: string | null;
|
||||
file: string | null;
|
||||
limit: number;
|
||||
maxDepth: number;
|
||||
maxFileBytes: number;
|
||||
tailBytes: number;
|
||||
includeSqlite: boolean;
|
||||
includeSensitive: boolean;
|
||||
includeCache: boolean;
|
||||
dryRun: boolean;
|
||||
full: boolean;
|
||||
allowOutsideRoot: boolean;
|
||||
}
|
||||
|
||||
interface CodexTraceCandidate {
|
||||
path: string;
|
||||
relativePath: string;
|
||||
kind: CodexTraceKind;
|
||||
bytes: number;
|
||||
mtimeMs: number;
|
||||
included: boolean;
|
||||
skippedReason: 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;
|
||||
|
||||
export function codexTraceHelp(): Record<string, unknown> {
|
||||
return {
|
||||
ok: true,
|
||||
command: "codex trace",
|
||||
output: "json",
|
||||
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 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]",
|
||||
],
|
||||
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.",
|
||||
"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.",
|
||||
"--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 function runCodexTraceCommand(args: string[]): Record<string, unknown> {
|
||||
const options = parseCodexTraceOptions(args);
|
||||
if (options.action === "help") return codexTraceHelp();
|
||||
if (options.action === "show") return codexTraceShow(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
|
||||
: (() => { throw new Error(`codex trace action must be one of: list, 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", "--help", "-h"],
|
||||
values: ["--root", "--output", "--file", "--limit", "--max-depth", "--max-file-bytes", "--tail-bytes"],
|
||||
});
|
||||
if (optionArgs.includes("--help") || optionArgs.includes("-h")) {
|
||||
return {
|
||||
action: "help",
|
||||
root: defaultCodexRoot(),
|
||||
outputDir: null,
|
||||
file: null,
|
||||
limit: defaultLimit,
|
||||
maxDepth: defaultMaxDepth,
|
||||
maxFileBytes: defaultMaxFileBytes,
|
||||
tailBytes: defaultTailBytes,
|
||||
includeSqlite: false,
|
||||
includeSensitive: false,
|
||||
includeCache: false,
|
||||
dryRun: false,
|
||||
full: false,
|
||||
allowOutsideRoot: false,
|
||||
};
|
||||
}
|
||||
const root = resolveUserPath(optionValue(optionArgs, "--root") ?? defaultCodexRoot());
|
||||
return {
|
||||
action,
|
||||
root,
|
||||
outputDir: optionValue(optionArgs, "--output") === undefined ? null : resolveUserPath(optionValue(optionArgs, "--output") ?? ""),
|
||||
file: optionValue(optionArgs, "--file") ?? null,
|
||||
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"),
|
||||
};
|
||||
}
|
||||
|
||||
function codexTraceList(options: CodexTraceOptions, candidates: CodexTraceCandidate[]): Record<string, unknown> {
|
||||
const selected = selectedCandidates(options, candidates);
|
||||
return {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function codexTraceCollect(options: CodexTraceOptions, candidates: CodexTraceCandidate[]): Record<string, unknown> {
|
||||
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 });
|
||||
return {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function codexTraceShow(options: CodexTraceOptions): Record<string, unknown> {
|
||||
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.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;
|
||||
return {
|
||||
ok: true,
|
||||
command: "codex trace show",
|
||||
root: options.root,
|
||||
file: fileReal,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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 selectedCandidates(options: CodexTraceOptions, candidates: CodexTraceCandidate[]): CodexTraceCandidate[] {
|
||||
return candidates.filter((item) => item.included).slice(0, options.limit);
|
||||
}
|
||||
|
||||
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> {
|
||||
return {
|
||||
path: candidate.relativePath,
|
||||
kind: candidate.kind,
|
||||
bytes: candidate.bytes,
|
||||
mtime: new Date(candidate.mtimeMs).toISOString(),
|
||||
showCommand: `bun scripts/cli.ts codex trace show --file ${shellWord(candidate.relativePath)}`,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
skippedByDefault: ["auth/config", "sqlite", "cache/.tmp/generated_images/plugins/skills"],
|
||||
};
|
||||
}
|
||||
|
||||
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 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 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}`);
|
||||
}
|
||||
}
|
||||
+12
-1
@@ -385,9 +385,12 @@ function scheduleHelp(): unknown {
|
||||
|
||||
function codexHelp(): unknown {
|
||||
return {
|
||||
command: "codex deploy|submit|task|tasks|unread|output|read|dev-ready|skills-sync|execution-plane|pr-preflight|judge|steer|resume|interrupt|cancel|queues|queue|move",
|
||||
command: "codex trace|deploy|submit|task|tasks|unread|output|read|dev-ready|skills-sync|execution-plane|pr-preflight|judge|steer|resume|interrupt|cancel|queues|queue|move",
|
||||
output: "json",
|
||||
usage: [
|
||||
"bun scripts/cli.ts codex trace list [--root ~/.codex] [--limit 30]",
|
||||
"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 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",
|
||||
@@ -435,6 +438,13 @@ function codexHelp(): unknown {
|
||||
default: "codex read marks a terminal task read and returns terminal metadata, final response, last error/judge, counts, and drill-down commands.",
|
||||
disclosure: "Full prompt, tool logs, and feedback prompts are not printed by codex read; use codex task/detail/trace/output for progressive disclosure.",
|
||||
},
|
||||
localTrace: {
|
||||
defaultRoot: "~/.codex",
|
||||
defaultIncludes: ["sessions/*.jsonl", "history.jsonl", "shell_snapshots", "*.log", "trace-named text files"],
|
||||
defaultExcludes: ["auth/config", "sqlite", "cache/.tmp/generated_images/plugins/skills"],
|
||||
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.",
|
||||
},
|
||||
unreadTriage: {
|
||||
default: "codex unread is read-only by default and returns counts, buckets, compact task rows, and one-time drill-down templates; per-task command blocks require --full or list.",
|
||||
mutationGuard: "Batch mark-read is blocked unless the explicit mark-read subcommand is used with --confirm; use codex read <taskId> for per-task review.",
|
||||
@@ -716,6 +726,7 @@ export async function staticNamespaceHelp(args: string[]): Promise<unknown | nul
|
||||
if (top === "gc") return gcHelp();
|
||||
if (top === "commander") return commanderHelp();
|
||||
if (top === "schedule") return scheduleHelp();
|
||||
if (top === "codex" && sub === "trace") return loadHelp(async () => (await import("./codex-trace")).codexTraceHelp(), codexHelp());
|
||||
if (top === "codex") return codexHelp();
|
||||
if (top === "job") return jobHelp();
|
||||
if (top === "debug") return debugHelp();
|
||||
|
||||
Reference in New Issue
Block a user