Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions plugins/codex/commands/attach.md
Original file line number Diff line number Diff line change
@@ -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 <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.
60 changes: 60 additions & 0 deletions plugins/codex/scripts/codex-companion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
generateJobId,
getConfig,
listJobs,
resolveJobLogFile,
setConfig,
upsertJob,
writeJobFile
Expand Down Expand Up @@ -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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Select only active jobs when no ID is provided

When attach is run without a job ID, this fallback picks jobs[0] whenever no queued/running job exists, so a previously completed/failed job is treated as attachable instead of reporting that there is no active job. In repositories with old job history, users will unexpectedly stream stale logs and immediately exit rather than getting the intended guidance to start /codex:rescue, which contradicts the command contract in plugins/codex/commands/attach.md.

Useful? React with 👍 / 👎.

}

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"],
Expand Down Expand Up @@ -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}`);
}
Expand Down