diff --git a/.agents/skills/unidesk-code-queue/SKILL.md b/.agents/skills/unidesk-code-queue/SKILL.md index aff0cd16..114c6ff0 100644 --- a/.agents/skills/unidesk-code-queue/SKILL.md +++ b/.agents/skills/unidesk-code-queue/SKILL.md @@ -93,11 +93,13 @@ 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 --pattern 'playwright|auth-login-failed' [--file sessions/...jsonl] [--since ISO] bun scripts/cli.ts codex trace collect [--root ~/.codex] [--output .state/codex-trace/] [--limit 30] bun scripts/cli.ts codex trace show --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,不压缩、不上传、不删除源文件。 +默认只收集 `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,不压缩、不上传、不删除源文件。 --- diff --git a/scripts/cli.ts b/scripts/cli.ts index 8c055510..2351d367 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -524,6 +524,11 @@ async function main(): Promise { } const result = await runCodeQueueCommand(config, args.slice(1)); const ok = (result as { ok?: unknown }).ok !== false; + if (isRenderedCliResult(result)) { + emitText(result.renderedText); + if (!ok) process.exitCode = 1; + return; + } emitJson(commandName, result, ok); if (!ok) process.exitCode = 1; return; diff --git a/scripts/src/codex-trace.ts b/scripts/src/codex-trace.ts index 2c32c88c..25820f03 100644 --- a/scripts/src/codex-trace.ts +++ b/scripts/src/codex-trace.ts @@ -1,10 +1,13 @@ -import { closeSync, copyFileSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, realpathSync, statSync, writeFileSync } from "node:fs"; +import { closeSync, copyFileSync, createReadStream, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readlinkSync, readSync, realpathSync, statSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import path from "node:path"; +import { createInterface } from "node:readline"; import { repoRoot } from "./config"; +import type { RenderedCliResult } from "./output"; -type CodexTraceAction = "help" | "list" | "collect" | "show"; +type CodexTraceAction = "help" | "list" | "collect" | "show" | "grep" | "active"; type CodexTraceKind = "session-jsonl" | "history-jsonl" | "shell-snapshot" | "log" | "trace-named" | "jsonl" | "sqlite-db" | "sqlite-sidecar" | "unrecognized"; +type CodexTraceOutput = "text" | "wide" | "name" | "json" | "yaml"; interface CodexTraceOptions { action: CodexTraceAction; @@ -21,6 +24,13 @@ interface CodexTraceOptions { dryRun: boolean; full: boolean; allowOutsideRoot: boolean; + pattern: string | null; + fixed: boolean; + includeSystem: boolean; + contextChars: number; + since: string | null; + fileLimit: number; + output: CodexTraceOutput; } interface CodexTraceCandidate { @@ -41,21 +51,28 @@ const defaultMaxFileBytes = 25 * 1024 * 1024; const maxFileBytesLimit = 512 * 1024 * 1024; const defaultTailBytes = 12_000; const maxTailBytes = 1_000_000; +const defaultContextChars = 700; +const maxContextChars = 4_000; +const defaultFileLimit = 8; +const maxFileLimit = 50; export function codexTraceHelp(): Record { return { ok: true, command: "codex trace", - output: "json", + output: "text", description: "Collect bounded local Codex trace artifacts. Defaults to ~/.codex and can scan another root with --root .", usage: [ "bun scripts/cli.ts codex trace list [--root ~/.codex] [--limit 30]", + "bun scripts/cli.ts codex trace active [--root ~/.codex]", + "bun scripts/cli.ts codex trace grep --pattern 'playwright|auth-login-failed' [--file sessions/...jsonl] [--since ISO]", "bun scripts/cli.ts codex trace collect [--root ~/.codex] [--output .state/codex-trace/] [--limit 30]", "bun scripts/cli.ts codex trace show --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.", + "grep parses JSONL records, skips bootstrap/system payloads by default, and returns bounded match-centered summaries instead of whole lines.", "Use --include-sqlite or --include-sensitive only for reviewed local diagnostics; values are not printed unless show reads a text file.", "collect copies bounded files and writes a manifest; it does not compress, upload, or delete source files.", ], @@ -70,16 +87,25 @@ export function codexTraceHelp(): Record { "--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.", + "--pattern ": "For grep, pattern to match against structured event text.", + "--fixed": "For grep, treat --pattern as a literal string.", + "--since ": "For grep, skip JSONL events before the timestamp string.", + "--context-chars ": `For grep, max summary characters per match, default ${defaultContextChars}, max ${maxContextChars}.`, + "--file-limit ": `For grep without --file, max target files, default ${defaultFileLimit}, max ${maxFileLimit}.`, + "--include-system": "For grep, include session_meta/system instruction records. Off by default.", + "-o json|yaml|name|wide": "Output mode. Default is concise text/table.", "--tail-bytes ": `For show, tail bytes to return, default ${defaultTailBytes}, max ${maxTailBytes}.`, "--full": "For show, read from the beginning up to --max-file-bytes instead of tailing.", }, }; } -export function runCodexTraceCommand(args: string[]): Record { +export async function runCodexTraceCommand(args: string[]): Promise | RenderedCliResult> { const options = parseCodexTraceOptions(args); - if (options.action === "help") return codexTraceHelp(); + if (options.action === "help") return renderCodexTraceResult(options, "codex trace help", codexTraceHelp()); + if (options.action === "active") return codexTraceActive(options); if (options.action === "show") return codexTraceShow(options); + if (options.action === "grep") return codexTraceGrep(options); const candidates = scanCodexTraceRoot(options); if (options.action === "list") return codexTraceList(options, candidates); return codexTraceCollect(options, candidates); @@ -89,12 +115,12 @@ 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}`); })(); + : rawAction === "list" || rawAction === "collect" || rawAction === "show" || rawAction === "grep" || rawAction === "active" ? rawAction + : (() => { throw new Error(`codex trace action must be one of: list, active, grep, collect, show, help; got ${rawAction}`); })(); const optionArgs = action === "help" && rawAction === undefined ? args : args.slice(1); assertKnownOptions(optionArgs, { - flags: ["--dry-run", "--full", "--include-sqlite", "--include-sensitive", "--include-cache", "--allow-outside-root", "--help", "-h"], - values: ["--root", "--output", "--file", "--limit", "--max-depth", "--max-file-bytes", "--tail-bytes"], + 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"], }); if (optionArgs.includes("--help") || optionArgs.includes("-h")) { return { @@ -112,9 +138,18 @@ function parseCodexTraceOptions(args: string[]): CodexTraceOptions { dryRun: false, full: false, allowOutsideRoot: false, + pattern: null, + fixed: false, + includeSystem: false, + contextChars: defaultContextChars, + since: null, + fileLimit: defaultFileLimit, + output: "text", }; } const root = resolveUserPath(optionValue(optionArgs, "--root") ?? defaultCodexRoot()); + const positionalPattern = action === "grep" && optionArgs[0] !== undefined && !optionArgs[0].startsWith("-") ? optionArgs[0] : null; + const output = parseOutputMode(optionValue(optionArgs, "-o") ?? optionValue(optionArgs, "--format"), hasFlag(optionArgs, "--wide")); return { action, root, @@ -130,12 +165,19 @@ function parseCodexTraceOptions(args: string[]): CodexTraceOptions { dryRun: hasFlag(optionArgs, "--dry-run"), full: hasFlag(optionArgs, "--full"), allowOutsideRoot: hasFlag(optionArgs, "--allow-outside-root"), + pattern: optionValue(optionArgs, "--pattern") ?? positionalPattern, + fixed: hasFlag(optionArgs, "--fixed"), + includeSystem: hasFlag(optionArgs, "--include-system"), + contextChars: positiveIntegerOption(optionArgs, "--context-chars", defaultContextChars, maxContextChars), + since: optionValue(optionArgs, "--since") ?? null, + fileLimit: positiveIntegerOption(optionArgs, "--file-limit", defaultFileLimit, maxFileLimit), + output, }; } -function codexTraceList(options: CodexTraceOptions, candidates: CodexTraceCandidate[]): Record { +function codexTraceList(options: CodexTraceOptions, candidates: CodexTraceCandidate[]): Record | RenderedCliResult { const selected = selectedCandidates(options, candidates); - return { + const raw = { ok: true, command: "codex trace list", root: options.root, @@ -151,9 +193,10 @@ function codexTraceList(options: CodexTraceOptions, candidates: CodexTraceCandid }, valuesPrinted: false, }; + return renderCodexTraceResult(options, "codex trace list", raw); } -function codexTraceCollect(options: CodexTraceOptions, candidates: CodexTraceCandidate[]): Record { +function codexTraceCollect(options: CodexTraceOptions, candidates: CodexTraceCandidate[]): Record | RenderedCliResult { const selected = selectedCandidates(options, candidates); const outputDir = options.outputDir ?? path.join(repoRoot, ".state", "codex-trace", timestampForPath()); const copied: Record[] = []; @@ -185,7 +228,7 @@ function codexTraceCollect(options: CodexTraceOptions, candidates: CodexTraceCan valuesPrinted: false, }; if (!options.dryRun) writeFileSync(path.join(outputDir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n", { mode: 0o600 }); - return { + const raw = { ok: true, command: "codex trace collect", root: options.root, @@ -199,9 +242,10 @@ function codexTraceCollect(options: CodexTraceOptions, candidates: CodexTraceCan skipped: skippedSummary(candidates), valuesPrinted: false, }; + return renderCodexTraceResult(options, "codex trace collect", raw); } -function codexTraceShow(options: CodexTraceOptions): Record { +function codexTraceShow(options: CodexTraceOptions): Record | RenderedCliResult { if (options.file === null) throw new Error("codex trace show requires --file "); const filePath = resolveTraceFile(options.root, options.file); const rootReal = safeRealpath(options.root); @@ -222,7 +266,7 @@ function codexTraceShow(options: CodexTraceOptions): Record { 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 { + const raw = { ok: true, command: "codex trace show", root: options.root, @@ -243,6 +287,213 @@ function codexTraceShow(options: CodexTraceOptions): Record { content, valuesPrinted: content !== null, }; + return renderCodexTraceResult(options, "codex trace show", raw); +} + +function codexTraceActive(options: CodexTraceOptions): Record | RenderedCliResult { + const rootReal = safeRealpath(options.root); + const procDir = "/proc"; + if (rootReal === null) { + const raw = { + ok: true, + command: "codex trace active", + root: options.root, + rootPresent: false, + supported: existsSync(procDir), + sessions: [], + valuesPrinted: false, + }; + return renderCodexTraceResult(options, "codex trace active", raw); + } + const sessions = activeCodexSessionFiles(rootReal).slice(0, options.limit); + const raw = { + ok: true, + command: "codex trace active", + root: options.root, + rootPresent: true, + supported: existsSync(procDir), + count: sessions.length, + sessions, + next: { + grep: "bun scripts/cli.ts codex trace grep --pattern --file ", + collect: "bun scripts/cli.ts codex trace collect --limit 30", + }, + valuesPrinted: false, + }; + return renderCodexTraceResult(options, "codex trace active", raw); +} + +async function codexTraceGrep(options: CodexTraceOptions): Promise | RenderedCliResult> { + if (options.pattern === null || options.pattern.length === 0) throw new Error("codex trace grep requires --pattern or a positional pattern"); + const matcher = makeMatcher(options.pattern, options.fixed); + const rootReal = safeRealpath(options.root); + if (rootReal === null) throw new Error(`codex trace root does not exist: ${options.root}`); + const files = grepTargetFiles(options, rootReal); + const results: Record[] = []; + let linesScanned = 0; + let matchedTotal = 0; + for (const file of files) { + const report = await grepJsonlFile(file, rootReal, matcher, options, results, options.limit); + linesScanned += report.linesScanned; + matchedTotal += report.matchedTotal; + if (results.length >= options.limit) break; + } + const raw = { + ok: true, + command: "codex trace grep", + root: options.root, + pattern: options.pattern, + fixed: options.fixed, + since: options.since, + policy: { + limit: options.limit, + fileLimit: options.fileLimit, + contextChars: options.contextChars, + includeSystem: options.includeSystem, + valuesRedacted: true, + }, + filesScanned: files.length, + linesScanned, + matchedTotal, + matchesReturned: results.length, + truncated: matchedTotal > results.length, + files: files.map((file) => file.relativePath), + results, + valuesPrinted: results.length > 0, + }; + return renderCodexTraceResult(options, "codex trace grep", raw); +} + +function renderCodexTraceResult(options: CodexTraceOptions, command: string, raw: Record): Record | RenderedCliResult { + const ok = raw.ok !== false; + if (options.output === "json") return renderedCliResult(ok, command, `${JSON.stringify(raw, null, 2)}\n`, "application/json"); + if (options.output === "yaml") return renderedCliResult(ok, command, `${Bun.YAML.stringify(raw)}\n`, "application/yaml"); + if (options.output === "name") return renderedCliResult(ok, command, renderCodexTraceNames(raw)); + if (command === "codex trace help" || raw.command === "codex trace") return renderedCliResult(ok, command, renderCodexTraceHelp(raw)); + if (raw.command === "codex trace active") return renderedCliResult(ok, command, renderCodexTraceActive(raw, options)); + if (raw.command === "codex trace grep") return renderedCliResult(ok, command, renderCodexTraceGrep(raw, options)); + if (raw.command === "codex trace list") return renderedCliResult(ok, command, renderCodexTraceList(raw, options)); + if (raw.command === "codex trace collect") return renderedCliResult(ok, command, renderCodexTraceCollect(raw, options)); + if (raw.command === "codex trace show") return renderedCliResult(ok, command, renderCodexTraceShow(raw)); + return raw; +} + +function renderedCliResult(ok: boolean, command: string, renderedText: string, contentType: RenderedCliResult["contentType"] = "text/plain"): RenderedCliResult { + return { ok, command, renderedText: renderedText.endsWith("\n") ? renderedText : `${renderedText}\n`, contentType }; +} + +function renderCodexTraceHelp(raw: Record): string { + const usage = stringArray(raw.usage); + const safety = stringArray(raw.safety); + const options = asRecord(raw.options) ?? {}; + const lines = [ + "Codex trace", + String(raw.description ?? ""), + "", + "Usage:", + ...usage.map((line) => ` ${line}`), + "", + "Safety:", + ...safety.map((line) => ` - ${line}`), + "", + "Options:", + ...Object.entries(options).map(([key, value]) => ` ${key.padEnd(24)} ${String(value)}`), + ]; + return lines.filter((line, index) => line.length > 0 || lines[index - 1]?.length !== 0).join("\n"); +} + +function renderCodexTraceActive(raw: Record, options: CodexTraceOptions): string { + const sessions = recordArray(raw.sessions); + if (sessions.length === 0) return "No active Codex session files found."; + const rows = sessions.map((session) => [ + String(session.pid ?? "-"), + String(session.fd ?? "-"), + ageFromIso(stringValue(session.mtime)), + formatBytes(numberValue(session.bytes)), + truncateMiddle(String(session.path ?? "-"), options.output === "wide" ? 140 : 82), + ]); + return [ + renderTable(["PID", "FD", "AGE", "SIZE", "SESSION"], rows), + "", + "Next:", + " bun scripts/cli.ts codex trace grep --file --pattern ", + ].join("\n"); +} + +function renderCodexTraceGrep(raw: Record, options: CodexTraceOptions): string { + const results = recordArray(raw.results); + const header = `Matched ${String(raw.matchesReturned ?? 0)}/${String(raw.matchedTotal ?? 0)} events in ${String(raw.filesScanned ?? 0)} file(s), scanned ${String(raw.linesScanned ?? 0)} lines${raw.truncated === true ? " (truncated)" : ""}.`; + if (results.length === 0) return `${header}\nNo matching trace events found.`; + const rows = results.map((result) => [ + shortIso(stringValue(result.timestamp)), + `${truncateMiddle(String(result.file ?? "-"), options.output === "wide" ? 96 : 42)}:${String(result.line ?? "-")}`, + truncateMiddle(String(result.item ?? result.type ?? "-"), 24), + signalLabel(asRecord(result.signals)), + truncateOneLine(String(result.summary ?? ""), options.output === "wide" ? 220 : 96), + ]); + return [ + header, + renderTable(["TIME", "LOCATION", "ITEM", "SIGNALS", "SUMMARY"], rows), + ].join("\n"); +} + +function renderCodexTraceList(raw: Record, options: CodexTraceOptions): string { + const files = recordArray(raw.files); + if (files.length === 0) return `No trace files found under ${String(raw.root ?? "-")}.`; + const rows = files.map((file) => [ + truncateMiddle(String(file.path ?? "-"), options.output === "wide" ? 140 : 82), + String(file.kind ?? "-"), + formatBytes(numberValue(file.bytes)), + ageFromIso(stringValue(file.mtime)), + ]); + const skipped = asRecord(raw.skipped) ?? {}; + const skippedText = Object.entries(skipped).map(([key, value]) => `${key}=${String(value)}`).join(" "); + return [ + `Root: ${String(raw.root ?? "-")}`, + renderTable(["PATH", "KIND", "SIZE", "AGE"], rows), + skippedText.length > 0 ? `Skipped: ${skippedText}` : "", + Number(raw.omitted ?? 0) > 0 ? `Omitted: ${String(raw.omitted)}` : "", + ].filter((line) => line.length > 0).join("\n"); +} + +function renderCodexTraceCollect(raw: Record, options: CodexTraceOptions): string { + const files = recordArray(raw.files); + const headline = raw.dryRun === true ? "Dry-run collect" : "Collected"; + const rows = files.map((file) => [ + truncateMiddle(String(file.source ?? "-"), options.output === "wide" ? 120 : 72), + String(file.kind ?? "-"), + formatBytes(numberValue(file.bytes)), + ]); + return [ + `${headline}: ${String(raw.copiedCount ?? 0)} file(s), ${formatBytes(numberValue(raw.copiedBytes))}`, + `Output: ${String(raw.outputDir ?? "-")}`, + raw.manifest === null || raw.manifest === undefined ? "" : `Manifest: ${String(raw.manifest)}`, + rows.length > 0 ? renderTable(["SOURCE", "KIND", "SIZE"], rows) : "", + ].filter((line) => line.length > 0).join("\n"); +} + +function renderCodexTraceShow(raw: Record): string { + const policy = asRecord(raw.contentPolicy) ?? {}; + const content = stringValue(raw.content); + const lines = [ + `Name: ${String(raw.relativePath ?? raw.file ?? "-")}`, + `Kind: ${String(raw.kind ?? "-")}`, + `Size: ${formatBytes(numberValue(raw.bytes))}`, + `Mode: ${String(policy.mode ?? "-")} bytes=${String(policy.bytesReturned ?? 0)} truncated=${String(policy.truncated ?? false)}`, + ]; + if (content !== null) lines.push("---", content); + else lines.push("Content: "); + return lines.join("\n"); +} + +function renderCodexTraceNames(raw: Record): string { + const sessions = recordArray(raw.sessions); + if (sessions.length > 0) return sessions.map((session) => `session/${String(session.path ?? "")}`).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"); + const results = recordArray(raw.results); + if (results.length > 0) return results.map((result) => `${String(result.file ?? "")}:${String(result.line ?? "")}`).join("\n"); + return ""; } function scanCodexTraceRoot(options: CodexTraceOptions): CodexTraceCandidate[] { @@ -291,6 +542,175 @@ function scanCodexTraceRoot(options: CodexTraceOptions): CodexTraceCandidate[] { return candidates.sort((left, right) => right.mtimeMs - left.mtimeMs || left.relativePath.localeCompare(right.relativePath)); } +function activeCodexSessionFiles(rootReal: string): Record[] { + const procDir = "/proc"; + if (!existsSync(procDir)) return []; + const rows = new Map>(); + let procEntries; + try { + procEntries = readdirSync(procDir, { withFileTypes: true }); + } catch { + return []; + } + for (const procEntry of procEntries) { + if (!procEntry.isDirectory() || !/^\d+$/u.test(procEntry.name)) continue; + const pid = Number(procEntry.name); + const fdDir = path.join(procDir, procEntry.name, "fd"); + let fdEntries; + try { + fdEntries = readdirSync(fdDir, { withFileTypes: true }); + } catch { + continue; + } + const cmdline = readProcCmdline(pid); + if (!isCodexSessionProcess(cmdline)) continue; + for (const fdEntry of fdEntries) { + let target; + try { + target = readlinkSync(path.join(fdDir, fdEntry.name)); + } catch { + continue; + } + const deletedSuffix = " (deleted)"; + if (target.endsWith(deletedSuffix)) target = target.slice(0, -deletedSuffix.length); + if (!target.endsWith(".jsonl") || !isWithin(rootReal, target) || !normalizePath(path.relative(rootReal, target)).startsWith("sessions/")) continue; + const realTarget = safeRealpath(target); + if (realTarget === null || !isWithin(rootReal, realTarget)) continue; + let stats; + try { + stats = statSync(realTarget); + } catch { + continue; + } + const relativePath = path.relative(rootReal, realTarget); + const key = `${pid}:${realTarget}`; + rows.set(key, { + pid, + fd: fdEntry.name, + path: relativePath, + bytes: stats.size, + mtime: stats.mtime.toISOString(), + command: truncateOneLine(cmdline, 180), + grepCommand: `bun scripts/cli.ts codex trace grep --file ${shellWord(relativePath)} --pattern `, + showCommand: `bun scripts/cli.ts codex trace show --file ${shellWord(relativePath)}`, + }); + } + } + return [...rows.values()].sort((left, right) => String(right.mtime).localeCompare(String(left.mtime)) || Number(left.pid) - Number(right.pid)); +} + +function grepTargetFiles(options: CodexTraceOptions, rootReal: string): CodexTraceCandidate[] { + if (options.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, + }]; + } + const active = activeCodexSessionFiles(rootReal) + .map((row) => String(row.path)) + .filter((relativePath) => relativePath.length > 0) + .map((relativePath) => { + const filePath = path.join(rootReal, relativePath); + const stats = statSync(filePath); + return { + path: filePath, + relativePath, + kind: classifyTraceKind(relativePath, path.basename(filePath)), + bytes: stats.size, + mtimeMs: stats.mtimeMs, + included: true, + skippedReason: null, + } satisfies CodexTraceCandidate; + }); + const scanned = scanCodexTraceRoot(options).filter((candidate) => candidate.included && isTextReadableTrace(candidate.kind, candidate.path)); + const byPath = new Map(); + for (const candidate of [...active, ...scanned]) { + if (!isTextReadableTrace(candidate.kind, candidate.path)) continue; + byPath.set(candidate.path, candidate); + } + return [...byPath.values()].sort((left, right) => right.mtimeMs - left.mtimeMs).slice(0, options.fileLimit); +} + +async function grepJsonlFile( + candidate: CodexTraceCandidate, + rootReal: string, + matcher: RegExp, + options: CodexTraceOptions, + results: Record[], + limit: number, +): Promise<{ linesScanned: number; matchedTotal: number }> { + let linesScanned = 0; + let matchedTotal = 0; + const input = createReadStream(candidate.path, { encoding: "utf8" }); + const reader = createInterface({ input, crlfDelay: Infinity }); + try { + for await (const line of reader) { + linesScanned += 1; + const event = parseJsonlEvent(line); + if (event === null) { + const rawMatch = matcher.test(line); + matcher.lastIndex = 0; + if (!rawMatch) continue; + matchedTotal += 1; + if (results.length < limit) { + results.push({ + file: candidate.relativePath, + line: linesScanned, + timestamp: null, + type: "raw", + fields: ["raw"], + summary: truncateOneLine(redactTraceText(line), options.contextChars), + signals: traceSignals(line), + }); + } + continue; + } + 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 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); + } + matcher.lastIndex = 0; + } + if (matchedFields.length === 0) continue; + matchedTotal += 1; + if (results.length >= limit) continue; + const summary = summarizeMatchedTraceEvent(event, fields, matcher, options.contextChars); + results.push({ + file: path.relative(rootReal, candidate.path), + line: linesScanned, + timestamp: typeof event.timestamp === "string" ? event.timestamp : null, + type: event.type, + item: traceEventItem(event), + fields: matchedFields, + summary, + signals: traceSignals(matchedValues.join(" ")), + }); + } + } finally { + reader.close(); + input.destroy(); + } + return { linesScanned, matchedTotal }; +} + function selectedCandidates(options: CodexTraceOptions, candidates: CodexTraceCandidate[]): CodexTraceCandidate[] { return candidates.filter((item) => item.included).slice(0, options.limit); } @@ -377,6 +797,151 @@ function policySummary(options: CodexTraceOptions): Record { }; } +function parseJsonlEvent(line: string): Record | null { + try { + const parsed = JSON.parse(line); + return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as Record : null; + } catch { + return null; + } +} + +function isBootstrapTraceEvent(event: Record): boolean { + const payload = asRecord(event.payload); + if (event.type === "session_meta") return true; + if (event.type !== "response_item") return false; + if (payload?.type !== "message") return false; + const content = Array.isArray(payload.content) ? payload.content : []; + const text = content + .map((item) => stringValue(asRecord(item)?.text)) + .filter((value): value is string => value !== null) + .join("\n"); + return text.includes("") + || text.includes("") + || text.includes("# AGENTS.md instructions") + || text.includes("You are Codex, a coding agent") + || text.includes("Knowledge cutoff:"); +} + +function traceEventFields(event: Record): Array<{ name: string; value: string }> { + const payload = asRecord(event.payload); + const fields: Array<{ name: string; value: string }> = []; + addField(fields, "timestamp", event.timestamp); + addField(fields, "type", event.type); + addField(fields, "payload.type", payload?.type); + addField(fields, "payload.role", payload?.role); + addField(fields, "payload.name", payload?.name); + addField(fields, "message", payload?.message); + addField(fields, "arguments", payload?.arguments); + addField(fields, "output", payload?.output); + const content = Array.isArray(payload?.content) ? payload.content : []; + for (const [index, item] of content.entries()) { + const record = asRecord(item); + addField(fields, `content.${index}.type`, record?.type); + addField(fields, `content.${index}.text`, record?.text); + } + return fields; +} + +function summarizeTraceEvent(event: Record, maxChars: number): string { + const payload = asRecord(event.payload); + const eventType = stringValue(event.type); + const payloadType = stringValue(payload?.type); + if (eventType === "response_item" && payloadType === "function_call") { + const name = stringValue(payload?.name) ?? "function_call"; + const args = parseJsonObject(stringValue(payload?.arguments) ?? ""); + const cmd = stringValue(args?.cmd); + return truncateOneLine(redactTraceText(cmd === null ? `${name} ${stringValue(payload?.arguments) ?? ""}` : `${name}: ${cmd}`), maxChars); + } + if (eventType === "response_item" && payloadType === "function_call_output") { + const callId = stringValue(payload?.call_id); + const output = stringValue(payload?.output) ?? ""; + return truncateOneLine(redactTraceText(`function_call_output${callId === null ? "" : ` ${callId}`}: ${output}`), maxChars); + } + if (eventType === "response_item" && payloadType === "message") { + const content = Array.isArray(payload?.content) ? payload.content : []; + const texts = content.map((item) => stringValue(asRecord(item)?.text)).filter((value): value is string => value !== null); + return truncateOneLine(redactTraceText(texts.join(" ")), maxChars); + } + if (eventType === "event_msg") { + return truncateOneLine(redactTraceText(stringValue(payload?.message) ?? JSON.stringify(payload ?? {})), maxChars); + } + return truncateOneLine(redactTraceText(JSON.stringify(event)), maxChars); +} + +function summarizeMatchedTraceEvent(event: Record, fields: Array<{ name: string; value: string }>, matcher: RegExp, maxChars: number): string { + const item = traceEventItem(event); + for (const field of fields) { + 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); + return truncateOneLine(`${item === null ? String(event.type ?? "event") : item} ${field.name}: ${snippet}`, maxChars + 120); + } + return summarizeTraceEvent(event, maxChars); +} + +function snippetAroundMatch(value: string, matchIndex: number, maxChars: number): string { + const halfWindow = Math.max(40, Math.floor(maxChars / 2)); + const start = Math.max(0, matchIndex - halfWindow); + const end = Math.min(value.length, start + maxChars); + const prefix = start > 0 ? "…" : ""; + const suffix = end < value.length ? "…" : ""; + return `${prefix}${value.slice(start, end)}${suffix}`; +} + +function traceEventItem(event: Record): string | null { + const payload = asRecord(event.payload); + return stringValue(payload?.name) ?? stringValue(payload?.type) ?? stringValue(payload?.role); +} + +function traceSignals(text: string): Record { + return { + authLoginFailed: /auth-login-failed/iu.test(text), + status503: /status["']?\s*[:=]\s*503|Service Unavailable/iu.test(text), + sshHint: /UNIDESK_SSH_HINT|UNIDESK_SSH_TCP_POOL_HINT/iu.test(text), + transTimeoutHint: /UNIDESK_TRAN_TIMEOUT_HINT/iu.test(text), + sshTiming: /UNIDESK_SSH_TIMING/iu.test(text), + downloadProgress: /unidesk\.ssh\.download\.progress/iu.test(text), + sshPlaywright: /"command"\s*:\s*"ssh playwright"|command['"]?\s*[:=]\s*['"]?ssh playwright/iu.test(text), + }; +} + +function addField(fields: Array<{ name: string; value: string }>, name: string, value: unknown): void { + const text = stringValue(value); + if (text !== null && text.length > 0) fields.push({ name, value: text }); +} + +function asRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : null; +} + +function stringValue(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +function numberValue(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function stringArray(value: unknown): string[] { + return Array.isArray(value) ? value.map((item) => String(item)) : []; +} + +function recordArray(value: unknown): Record[] { + return Array.isArray(value) ? value.map(asRecord).filter((item): item is Record => item !== null) : []; +} + +function parseJsonObject(value: string): Record | null { + if (value.trim().length === 0) return null; + try { + const parsed = JSON.parse(value); + return asRecord(parsed); + } catch { + return null; + } +} + function readFileSlice(filePath: string, start: number, length: number): string { if (length <= 0) return ""; const fd = openSync(filePath, "r"); @@ -396,12 +961,121 @@ function redactTraceText(text: string): string { .replace(/((?:api[_-]?key|token|authorization|password|secret)["']?\s*[:=]\s*["']?)[^"',\s}]+/giu, "$1"); } +function truncateOneLine(value: string, maxChars: number): string { + const oneLine = value.replace(/\s+/gu, " ").trim(); + return oneLine.length <= maxChars ? oneLine : `${oneLine.slice(0, Math.max(0, maxChars - 1))}…`; +} + +function truncateMiddle(value: string, maxChars: number): string { + if (value.length <= maxChars) return value; + if (maxChars <= 8) return value.slice(0, maxChars); + const keep = maxChars - 1; + const left = Math.ceil(keep * 0.58); + const right = keep - left; + return `${value.slice(0, left)}…${value.slice(value.length - right)}`; +} + +function renderTable(headers: string[], rows: string[][]): string { + const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => visibleLength(row[index] ?? "")))); + const renderRow = (row: string[]) => row.map((cell, index) => padRight(cell, widths[index] ?? 0)).join(" ").trimEnd(); + return [renderRow(headers), ...rows.map(renderRow)].join("\n"); +} + +function padRight(value: string, width: number): string { + const length = visibleLength(value); + return length >= width ? value : `${value}${" ".repeat(width - length)}`; +} + +function visibleLength(value: string): number { + return [...value].length; +} + +function formatBytes(value: number | null): string { + if (value === null) return "-"; + const units = ["B", "Ki", "Mi", "Gi"]; + let size = value; + let unitIndex = 0; + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex += 1; + } + return unitIndex === 0 ? `${value}B` : `${size.toFixed(size >= 10 ? 0 : 1)}${units[unitIndex]}`; +} + +function ageFromIso(value: string | null): string { + if (value === null) return "-"; + const time = Date.parse(value); + if (!Number.isFinite(time)) return "-"; + const seconds = Math.max(0, Math.floor((Date.now() - time) / 1000)); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + if (hours < 48) return `${hours}h`; + return `${Math.floor(hours / 24)}d`; +} + +function shortIso(value: string | null): string { + if (value === null) return "-"; + return value.replace(/\.\d{3}Z$/u, "Z"); +} + +function signalLabel(signals: Record | null): string { + if (signals === null) return "-"; + const labels = [ + signals.authLoginFailed === true ? "auth" : "", + signals.status503 === true ? "503" : "", + signals.sshHint === true ? "ssh-hint" : "", + signals.transTimeoutHint === true ? "timeout" : "", + signals.sshTiming === true ? "timing" : "", + signals.downloadProgress === true ? "download" : "", + signals.sshPlaywright === true ? "playwright" : "", + ].filter((label) => label.length > 0); + return labels.length === 0 ? "-" : labels.join(","); +} + function resolveTraceFile(root: string, file: string): string { if (file === "~" || file.startsWith("~/")) return resolveUserPath(file); if (path.isAbsolute(file)) return file; return path.resolve(root, file); } +function readProcCmdline(pid: number): string { + try { + const raw = readFileSync(path.join("/proc", String(pid), "cmdline"), "utf8"); + return raw.split("\0").filter(Boolean).join(" "); + } catch { + return ""; + } +} + +function isCodexSessionProcess(cmdline: string): boolean { + const [executable] = cmdline.split(/\s+/u).filter(Boolean); + if (executable === undefined) return false; + return path.basename(executable) === "codex"; +} + +function makeMatcher(pattern: string, fixed: boolean): RegExp { + const source = fixed ? escapeRegExp(pattern) : pattern; + try { + return new RegExp(source, "iu"); + } catch (error) { + if (fixed) throw error; + return new RegExp(escapeRegExp(pattern), "iu"); + } +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} + +function parseOutputMode(raw: string | undefined, wide: boolean): CodexTraceOutput { + if (wide) return "wide"; + if (raw === undefined) return "text"; + if (raw === "json" || raw === "yaml" || raw === "name" || raw === "wide") return raw; + throw new Error(`codex trace output mode must be one of: json, yaml, name, wide; got ${raw}`); +} + function defaultCodexRoot(): string { return path.join(homedir(), ".codex"); } diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 0c217b34..5b8ea47f 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -389,6 +389,8 @@ function codexHelp(): unknown { output: "json", 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 --pattern 'playwright|auth-login-failed' [--file sessions/...jsonl] [--since ISO]", "bun scripts/cli.ts codex trace collect [--root ~/.codex] [--output .state/codex-trace/] [--limit 30]", "bun scripts/cli.ts codex trace show --file sessions/2026/06/16/.jsonl [--root ~/.codex] [--tail-bytes 12000]", "bun scripts/cli.ts codex deploy # disabled legacy deployment entry", @@ -442,6 +444,9 @@ 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.", + output: "Default output is concise text/table; use -o json|yaml|name|wide for machine or wider output.", 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.", }, @@ -726,7 +731,7 @@ export async function staticNamespaceHelp(args: string[]): Promise (await import("./codex-trace")).codexTraceHelp(), codexHelp()); + if (top === "codex" && sub === "trace") return null; if (top === "codex") return codexHelp(); if (top === "job") return jobHelp(); if (top === "debug") return debugHelp();