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}/stateoros.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
stopReviewGatetoggle - 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:
<jobId>.json: Contains metadata includingcreatedAt,updatedAt,status(queued,running,completed,failed, orcancelled),sessionId, and custom payloads<jobId>.log: Append-only text log written bytracked-jobs.mjsfunctionsappendLogLineandappendLogBlock
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/stateoros.tmpdir()/codex-companion, with workspace-specific subdirectories named via SHA-256 hash of the root path - Core files:
state.jsonholds global config and a capped job list (50 max); individual<jobId>.jsonand<jobId>.logfiles store details and progress - Key modules:
state.mjshandles persistence;tracked-jobs.mjsmanages execution and logging;job-control.mjsenriches data for UI consumption - Isolation:
CODEX_COMPANION_SESSION_IDfilters 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →