How Job Persistence and State Management Work in the OpenAI Codex Plugin

The Codex plugin stores job history and global configuration in a JSON state file and individual job logs within a workspace-specific directory derived from the workspace root hash, enabling durable tracking across plugin restarts without external services.

The openai/codex-plugin-cc repository implements a file-based persistence layer that survives IDE reloads and process restarts. Understanding how job persistence and state management function is essential for developers integrating with or extending the Codex companion plugin.

State Storage Location and Directory Structure

The plugin maintains a persistent, per-workspace state outside the repository tree. The root location is determined by the CLAUDE_PLUGIN_DATA environment variable or falls back to the system temporary directory.

Path resolution logic in plugins/codex/scripts/lib/state.mjs derives a unique directory for each workspace:

  • State root: ${CLAUDE_PLUGIN_DATA}/state or os.tmpdir()/codex-companion
  • Workspace directory: <state-root>/<slug>-<hash> where the slug is the workspace folder name and the hash is a 16-character SHA-256 digest of the canonical workspace root path
  • Jobs directory: <workspace-dir>/jobs
  • State file: <workspace-dir>/state.json

The resolveStateDir function (lines 30-41) guarantees unique storage even when two workspaces share identical names by hashing the resolved absolute path:

// 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 State File Format

The state.json file acts as the central registry. It contains three top-level fields managed by loadState and saveState (lines 58-77 and 92-115):

{
  "version": 1,
  "config": { "stopReviewGate": false },
  "jobs": [ … ]
}
  • version: Schema version integer for future migrations
  • config: Global flags such as the stopReviewGate toggle
  • jobs: Array of recent job records capped at 50 entries (MAX_JOBS)

When state.json is missing or corrupted, loadState returns a fresh default state, ensuring the plugin never fails to start due to disk issues.

Individual Job Records and Log Files

Each job receives a unique ID generated by generateJobId (lines 24-27 in state.mjs), which produces strings like job-lx3k2m-9a8b7c:

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

Job data is split across two files inside the jobs directory:

  1. <jobId>.json: Contains metadata including createdAt, updatedAt, status (queued, running, completed, failed, or cancelled), sessionId, and custom payloads
  2. <jobId>.log: Append-only text log written by tracked-jobs.mjs functions appendLogLine and appendLogBlock

Creating and Updating Jobs

The plugin uses an upsert pattern to maintain the job list. The upsertJob function (lines 29-46) merges patches into existing records or prepends new entries while updating timestamps:

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

When saveState persists the state, it performs garbage collection: it writes the trimmed job list (respecting MAX_JOBS) to state.json and deletes individual .json and .log files for jobs that have fallen off the list.

Session Isolation

Jobs can be scoped to a specific Codex session via the CODEX_COMPANION_SESSION_ID environment variable. The filterJobsForCurrentSession helper in plugins/codex/scripts/lib/job-control.mjs filters the jobs array so users only see jobs belonging to their current session unless no session ID is configured. This prevents cross-contamination when running multiple isolated plugin instances.

Querying and Enriching Job Data for the UI

The job-control.mjs module transforms raw persisted data into user-friendly snapshots. The buildStatusSnapshot function loads state via listJobs, sorts them newest-first, and buckets them into running, latestFinished, and recent categories.

The enrichJob function (shown below) adds derived fields for display:

  • phase: Inferred from log content via inferLegacyJobPhase
  • progressPreview: Last N lines of the log file via readJobProgressPreview
  • elapsed and duration: Human-readable time strings formatted by formatElapsedDuration
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) };
}

Running a Tracked Job

The runTrackedJob function in tracked-jobs.mjs orchestrates execution: it writes an initial running status, executes the provided runner function, and updates the record with final status, timestamps, and rendered output (lines 42-81).

Example workflow:

import { resolveWorkspaceRoot } from "./workspace.mjs";
import { generateJobId, createJobLogFile, runTrackedJob } from "./tracked-jobs.mjs";
import { writeJobFile } from "./state.mjs";

const cwd = resolveWorkspaceRoot(process.cwd());
const jobId = generateJobId("analysis");
const logFile = createJobLogFile(cwd, jobId, "Code Analysis");

const jobRecord = {
  id: jobId,
  workspaceRoot: cwd,
  kind: "analysis",
  logFile,
  createdAt: new Date().toISOString(),
};
writeJobFile(cwd, jobId, jobRecord);

await runTrackedJob(jobRecord, async () => {
  // Perform work
  return {
    exitStatus: 0,
    payload: { filesChanged: 3 },
    rendered: "Analysis complete: 3 files updated",
    threadId: "thread-123",
    turnId: "turn-456",
    summary: "Refactoring suggestions applied"
  };
});

Summary

  • Storage: State lives in CLAUDE_PLUGIN_DATA/state or os.tmpdir()/codex-companion, with workspace-specific subdirectories named via SHA-256 hash of the root path
  • Core files: state.json holds global config and a capped job list (50 max); individual <jobId>.json and <jobId>.log files store details and progress
  • Key modules: state.mjs handles persistence; tracked-jobs.mjs manages execution and logging; job-control.mjs enriches data for UI consumption
  • Isolation: CODEX_COMPANION_SESSION_ID filters jobs by session to prevent cross-talk
  • Durability: All state is file-based with no external dependencies, ensuring survival across restarts and reloads

Frequently Asked Questions

Where does the Codex plugin store its job history?

The plugin stores job history in a workspace-specific directory derived from a SHA-256 hash of the canonical workspace root path. By default, this resides in os.tmpdir()/codex-companion, but you can override the root via the CLAUDE_PLUGIN_DATA environment variable. Inside this directory, state.json tracks the latest 50 jobs, while individual job metadata and logs live in the jobs/ subfolder.

How does the plugin handle concurrent job updates?

The plugin uses atomic file operations and an upsert pattern via upsertJob in state.mjs. When updating, it reads the current state, applies the patch (inserting new jobs at the front or updating existing entries), and writes the entire state back via saveState. This design assumes single-process access per workspace; session isolation via CODEX_COMPANION_SESSION_ID further segments job visibility when multiple sessions run concurrently.

What happens to old job files when the history limit is reached?

When saveState persists the job list, it enforces the MAX_JOBS limit (50 entries). Any jobs that fall off the end of the array are permanently removed from state.json, and the corresponding .json and .log files in the jobs/ directory are deleted from disk, preventing unbounded disk usage.

Can I query job status programmatically from external tools?

Yes. Import buildStatusSnapshot or buildSingleJobSnapshot from plugins/codex/scripts/lib/job-control.mjs. These functions return enriched job objects including status, elapsed time, and log previews without requiring the UI. You can also read log files directly from the jobs directory using readJobProgressPreview to tail progress in real-time.

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 →