How the Codex Plugin Implements Turn Capture and Notification
The Codex plugin wraps every backend interaction in a turn, generates unique IDs via nextTurnId in state.mjs, emits turn/started and turn/completed JSON-RPC events, and routes notifications through app-server-broker.mjs to clients via WebSocket.
The openai/codex-plugin-cc repository provides a reference implementation for building conversational AI plugins that require strict observability. Understanding the turn capture and notification system reveals how the plugin maintains state across asynchronous operations and notifies external consumers like VS Code panels or test harnesses in real time.
Understanding the Turn Capture Architecture
The plugin treats every discrete unit of work—a command sent to the Codex backend, a sub-agent invocation, or a tool execution—as a turn. Each turn receives a unique identifier and transitions through a well-defined lifecycle that mirrors JSON-RPC request-response patterns.
Turn State Management in state.mjs
Central to the system is plugins/codex/scripts/lib/state.mjs, which maintains a monotonic counter (nextTurnId) to generate identifiers such as turn_1, turn_2, and so forth. When a command handler initiates work, it calls nextTurnId(state) to obtain a fresh ID before emitting the initial event.
The module also exports buildTurn(turnId, status, description), a factory function that constructs the turn payload (lines 165–174). This payload includes the turn ID, the current status (started or completed), and minimal metadata describing the operation. According to the source, emitTurnStarted and emitTurnCompleted wrap this builder and immediately invoke the send helper to dispatch JSON-RPC messages to the broker.
// plugins/codex/scripts/lib/state.mjs (lines 165-174)
export function emitTurnStarted(state, threadId, description) {
const turnId = nextTurnId(state);
const turn = buildTurn(turnId, "started", description);
send({ method: "turn/started", params: { threadId, turn } });
return turnId;
}
export function emitTurnCompleted(state, threadId, turnId) {
const turn = buildTurn(turnId, "completed");
send({ method: "turn/completed", params: { threadId, turn } });
}
Turn vs. Item Events
While turns represent top-level conversational units, items represent granular steps within a turn—such as individual tool calls or streaming deltas. The plugin emits parallel item/started and item/completed events that reference the parent turn ID. This hierarchical event structure allows consumers to render nested progress indicators without parsing the turn payload itself.
The Notification Broker System
Once state.mjs generates a turn event, the plugin delegates routing to plugins/codex/scripts/app-server-broker.mjs. This broker acts as a message router between the plugin’s internal state machine and external clients connected via WebSocket.
Message Routing via app-server-broker.mjs
The broker’s handleMessage function (lines 70–90) inspects incoming JSON-RPC method names. When it encounters turn/started or turn/completed, it forwards the payload to all registered listeners. The implementation maintains a Set of client connections and iterates through them, serializing the turn object to JSON before transmission.
// plugins/codex/scripts/app-server-broker.mjs (lines 70-90)
function handleMessage(message, clients) {
if (message.method?.startsWith('turn/')) {
clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(message));
}
});
}
}
WebSocket Delivery in app-server.mjs
Underpinning the broker is plugins/codex/scripts/lib/app-server.mjs, which manages the WebSocket server lifecycle and connection state. This module handles connection upgrades, heartbeat pings, and reconnection logic, ensuring that turn/completed notifications survive transient network interruptions. The separation of concerns—state generation in state.mjs, routing in app-server-broker.mjs, and transport in app-server.mjs—allows developers to swap transport layers without modifying turn logic.
Implementation Walkthrough: Capturing a Turn
Consider a command handler defined in plugins/codex/commands/review.md. To capture the entire review operation as a turn, the implementation follows this sequence:
- Initialize the turn by calling
emitTurnStarted. - Execute the Codex API call or sub-agent tasks.
- Emit item events for intermediate steps.
- Finalize with
emitTurnCompleted.
// plugins/codex/commands/review.md (conceptual handler)
import { emitTurnStarted, emitTurnCompleted } from '../scripts/lib/state.mjs';
export async function handleReview(state, threadId, prompt) {
const turnId = emitTurnStarted(state, threadId, { command: 'review', prompt });
try {
const result = await callCodexApi(prompt);
// Intermediate item events omitted for brevity
emitTurnCompleted(state, threadId, turnId);
return result;
} catch (error) {
// Error handling ensures turn/completed still fires
emitTurnCompleted(state, threadId, turnId);
throw error;
}
}
This pattern ensures that even if the underlying API call throws an exception, the notification system receives a definitive turn/completed event, preventing UI clients from displaying indefinite loading states.
Summary
- Turn Identity: The
nextTurnIdcounter inplugins/codex/scripts/lib/state.mjsguarantees monotonic, unique identifiers for every discrete operation. - Event Semantics:
turn/startedsignals the beginning of work, whileturn/completedguarantees closure, both emitted via JSON-RPC through thesendhelper. - Broker Architecture:
app-server-broker.mjsdecouples state management from transport, forwarding turn events to WebSocket clients viahandleMessage. - Hierarchical Observability: Turns encapsulate lower-level
item/*events, enabling rich, nested progress visualization without tight coupling. - Resilient Delivery: The underlying
app-server.mjsWebSocket implementation ensures notifications survive network interruptions through connection management and heartbeat mechanisms.
Frequently Asked Questions
What constitutes a "turn" in the Codex plugin?
A turn represents a single logical interaction between the user and the Codex backend, such as executing a review command or running a setup script. Each turn receives a unique ID (e.g., turn_1) and emits lifecycle events that allow external observers to track when work begins and ends, regardless of how many intermediate API calls or sub-agents are involved.
How does the plugin generate unique turn identifiers?
The plugin maintains a monotonic counter in the global state object managed by plugins/codex/scripts/lib/state.mjs. The nextTurnId(state) function increments this counter and returns a formatted string like turn_${counter}, ensuring that every turn ID is unique within the current session and sortable by creation time.
What is the difference between turn events and item events?
Turn events (turn/started, turn/completed) bookend entire operations, while item events (item/started, item/completed) track granular steps within a turn, such as individual tool invocations or streaming tokens. The item events include a reference to the parent turn ID, enabling consumers to render nested progress trees without parsing the full turn payload.
How does the UI receive turn notifications in real time?
The UI connects to the plugin via WebSocket through the endpoint managed by plugins/codex/scripts/lib/app-server.mjs. When app-server-broker.mjs receives a turn event from state.mjs, it broadcasts the JSON-RPC message to all connected WebSocket clients. The UI subscribes to these messages and updates its view upon receiving turn/completed, ensuring synchronous state between the plugin backend and the interface.
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 →