How the OpenAI Codex Plugin Handles Job Persistence and State Management

The OpenAI Codex plugin uses a self‑contained, file‑based persistence layer that stores workspace‑specific state in JSON files and timestamped logs, enabling jobs to survive across IDE reloads and process restarts without external services.

The openai/codex-plugin-cc repository implements a durable state management system that tracks every Codex job from creation through completion. By leveraging the local filesystem and environment‑based configuration, the plugin maintains a rolling history of up to 50 jobs per workspace while keeping execution logs isolated and queryable.

Where State Is Persisted

The plugin calculates a unique, persistent directory for each workspace using a slug‑hash derivation. In plugins/codex/scripts/lib/state.mjs, the resolveStateDir function (lines 30‑41) generates this path by hashing the canonical workspace root, ensuring unique storage even when repositories share identical names.

// plugins/codex/scripts/lib/state.mjs
export function resolveStateDir(cwd) {
  const workspaceRoot = resolveWorkspaceRoot(cwd);
  const canonicalWorkspaceRoot = fs.realpathSync.native(workspaceRoot) ?? workspaceRoot;
  const slug = path.basename(workspaceRoot) || "workspace";
  const hash = createHash("sha256").update(canonicalWorkspaceRoot).digest("hex").slice(0, 16);
  const stateRoot = process.env[PLUGIN_DATA_ENV] 
    ? path.join(process.env[PLUGIN_DATA_ENV], "state") 
    : FALLBACK_STATE_ROOT_DIR;
  return path.join(stateRoot, `${slug}-${hash}`);
}

The storage hierarchy follows this structure:

  • State root: Determined by the CLAUDE_PLUGIN_DATA environment variable (appending /state) or falling back to os.tmpdir()/codex-companion
  • Workspace directory: <state-root>/<slug>-<hash>/
  • State file: <workspace-directory>/state.json
  • Jobs directory: <workspace-directory>/jobs/ containing individual <jobId>.json records and <jobId>.log files

The State File Structure

The main state.json file acts as the source of truth for global configuration and the job index. Managed by loadState and saveState (lines 58‑77 and 92‑115), the file contains three top‑level fields:

{
  "version": 1,
  "config": { "stopReviewGate": false },
  "jobs": [ … ]
}
  • version: Supports future schema migrations
  • config: Stores global flags such as the stopReviewGate toggle
  • jobs: An array of recent job metadata (capped at 50 entries via MAX_JOBS)

If state.json is missing or corrupted, the system returns a fresh default state, ensuring the plugin remains resilient to filesystem errors.

Individual Job Records

Each job receives a unique ID generated by generateJobId (lines 24‑27 in state.mjs), which produces strings like job‑<timestamp>‑<random>:

export function generateJobId(prefix = "job") {
  const random = Math.random().toString(36).slice(2, 8);
  return `${prefix}-${Date.now().toString(36)}-${random}`;
}

The plugin stores two artifacts per job in the jobs subdirectory:

  1. <jobId>.json: Contains the job record with createdAt, updatedAt, status (queued|running|completed|failed|cancelled), optional sessionId, and custom payload data
  2. <jobId>.log: A plain text log of execution progress with timestamped entries

Updating and Pruning State

When a job’s status changes, upsertJob (lines 29‑46) performs an atomic update to the in‑memory state and persists it:

export function upsertJob(cwd, jobPatch) {
  return updateState(cwd, (state) => {
    const timestamp = nowIso();
    const idx = state.jobs.findIndex((j) => j.id === jobPatch.id);
    if (idx === -1) {
      state.jobs.unshift({ createdAt: timestamp, updatedAt: timestamp, ...jobPatch });
    } else {
      state.jobs[idx] = { ...state.jobs[idx], ...jobPatch, updatedAt: timestamp };
    }
  });
}

The saveState function (lines 92‑115) handles persistence and cleanup:

  • Writes the truncated job list (respecting MAX_JOBS) back to state.json
  • Deletes orphaned <jobId>.json and <jobId>.log files no longer referenced in the state array
  • Ensures the jobs directory remains synchronized with the metadata index

Tracking Job Execution

Actual job execution flows through plugins/codex/scripts/lib/tracked-jobs.mjs. The runTrackedJob function (lines 42‑81) manages the lifecycle:

  1. Writes an initial running status to the log and state
  2. Executes the user‑provided runner function
  3. Captures the result, including exitStatus, rendered output, and optional threadId/turnId
  4. Updates the final record with timestamps and status

Log entries are appended via appendLogLine using synchronous filesystem calls for durability:

export function appendLogLine(logFile, message) {
  const normalized = String(message ?? "").trim();
  if (!logFile || !normalized) return;
  fs.appendFileSync(logFile, `[${nowIso()}] ${normalized}\n`, "utf8");
}

Building Status Snapshots

The UI layer consumes persisted data through plugins/codex/scripts/lib/job-control.mjs. The buildStatusSnapshot function transforms raw records into enriched views using enrichJob:

  • Phase inference: inferLegacyJobPhase interprets log previews to determine human‑readable status labels
  • Progress preview: readJobProgressPreview extracts the last N lines from log files for quick inspection
  • Duration calculation: Formats elapsed time between startedAt and completedAt (or current time for active jobs)

Session isolation is handled via the CODEX_COMPANION_SESSION_ID environment variable. When set, filterJobsForCurrentSession restricts queries to jobs matching that sessionId, preventing cross‑contamination between concurrent Codex instances.

Summary

  • File‑based durability: All state resides in state.json and per‑job files under a workspace‑specific slug‑hash directory derived from CLAUDE_PLUGIN_DATA or os.tmpdir()
  • Automatic pruning: The system maintains a rolling window of 50 jobs, deleting old files when saving state
  • Self‑contained execution: runTrackedJob in tracked-jobs.mjs coordinates logging, status updates, and result persistence without external services
  • Session awareness: Jobs can be scoped to specific Codex sessions via CODEX_COMPANION_SESSION_ID, with UI filtering handled in job-control.mjs

Frequently Asked Questions

Where does the Codex plugin store its state files?

The plugin stores state in a directory derived from the CLAUDE_PLUGIN_DATA environment variable (defaulting to os.tmpdir()/codex-companion). Inside this root, it creates workspace‑specific folders using a SHA‑256 hash of the canonical workspace path, ensuring unique storage per repository even when names collide.

How does the plugin generate unique job IDs?

Job IDs are created by generateJobId in state.mjs using a template of prefix‑<base36‑timestamp>‑<random>, producing identifiers like job‑m3k9z2‑a7b3c9. This guarantees uniqueness across milliseconds while remaining human‑readable.

What happens when the job history exceeds 50 entries?

When saveState persists the job list, it enforces the MAX_JOBS limit (50) by truncating the array. The function then scans the jobs directory and deletes any <jobId>.json or <jobId>.log files whose IDs are no longer present in the state, preventing disk bloat from orphaned records.

How does session isolation work for concurrent jobs?

The plugin checks the CODEX_COMPANION_SESSION_ID environment variable when building status snapshots. If set, filterJobsForCurrentSession returns only jobs bearing that sessionId, allowing multiple Codex instances to operate independently without exposing each other’s job history.

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 →