Codex Plugin Progress Reporting and Notification Handling: Implementation Details
The Codex plugin implements progress reporting and notification handling through two tightly-coupled mechanisms: a lightweight createProgressReporter function that writes status updates to log files and callbacks, and a JSON-RPC notification handler in captureTurn that translates real-time server events into human-readable progress phases.
This article breaks down how the openai/codex-plugin-cc repository tracks long-running Codex jobs—such as reviews and rescues—and surfaces their progress to users. We'll examine the source code in plugins/codex/scripts/lib/tracked-jobs.mjs and plugins/codex/scripts/lib/codex.mjs to understand the end-to-end flow from job creation to final status reporting.
Progress Reporting Architecture
The progress reporting system centers on the createProgressReporter factory function in plugins/codex/scripts/lib/tracked-jobs.mjs. This lightweight abstraction supports multiple output destinations simultaneously, giving the plugin flexibility for CLI, file-based, and programmatic consumers.
The createProgressReporter Function
export function createProgressReporter({ stderr = false, logFile = null, onEvent = null } = {}) {
if (!stderr && !logFile && !onEvent) {
return null; // No destination → noop
}
return (eventOrMessage) => {
const event = normalizeProgressEvent(eventOrMessage);
const stderrMessage = event.stderrMessage ?? event.message;
// 1️⃣ Write to stderr if requested
if (stderr && stderrMessage) {
process.stderr.write(`[codex] ${stderrMessage}\n`);
}
// 2️⃣ Append a plain line to the job‑specific log file
appendLogLine(logFile, event.message);
// 3️⃣ Append an optional multi‑line block (title + body) to the log
appendLogBlock(logFile, event.logTitle, event.logBody);
// 4️⃣ Invoke any user‑supplied callback (e.g. UI hook)
onEvent?.(event);
};
}
Key design decisions:
- Multi-destination output – The reporter can write to
stderr, append to a log file, and fire a custom callback all at once. This enables permanent audit trails alongside immediate user feedback. - Event normalization –
normalizeProgressEvent(lines 12-23) converts raw strings or objects into a canonical shape with fields likemessage,phase,logTitle, andlogBody. - No-op optimization – Returns
nullwhen no destinations are configured, avoiding overhead in headless scenarios.
Integration with Job Execution
Most job-executing helpers accept an onProgress option. In runTrackedJob (lines 42-56), the plugin builds a progress object and passes it to Codex turn execution:
const { logFile, progress } = createTrackedProgress(job, { /* options */ });
await runAppServerTurn(cwd, { onProgress: progress, /* … */ });
Notification Handling in captureTurn
Real-time progress updates come from the Codex app-server via JSON-RPC notifications. The captureTurn function in plugins/codex/scripts/lib/codex.mjs (lines 59-88) orchestrates this flow.
Temporary Handler Installation
async function captureTurn(client, threadId, startRequest, options = {}) {
const state = createTurnCaptureState(threadId, options);
const previousHandler = client.notificationHandler;
// Install a temporary handler that routes notifications to `applyTurnNotification`
client.setNotificationHandler((message) => {
// Buffer early messages until we have a turnId
if (!state.turnId) {
state.bufferedNotifications.push(message);
return;
}
// Certain meta‑notifications (thread start/name) are always relevant
if (message.method === "thread/started" ||
message.method === "thread/name/updated") {
applyTurnNotification(state, message);
return;
}
// Discard notifications that belong to other threads/subagents; forward them
// to the previous plugin handler (e.g. the main Claude session) if present.
if (!belongsToTurn(state, message)) {
previousHandler?.(message);
return;
}
// All other notifications are processed for the active turn
applyTurnNotification(state, message);
});
try {
const response = await startRequest();
// … replay buffered notifications, await completion …
return await state.completion;
} finally {
client.setNotificationHandler(previousHandler ?? null);
}
}
Critical behaviors:
- Message buffering – Early notifications arriving before the
turnIdis known are stored instate.bufferedNotificationsand replayed once identification is established. - Handler chaining – Non-turn messages are forwarded to
previousHandler, preserving the outer plugin's notification processing (e.g., the main Claude session). - Cleanup guarantee – The
finallyblock restores the original handler, preventing leaks across concurrent operations.
Notification Processing with applyTurnNotification
The applyTurnNotification function (lines 90-115) maps raw Codex notifications to structured progress events:
function applyTurnNotification(state, message) {
switch (message.method) {
case "turn/started":
state.threadTurnIds.set(message.params.threadId, message.params.turn.id);
emitProgress(state.onProgress,
`Turn started (${message.params.turn.id}).`,
"starting",
(message.params.threadId ?? null) === state.threadId
? { threadId: message.params.threadId, turnId: message.params.turn.id }
: {}
);
break;
case "item/started":
recordItem(state, message.params.item, "started", message.params.threadId);
const update = describeStartedItem(state, message.params.item);
emitProgress(state.onProgress, update?.message, update?.phase ?? null);
break;
case "item/completed":
recordItem(state, message.params.item, "completed", message.params.threadId);
const upd = describeCompletedItem(state, message.params.item);
emitProgress(state.onProgress, upd?.message, upd?.phase ?? null);
break;
case "turn/completed":
emitProgress(state.onProgress,
`Turn ${message.params.turn.status === "completed" ? "completed" : message.params.turn.status}.`,
"finalizing"
);
completeTurn(state, message.params.turn);
break;
default:
break;
}
}
Phase inference pipeline:
describeStartedItem/describeCompletedItem(lines 126-170) translate low-level Codex events into high-level phases:starting,reviewing,investigating,editing,finalizing.emitProgressforwards these phases to the reporter installed viastate.onProgress.recordItemaccumulates item state for log aggregation viaemitLogEvent.
End-to-End Job Flow
Understanding how progress reporting and notification handling connect requires tracing the full job lifecycle:
- Job creation –
createJobRecordinitializes a new job file;createJobLogFilecreates the backing log. - Reporter wiring –
createTrackedProgresscombines the job record withcreateProgressReporterto produce aprogressfunction. - Execution –
runTrackedJobwrites a "running" status, then invokes the runner (e.g.,runAppServerReview) withprogressin its options. - Turn capture – The runner calls
captureTurn, which installs the notification handler and awaits server responses. - Progress streaming – All notifications route through
applyTurnNotification→emitProgress→ the reporter's destinations. - Status queries –
job-control.mjsreads log files viareadJobProgressPreviewand infers phases viainferLegacyJobPhasefor commands like/codex:status.
Practical Code Examples
Creating a Progress Reporter for a New Job
import { createJobLogFile, createTrackedProgress } from "./tracked-jobs.mjs";
const logFile = createJobLogFile(workspaceRoot, job.id, "Codex review");
const { progress } = createTrackedProgress(job, { logFile });
// Pass `progress` to the turn runner
await runAppServerReview(cwd, { onProgress: progress, /* …other options… */ });
The reporter writes to logFile, streams to stderr, and forwards events to any UI hook supplied via onEvent.
Handling Progress Events in a Custom UI
function uiProgressHandler(event) {
// `event` has shape { message, phase?, logTitle?, logBody?, stderrMessage? }
console.log(`[${event.phase || "info"}] ${event.message}`);
if (event.logBody) {
console.log(`--- ${event.logTitle} ---\n${event.logBody}`);
}
}
// Wire the handler when launching a task
const { progress } = createTrackedProgress(job, {
logFile,
onEvent: uiProgressHandler
});
Querying Job Status for CLI Commands
import { buildStatusSnapshot } from "./job-control.mjs";
const snapshot = buildStatusSnapshot(process.cwd());
// `snapshot.running`, `snapshot.recent`, `snapshot.latestFinished` now contain
// enriched jobs with `phase` and a short `progressPreview` (last 4 log lines).
Core Implementation Files
| File | Responsibility |
|---|---|
plugins/codex/scripts/lib/tracked-jobs.mjs |
Job records, log files, and createProgressReporter |
plugins/codex/scripts/lib/codex.mjs |
JSON-RPC client, captureTurn, notification-to-progress mapping |
plugins/codex/scripts/lib/job-control.mjs |
Log parsing, phase inference, status snapshots |
plugins/codex/scripts/lib/render.mjs |
Status table formatting using progressPreview |
plugins/codex/scripts/lib/app-server.mjs |
Low-level JSON-RPC notification streaming |
Summary
- Progress reporting in the Codex plugin is implemented by
createProgressReporterintracked-jobs.mjs, supporting simultaneous log file, stderr, and callback output through event normalization. - Notification handling relies on
captureTurnincodex.mjsto install temporary JSON-RPC handlers, buffer early messages, and route turn-specific notifications throughapplyTurnNotification. - Phase inference translates raw Codex events (
turn/started,item/completed, etc.) into user-friendly progress phases via helper functions likedescribeStartedItem. - Persistence and querying are handled by
job-control.mjs, which reads log files and builds status snapshots for CLI commands.
Frequently Asked Questions
How does the Codex plugin handle notifications that arrive before a turn ID is assigned?
The captureTurn function buffers early notifications in state.bufferedNotifications until the turn/started message establishes the turn ID. Once known, buffered messages are replayed through applyTurnNotification before processing new arrivals.
Can progress events be consumed by multiple destinations simultaneously?
Yes. The createProgressReporter function supports concurrent output to a log file, stderr, and a custom onEvent callback. This design allows audit trails, user feedback, and programmatic hooks to operate without interference.
What determines the progress phase values like "reviewing" or "investigating"?
Helper functions describeStartedItem and describeCompletedItem (lines 126-170 in codex.mjs) inspect the item type and content to infer semantic phases. These mappings transform low-level Codex operations into human-readable progress states.
How does the plugin isolate notifications when multiple turns run concurrently?
Each captureTurn call saves the previous client.notificationHandler and installs a temporary handler scoped to that turn's threadId. Non-matching notifications are forwarded to the previous handler, ensuring proper isolation and cleanup via the finally block.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →