Codex Plugin Job State File Structure: How On-Disk State Is Organized

Codex Companion plugin stores workspace state in a three-tier filesystem layout: a global state.json containing job metadata summaries, individual JSON files per job for full payloads, and optional .log files for execution output.

The openai/codex-plugin-cc repository persists runtime state to disk so that job history survives process restarts. Understanding this structure helps you debug, migrate, or integrate with the plugin's data layer. This article breaks down exactly how job state files are organized, where they live, and how to access them programmatically.

State Directory Layout

The plugin partitions on-disk state across three file types under a workspace-specific root:

File type Relative path Purpose
Global state state/<slug-hash>/state.json Workspace-level metadata, config, and job index
Job payload state/<slug-hash>/jobs/<job-id>.json Complete job record with arguments, results, timestamps
Job logs state/<slug-hash>/jobs/<job-id>.log Streaming stdout/stderr capture (created on demand)

This separation keeps state.json lightweight for frequent reads while isolating bulky job data and logs.

Resolving State Paths

Path computation lives in state.mjs. The core helper is resolveStateDir() which builds the root directory:

// From state.mjs lines 29-44
export function resolveStateDir(cwd) {
  const workspaceRoot = getWorkspaceRoot(cwd);
  const slug = slugifyPath(workspaceRoot);
  const hash = createHash("sha256")
    .update(workspaceRoot)
    .digest("hex")
    .slice(0, 16);
  const base = process.env.CLAUDE_PLUGIN_DATA || "/tmp/codex-companion";
  return path.join(base, `${slug}-${hash}`);
}

The function generates a deterministic 16-character hash from the workspace's canonical path. You can override the base directory via the CLAUDE_PLUGIN_DATA environment variable; otherwise it falls back to /tmp/codex-companion (see lines 10-13).

Location Helpers

Four exported functions resolve specific file paths:

resolveStateFile(cwd)      // → <state-dir>/state.json
resolveJobsDir(cwd)        // → <state-dir>/jobs
resolveJobFile(cwd, id)    // → <state-dir>/jobs/<id>.json
resolveJobLogFile(cwd, id) // → <state-dir>/jobs/<id>.log

Source references: resolveStateFile at lines 46-48, resolveJobsDir at lines 50-52, resolveJobLogFile at lines 83-86, and resolveJobFile at lines 88-90.

state.json Structure

The global state file is a JSON object with three mandatory fields:

{
  "version": 1,
  "config": { /* workspace configuration */ },
  "jobs": [
    { "id": "review-abc123", "status": "completed", "createdAt": "...", "updatedAt": "..." },
    { "id": "explain-def456", "status": "running", "createdAt": "...", "updatedAt": "..." }
  ]
}

The jobs array is a lightweight index—it contains enough metadata to list and filter jobs without opening individual files. Full job payloads live in separate JSON files, not inline here.

Job State File Lifecycle

When upsertJob() saves new state, the plugin executes this sequence (lines 105-112):

  1. Load existing state via loadState().
  2. Prune job list to MAX_JOBS (default 50).
  3. Write updated state.json.
  4. Delete orphaned .json and .log files for jobs no longer in the index.

This garbage collection prevents unbounded disk growth. Log files are removed automatically when their parent job is pruned.

Working with Job State Files

Reading a Job's Full Payload

import { resolveJobFile, loadState } from "./state.mjs";
import fs from "fs";

// List jobs from the index
const { jobs } = await loadState(process.cwd());

// Access complete job data
const jobId = jobs[0].id;
const jobPath = resolveJobFile(process.cwd(), jobId);
const fullJob = JSON.parse(fs.readFileSync(jobPath, "utf8"));

console.log(fullJob.prompt);     // job-specific data
console.log(fullJob.result);     // execution results

Streaming Job Logs

import { resolveJobLogFile } from "./state.mjs";
import { createReadStream } from "fs";

const logPath = resolveJobLogFile(process.cwd(), jobId);
const stream = createReadStream(logPath, { encoding: "utf8" });

stream.on("data", chunk => process.stdout.write(chunk));

Log files are plain text with newline-delimited entries. They are created on first write and may not exist for jobs that produce no output.

Key Implementation Files

File Role
scripts/lib/state.mjs Core path resolution, loadState(), saveState(), upsertJob(), pruning logic
scripts/lib/workspace.mjs getWorkspaceRoot() for canonical path computation
tests/state.test.mjs Validates directory layout and file persistence
tests/runtime.test.mjs End-to-end job lifecycle verification

Summary

  • Three file types: state.json for global index, <job-id>.json for payloads, <job-id>.log for output.
  • Deterministic paths: SHA-256 hash of workspace path plus slug, overridable via CLAUDE_PLUGIN_DATA.
  • API surface: resolveStateDir(), resolveJobFile(), resolveJobLogFile() expose all locations.
  • Automatic cleanup: Jobs beyond MAX_JOBS (50) prune their associated files on next save.

Frequently Asked Questions

What environment variable controls the state directory?

Set CLAUDE_PLUGIN_DATA to use a custom base path. Without it, the plugin defaults to /tmp/codex-companion.

How many jobs are retained by default?

The plugin keeps 50 jobs maximum. Older jobs and their logs are deleted automatically when the threshold is exceeded. This limit is defined as MAX_JOBS in state.mjs.

Why split job data across multiple files instead of one large JSON?

Separating the global index (state.json) from full payloads reduces I/O for common operations like listing job history. The index stays small and fast to parse, while individual job files load only when needed.

Can job log files be read while a job is running?

Yes. Log files are opened in append mode and written incrementally. External processes can tail or stream them via resolveJobLogFile() without interfering with active writes.

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 →