Skip to content

Commit c310f05

Browse files
Reza Khosraviclaude
authored andcommitted
feat: add /codex:attach command for live log streaming
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 <noreply@anthropic.com>
1 parent 807e03a commit c310f05

2 files changed

Lines changed: 73 additions & 0 deletions

File tree

plugins/codex/commands/attach.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
description: Attach to a running Codex job and stream its live log output until it completes
3+
argument-hint: '[job-id] [--poll-interval-ms <ms>]'
4+
disable-model-invocation: true
5+
allowed-tools: Bash(node:*)
6+
---
7+
8+
!`node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" attach "$ARGUMENTS"`
9+
10+
Present the command output verbatim to the user. Do not summarize or condense it.
11+
12+
If no active job is found, tell the user to start one with `/codex:rescue`.
13+
If a job ID was not specified, the command automatically attaches to the most recent active job.

plugins/codex/scripts/codex-companion.mjs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
generateJobId,
2929
getConfig,
3030
listJobs,
31+
resolveJobLogFile,
3132
setConfig,
3233
upsertJob,
3334
writeJobFile
@@ -837,6 +838,62 @@ async function handleTaskWorker(argv) {
837838
);
838839
}
839840

841+
async function handleAttach(argv) {
842+
const { options, positionals } = parseCommandInput(argv, {
843+
valueOptions: ["cwd", "poll-interval-ms"],
844+
booleanOptions: []
845+
});
846+
847+
const cwd = resolveCommandCwd(options);
848+
const workspaceRoot = resolveCommandWorkspace(options);
849+
const pollIntervalMs = Math.max(200, Number(options["poll-interval-ms"]) || 500);
850+
const reference = positionals[0] ?? "";
851+
852+
let job;
853+
if (reference) {
854+
const snapshot = buildSingleJobSnapshot(cwd, reference);
855+
job = snapshot.job;
856+
} else {
857+
const jobs = sortJobsNewestFirst(listJobs(workspaceRoot));
858+
job = jobs.find((j) => isActiveJobStatus(j.status)) ?? jobs[0] ?? null;
859+
}
860+
861+
if (!job) {
862+
process.stdout.write("No Codex job found. Start one with /codex:rescue.\n");
863+
return;
864+
}
865+
866+
const logFile = job.logFile ?? resolveJobLogFile(workspaceRoot, job.id);
867+
process.stdout.write(`[attach] Job ${job.id} · status: ${job.status}\n`);
868+
if (job.title) process.stdout.write(`[attach] Task: ${job.title}\n`);
869+
process.stdout.write(`[attach] Log: ${logFile}\n---\n`);
870+
871+
let offset = 0;
872+
873+
function flushNewLogContent() {
874+
if (!fs.existsSync(logFile)) return;
875+
const content = fs.readFileSync(logFile, "utf8");
876+
if (content.length > offset) {
877+
process.stdout.write(content.slice(offset));
878+
offset = content.length;
879+
}
880+
}
881+
882+
flushNewLogContent();
883+
884+
while (true) {
885+
await sleep(pollIntervalMs);
886+
flushNewLogContent();
887+
888+
const currentJob = listJobs(workspaceRoot).find((j) => j.id === job.id);
889+
if (!currentJob || !isActiveJobStatus(currentJob.status)) {
890+
flushNewLogContent();
891+
process.stdout.write(`\n--- Job ${job.id} ${currentJob?.status ?? "gone"} ---\n`);
892+
break;
893+
}
894+
}
895+
}
896+
840897
async function handleStatus(argv) {
841898
const { options, positionals } = parseCommandInput(argv, {
842899
valueOptions: ["cwd", "timeout-ms", "poll-interval-ms"],
@@ -1015,6 +1072,9 @@ async function main() {
10151072
case "cancel":
10161073
await handleCancel(argv);
10171074
break;
1075+
case "attach":
1076+
await handleAttach(argv);
1077+
break;
10181078
default:
10191079
throw new Error(`Unknown subcommand: ${subcommand}`);
10201080
}

0 commit comments

Comments
 (0)