How the Codex Plugin Captures and Returns File Modifications and Command Execution Results

The Codex plugin uses a notification-driven capture loop that listens to Codex "item/completed" notifications, filters them by turn, and accumulates file changes and command execution results into a structured state object returned to the caller.

This article explains the complete mechanism in the openai/codex-plugin-cc repository, from state initialization to the final returned payload. Understanding this flow helps developers build reliable integrations that track exactly what changes Codex makes during an automated coding session.


Turn-Capture State Initialization

Every Codex turn begins by creating a dedicated state container. The function createTurnCaptureState in plugins/codex/scripts/lib/codex.mjs initializes an object with two arrays that will hold captured events:

// Simplified structure from codex.mjs lines 31-38
{
  fileChanges: [],        // Accumulates fileChange notifications
  commandExecutions: [],  // Accumulates commandExecution notifications
  completion: Promise     // Resolves when the turn ends
}

This state persists for the entire duration of one turn or review operation. All subsequent notifications are filtered and stored here.


Installing the Notification Handler

Before sending a request to Codex, the captureTurn function installs a temporary notification handler using client.setNotificationHandler. This handler intercepts every notification sent by the Codex app-server.

The handler assignment appears at plugins/codex/scripts/lib/codex.mjs lines 61-68:

client.setNotificationHandler((notification) => {
  // Only process notifications belonging to this turn
  if (!belongsToTurn(notification, turnId)) return;
  
  recordItem(notification, state);
});

The handler remains active until completeTurn is called, ensuring no events are missed during the turn's execution.


Filtering Notifications by Turn

Not all notifications belong to the current turn. The belongsToTurn function (lines 96-104) performs a strict membership check by comparing the notification's turn identifier against the active turnId:

function belongsToTurn(notification, turnId) {
  return notification.params?.turnId === turnId;
}

This prevents cross-turn contamination when multiple turns execute concurrently or in rapid succession.


Recording File Changes and Command Executions

The recordItem function (lines 80-88) processes qualifying notifications and persists them to the state object. It discriminates by notification type and lifecycle status:

Notification Type Lifecycle Requirement Storage Target
fileChange "completed" state.fileChanges
commandExecution "completed" state.commandExecutions

Only completed lifecycle events are captured. Intermediate states ("started", "in-progress") are ignored to ensure the final result reflects the definitive outcome.

function recordItem(notification, state) {
  const { type, lifecycle, item } = notification.params;
  
  if (lifecycle !== "completed") return;
  
  if (type === "fileChange") {
    state.fileChanges.push(item);
  } else if (type === "commandExecution") {
    state.commandExecutions.push(item);
  }
}

Each fileChange item includes the patch list showing actual modifications. Each commandExecution item contains the command string, exit code, and captured stdout/stderr streams.


Completing the Capture Loop

When Codex signals turn completion, completeTurn (lines 46-55) resolves the state.completion promise. This triggers the return of the fully populated TurnCaptureState containing all accumulated events.

The capture loop is synchronous with the turn lifecycle: it starts before the request, runs continuously during Codex execution, and terminates cleanly on completion.


High-Level API: Returning Results to Callers

Two primary functions expose capture results to consuming code: runAppServerTurn for standard turns and runAppServerReview for review operations. Both invoke captureTurn internally and return a structured payload.

From plugins/codex/scripts/lib/codex.mjs lines 55-58, the return object includes:

{
  fileChanges: state.fileChanges,           // Raw fileChange notifications
  touchedFiles: collectTouchedFiles(state), // Derived file paths list
  commandExecutions: state.commandExecutions // Raw commandExecution notifications
}

The touchedFiles array is computed via collectTouchedFiles, which extracts unique file paths from the change notifications for convenient access.


Practical Usage Example

import { runAppServerTurn } from 
  "./plugins/codex/scripts/lib/codex.mjs";

async function automateRefactor() {
  const result = await runAppServerTurn(
    process.cwd(),
    {
      prompt: "Refactor utils.js to use async/await and run npm test",
      model: "gpt-4o",
      onProgress: (msg) => console.log("Progress:", msg)
    }
  );

  // Inspect captured modifications
  console.log("Files modified:", result.touchedFiles);
  console.log("Detailed changes:", result.fileChanges);
  console.log("Test command results:", result.commandExecutions);
}

automateRefactor();

The result object provides complete observability: every file patch, every executed command with its exit status, and a clean list of affected paths.


Summary

  • State creation: createTurnCaptureState initializes per-turn storage for fileChanges and commandExecutions in plugins/codex/scripts/lib/codex.mjs
  • Notification handling: client.setNotificationHandler installs a temporary handler that filters by turn ID via belongsToTurn
  • Selective recording: recordItem captures only completed lifecycle notifications of type fileChange or commandExecution
  • Turn finalization: completeTurn resolves the completion promise, returning the populated state
  • Public API: runAppServerTurn and runAppServerReview return structured results including processed touchedFiles and raw notification arrays

Frequently Asked Questions

How does the plugin avoid capturing notifications from other turns?

The belongsToTurn function compares notification.params.turnId against the active turn identifier. Notifications with mismatched IDs are silently ignored, ensuring isolation between concurrent or sequential turn operations.

Why does the plugin only capture "completed" lifecycle notifications?

Intermediate states like "started" or "in-progress" represent partial or speculative data. By filtering for "completed" lifecycle status, the plugin guarantees that captured items reflect final, committed results suitable for downstream processing.

What information is included in a captured fileChange item?

Each fileChange item contains the complete patch list showing line-by-line modifications, file paths, and metadata from the Codex edit operation. The touchedFiles helper extracts just the unique file paths for convenience.

Can I monitor progress while a turn is still executing?

Yes. Both runAppServerTurn and runAppServerReview accept an onProgress callback that receives real-time status updates. This operates independently of the capture loop, allowing UI updates while the final results accumulate in the state object.

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 →