From c310f05758b520ff3bcb0f9fdcd527c470ccd255 Mon Sep 17 00:00:00 2001 From: Reza Khosravi Date: Sun, 3 May 2026 01:44:47 +0200 Subject: [PATCH] feat: add /codex:attach command for live log streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new `attach` subcommand to codex-companion.mjs and a matching `/codex:attach` command definition. **Problem:** Background Codex jobs (`/codex:rescue --background`) produce no live output. The only way to observe progress is to poll `/codex:status` or wait for `/codex:result` after completion — a blind black-box experience. **Solution:** `/codex:attach [job-id]` tails the job's on-disk log file in real time and exits cleanly when the job reaches a terminal status (completed, failed, cancelled). If no job ID is given, it automatically attaches to the most recent active job. Implementation details: - New `handleAttach()` async function in codex-companion.mjs - Reads the job log file path from `job.logFile` (falls back to `resolveJobLogFile` if missing) - Polls for new log content every 500ms (configurable via `--poll-interval-ms`) - Simultaneously polls job state via `listJobs()` for terminal status - Final log flush + closing line on exit - No new dependencies; uses existing state/job-control infrastructure - Does not modify any existing commands or behaviour Co-Authored-By: Claude Sonnet 4.6 --- plugins/codex/commands/attach.md | 13 +++++ plugins/codex/scripts/codex-companion.mjs | 60 +++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 plugins/codex/commands/attach.md diff --git a/plugins/codex/commands/attach.md b/plugins/codex/commands/attach.md new file mode 100644 index 00000000..824a8086 --- /dev/null +++ b/plugins/codex/commands/attach.md @@ -0,0 +1,13 @@ +--- +description: Attach to a running Codex job and stream its live log output until it completes +argument-hint: '[job-id] [--poll-interval-ms ]' +disable-model-invocation: true +allowed-tools: Bash(node:*) +--- + +!`node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" attach "$ARGUMENTS"` + +Present the command output verbatim to the user. Do not summarize or condense it. + +If no active job is found, tell the user to start one with `/codex:rescue`. +If a job ID was not specified, the command automatically attaches to the most recent active job. diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 35222fd5..6a3eedec 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -28,6 +28,7 @@ import { generateJobId, getConfig, listJobs, + resolveJobLogFile, setConfig, upsertJob, writeJobFile @@ -837,6 +838,62 @@ async function handleTaskWorker(argv) { ); } +async function handleAttach(argv) { + const { options, positionals } = parseCommandInput(argv, { + valueOptions: ["cwd", "poll-interval-ms"], + booleanOptions: [] + }); + + const cwd = resolveCommandCwd(options); + const workspaceRoot = resolveCommandWorkspace(options); + const pollIntervalMs = Math.max(200, Number(options["poll-interval-ms"]) || 500); + const reference = positionals[0] ?? ""; + + let job; + if (reference) { + const snapshot = buildSingleJobSnapshot(cwd, reference); + job = snapshot.job; + } else { + const jobs = sortJobsNewestFirst(listJobs(workspaceRoot)); + job = jobs.find((j) => isActiveJobStatus(j.status)) ?? jobs[0] ?? null; + } + + if (!job) { + process.stdout.write("No Codex job found. Start one with /codex:rescue.\n"); + return; + } + + const logFile = job.logFile ?? resolveJobLogFile(workspaceRoot, job.id); + process.stdout.write(`[attach] Job ${job.id} · status: ${job.status}\n`); + if (job.title) process.stdout.write(`[attach] Task: ${job.title}\n`); + process.stdout.write(`[attach] Log: ${logFile}\n---\n`); + + let offset = 0; + + function flushNewLogContent() { + if (!fs.existsSync(logFile)) return; + const content = fs.readFileSync(logFile, "utf8"); + if (content.length > offset) { + process.stdout.write(content.slice(offset)); + offset = content.length; + } + } + + flushNewLogContent(); + + while (true) { + await sleep(pollIntervalMs); + flushNewLogContent(); + + const currentJob = listJobs(workspaceRoot).find((j) => j.id === job.id); + if (!currentJob || !isActiveJobStatus(currentJob.status)) { + flushNewLogContent(); + process.stdout.write(`\n--- Job ${job.id} ${currentJob?.status ?? "gone"} ---\n`); + break; + } + } +} + async function handleStatus(argv) { const { options, positionals } = parseCommandInput(argv, { valueOptions: ["cwd", "timeout-ms", "poll-interval-ms"], @@ -1015,6 +1072,9 @@ async function main() { case "cancel": await handleCancel(argv); break; + case "attach": + await handleAttach(argv); + break; default: throw new Error(`Unknown subcommand: ${subcommand}`); }