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 like message, phase, logTitle, and logBody.
  • No-op optimization – Returns null when 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 turnId is known are stored in state.bufferedNotifications and 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 finally block 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.
  • emitProgress forwards these phases to the reporter installed via state.onProgress.
  • recordItem accumulates item state for log aggregation via emitLogEvent.

End-to-End Job Flow

Understanding how progress reporting and notification handling connect requires tracing the full job lifecycle:

  1. Job creation – createJobRecord initializes a new job file; createJobLogFile creates the backing log.
  2. Reporter wiring – createTrackedProgress combines the job record with createProgressReporter to produce a progress function.
  3. Execution – runTrackedJob writes a "running" status, then invokes the runner (e.g., runAppServerReview) with progress in its options.
  4. Turn capture – The runner calls captureTurn, which installs the notification handler and awaits server responses.
  5. Progress streaming – All notifications route through applyTurnNotification → emitProgress → the reporter's destinations.
  6. Status queries – job-control.mjs reads log files via readJobProgressPreview and infers phases via inferLegacyJobPhase for 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 createProgressReporter in tracked-jobs.mjs, supporting simultaneous log file, stderr, and callback output through event normalization.
  • Notification handling relies on captureTurn in codex.mjs to install temporary JSON-RPC handlers, buffer early messages, and route turn-specific notifications through applyTurnNotification.
  • Phase inference translates raw Codex events (turn/started, item/completed, etc.) into user-friendly progress phases via helper functions like describeStartedItem.
  • 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →