How the Codex Plugin Handles Job State Persistence: File-Based Architecture Explained

The Codex plugin persists job state using a workspace-specific directory containing JSON records and append-only log files, optionally controlled by the CLAUDE_PLUGIN_DATA environment variable, ensuring durability across plugin restarts and process crashes.

The openai/codex-plugin-cc repository implements a robust job state persistence mechanism that requires no external databases or services. By leveraging the file system and deterministic directory hashing, the plugin maintains a complete history of recent jobs, their metadata, and execution logs outside the repository workspace. This article examines the source code in plugins/codex/scripts/lib/ to explain how the Codex plugin manages durable job tracking through pure JavaScript/TypeScript.

Where Job State Is Stored

State Root and Workspace-Specific Directories

The plugin determines the storage location through the resolveStateDir function in plugins/codex/scripts/lib/state.mjs. The system checks for the CLAUDE_PLUGIN_DATA environment variable to set a custom state root; if absent, it falls back to os.tmpdir()/codex-companion.

Each workspace receives a unique subdirectory computed from the canonical workspace root path. This prevents collisions when two directories share the same basename.

// state.mjs – resolveStateDir (lines 30-41)
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 State File Structure

Inside the workspace-specific directory, the plugin maintains state.json alongside a jobs subdirectory. The JSON file holds global configuration and an array of recent job records, while the jobs folder contains individual job files and logs.

The State File Format

Global Configuration and Job History

The state.json file structure contains three top-level fields:

{
  "version": 1,
  "config": { "stopReviewGate": false },
  "jobs": [ … ]
}
  • version – Integer for future migration support.
  • config – Global flags such as the stop-review-gate toggle.
  • jobs – Array of recent job records (capped at 50 entries via MAX_JOBS).

The loadState and saveState functions (lines 58-77 and 92-115 in state.mjs) handle atomic reads and writes. If the file is missing or corrupted, the plugin returns a fresh default state.

Versioning and Migration Support

The version field allows the plugin to evolve its persistence schema. When loading state, the implementation can detect legacy versions and migrate data accordingly before saving back to disk.

Individual Job Records and Logging

Job ID Generation

Each job receives a unique identifier generated by generateJobId (lines 24-27 in state.mjs):

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

This produces IDs like job-lx8k2m-9a3b4c, combining a prefix, base-36 timestamp, and random suffix.

Per-Job Files and Append-Only Logs

For every job, the plugin creates two files in the jobs directory:

  1. <jobId>.json – The serialized job record containing timestamps, status, and payload.
  2. <jobId>.log – An append-only log file for progress tracking.

The tracked-jobs.mjs module provides appendLogLine and appendLogBlock for writing timestamped entries:

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

Adding and Updating Jobs

The Upsert Pattern

The upsertJob function (lines 29-46 in state.mjs) implements an upsert pattern that merges patches into existing records or prepends new ones:

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 };
    }
  });
}

Pruning Stale State

When saveState executes, it writes the trimmed job list (respecting MAX_JOBS) and deletes orphaned job files from the jobs directory that no longer appear in the persisted list. This prevents unbounded disk growth while maintaining the recent history.

Runtime Job Execution and Logging

The runTrackedJob function in tracked-jobs.mjs orchestrates the execution flow:

  1. Writes an initial running status to the job record.
  2. Executes the user-provided runner function.
  3. Captures the return value (exitStatus, payload, rendered, threadId, turnId, summary).
  4. Updates the record with final status and timestamps.
  5. Appends all progress to the associated log file.

This ensures that even if the process crashes mid-execution, the partial state and logs remain queryable upon restart.

Building Status Snapshots for the UI

The job-control.mjs module translates raw persistence data into user-friendly snapshots via buildStatusSnapshot. The process involves:

  1. Loading – listJobs retrieves the current state.
  2. Sorting – sortJobsNewestFirst orders jobs by creation time.
  3. Enriching – enrichJob adds computed fields:
export function enrichJob(job, options = {}) {
  const maxProgressLines = options.maxProgressLines ?? DEFAULT_MAX_PROGRESS_LINES;
  const enriched = {
    ...job,
    kindLabel: getJobTypeLabel(job),
    progressPreview: (job.status === "queued" || job.status === "running" || job.status === "failed")
                      ? readJobProgressPreview(job.logFile, maxProgressLines) : [],
    elapsed: formatElapsedDuration(job.startedAt ?? job.createdAt, job.completedAt ?? null),
    duration: (job.status === "completed" || job.status === "failed" || job.status === "cancelled")
               ? formatElapsedDuration(job.startedAt ?? job.createdAt, job.completedAt ?? job.updatedAt) : null,
  };
  return { ...enriched, phase: enriched.phase ?? inferLegacyJobPhase(enriched, enriched.progressPreview) };
}

The resulting snapshot categorizes jobs into running, latestFinished, and recent buckets, providing the /codex:status UI with immediate visibility into task progress.

Session Isolation

Jobs can be scoped to specific Codex sessions using the CODEX_COMPANION_SESSION_ID environment variable. When querying jobs, the filterJobsForCurrentSession function in job-control.mjs filters the persisted list by sessionId. If no session ID is set, the plugin displays all jobs for the workspace, enabling both isolated and shared visibility modes.

Summary

  • Persistent directories are derived from the workspace root via SHA256 hashing in resolveStateDir, preventing name collisions.
  • State files (state.json) store global configuration and up to 50 recent job records with versioning for migrations.
  • Individual jobs get unique IDs and dedicated JSON/log files, updated atomically through upsertJob and saveState.
  • Progress tracking uses append-only log files written via appendLogLine in tracked-jobs.mjs.
  • UI snapshots are built by enriching raw records with progress previews and formatted durations in job-control.mjs.
  • Session isolation is achieved through environment variable filtering, allowing multi-session workflows.

Frequently Asked Questions

Where does the Codex plugin store job state files?

The plugin stores files in a directory derived from process.env.CLAUDE_PLUGIN_DATA plus /state, or falls back to os.tmpdir()/codex-companion. Inside this root, it creates workspace-specific folders using a slug-hash format (e.g., myproject-a1b2c3d4e5f67890) generated from the canonical workspace root path.

How many jobs are kept in the persistence history?

The plugin maintains a maximum of 50 recent jobs per workspace, defined by the MAX_JOBS constant. When the limit is exceeded, saveState automatically trims the oldest entries from state.json and deletes their associated files from the jobs subdirectory.

What happens to job state when the plugin restarts?

Because state lives outside the repository in the temporary or configured data directory, it survives across plugin restarts, IDE reloads, and separate process invocations. On startup, loadState reads the existing state.json (or creates a default if missing), allowing the UI to immediately display historical jobs via buildStatusSnapshot.

How does the plugin handle concurrent sessions?

The plugin supports session isolation through the CODEX_COMPANION_SESSION_ID environment variable. When set, filterJobsForCurrentSession limits queries to jobs matching that specific sessionId. If the variable is unset, the plugin operates in shared mode, showing all jobs for the workspace regardless of which session created them.

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 →