diff --git a/.agents/skills/unidesk-code-queue/SKILL.md b/.agents/skills/unidesk-code-queue/SKILL.md index 3f573ba9..aff0cd16 100644 --- a/.agents/skills/unidesk-code-queue/SKILL.md +++ b/.agents/skills/unidesk-code-queue/SKILL.md @@ -87,6 +87,20 @@ HWLAB Code Agent / CaseRun follow-up 的日常派单也归入 AgentRun 资源原 --- +## 本地 Codex Trace 采集 + +`bun scripts/cli.ts codex trace ...` 是本机 Codex 运行痕迹的只读诊断入口,默认扫描 `~/.codex`,也可以用 `--root ` 指向其他 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/] [--limit 30] +bun scripts/cli.ts codex trace show --file sessions/2026/06/16/.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 替代命令: diff --git a/scripts/src/code-queue.ts b/scripts/src/code-queue.ts index 0859fadc..aa2f28d6 100644 --- a/scripts/src/code-queue.ts +++ b/scripts/src/code-queue.ts @@ -8122,6 +8122,10 @@ function codexResumeTask(taskId: string, args: string[], fetcher: CodexResponseF export async function runCodeQueueCommand(config: UniDeskConfig, args: string[]): Promise { 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"); } diff --git a/scripts/src/codex-trace.ts b/scripts/src/codex-trace.ts new file mode 100644 index 00000000..2c32c88c --- /dev/null +++ b/scripts/src/codex-trace.ts @@ -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 { + 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 .", + 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/] [--limit 30]", + "bun scripts/cli.ts codex trace show --file sessions/2026/06/16/.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 ": "Codex home or another trace root. Defaults to ~/.codex.", + "--output ": "Collect destination. Defaults to .state/codex-trace/.", + "--limit ": `Maximum included files, default ${defaultLimit}, max ${maxLimit}.`, + "--max-depth ": `Recursive scan depth, default ${defaultMaxDepth}, max ${maxDepthLimit}.`, + "--max-file-bytes ": "Skip files larger than this during collect/list inclusion.", + "--include-sqlite": "Include *.sqlite and sqlite sidecars if under max size.", + "--include-sensitive": "Allow auth/config-looking files. Off by default.", + "--include-cache": "Traverse cache, .tmp, generated_images, plugins, and skills directories. Off by default.", + "--dry-run": "For collect, report the plan without copying files.", + "--file ": "For show, file path relative to --root or an absolute path under --root.", + "--tail-bytes ": `For show, tail bytes to return, default ${defaultTailBytes}, max ${maxTailBytes}.`, + "--full": "For show, read from the beginning up to --max-file-bytes instead of tailing.", + }, + }; +} + +export function runCodexTraceCommand(args: string[]): Record { + 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 { + 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 ", + }, + valuesPrinted: false, + }; +} + +function codexTraceCollect(options: CodexTraceOptions, candidates: CodexTraceCandidate[]): Record { + const selected = selectedCandidates(options, candidates); + const outputDir = options.outputDir ?? path.join(repoRoot, ".state", "codex-trace", timestampForPath()); + const copied: Record[] = []; + if (!options.dryRun) mkdirSync(outputDir, { recursive: true, mode: 0o700 }); + for (const candidate of selected) { + const target = path.join(outputDir, candidate.relativePath); + if (!options.dryRun) { + mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 }); + copyFileSync(candidate.path, target); + } + copied.push({ + source: candidate.relativePath, + target: path.relative(outputDir, target), + kind: candidate.kind, + bytes: candidate.bytes, + mtime: new Date(candidate.mtimeMs).toISOString(), + }); + } + const manifest = { + generatedAt: new Date().toISOString(), + command: "codex trace collect", + root: options.root, + outputDir, + dryRun: options.dryRun, + policy: policySummary(options), + summary: candidateSummary(candidates, selected), + files: copied, + skipped: skippedSummary(candidates), + valuesPrinted: false, + }; + if (!options.dryRun) writeFileSync(path.join(outputDir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n", { mode: 0o600 }); + 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 { + if (options.file === null) throw new Error("codex trace show requires --file "); + 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 { + 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 { + const included = candidates.filter((item) => item.included); + const byKind: Record = {}; + for (const candidate of candidates) byKind[candidate.kind] = (byKind[candidate.kind] ?? 0) + 1; + return { + scannedFiles: candidates.length, + includedCandidates: included.length, + selectedFiles: selected.length, + selectedBytes: selected.reduce((sum, item) => sum + item.bytes, 0), + byKind, + }; +} + +function skippedSummary(candidates: CodexTraceCandidate[]): Record { + const skipped: Record = {}; + for (const candidate of candidates) { + if (candidate.skippedReason === null) continue; + skipped[candidate.skippedReason] = (skipped[candidate.skippedReason] ?? 0) + 1; + } + return skipped; +} + +function policySummary(options: CodexTraceOptions): Record { + return { + limit: options.limit, + maxDepth: options.maxDepth, + maxFileBytes: options.maxFileBytes, + includeSqlite: options.includeSqlite, + includeSensitive: options.includeSensitive, + includeCache: options.includeCache, + 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-") + .replace(/\b(?:ghp|github_pat)_[A-Za-z0-9_]{16,}/gu, "") + .replace(/((?:api[_-]?key|token|authorization|password|secret)["']?\s*[:=]\s*["']?)[^"',\s}]+/giu, "$1"); +} + +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}`); + } +} diff --git a/scripts/src/help.ts b/scripts/src/help.ts index ffa69ef4..0c217b34 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -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/] [--limit 30]", + "bun scripts/cli.ts codex trace show --file sessions/2026/06/16/.jsonl [--root ~/.codex] [--tail-bytes 12000]", "bun scripts/cli.ts codex deploy # 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//manifest.json", + note: "Use --root 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 for per-task review.", @@ -716,6 +726,7 @@ export async function staticNamespaceHelp(args: string[]): Promise (await import("./codex-trace")).codexTraceHelp(), codexHelp()); if (top === "codex") return codexHelp(); if (top === "job") return jobHelp(); if (top === "debug") return debugHelp();