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}`); }