Import three high-value upstream PRs, pin what they guarantee, and describe the fork - #7
Conversation
…LAUDE_PLUGIN_DATA root, orphaning brokers resolveStateDir() picks the state root from CLAUDE_PLUGIN_DATA when set, falling back to $TMPDIR/codex-companion when it's absent -- same workspace slug/hash either way, only the root differs. State written under one root (e.g. a broker registered while the var was unset) becomes invisible to any later lookup that resolves to the other root, since nothing checked both. For a broker specifically, that means SessionEnd never finds it to shut down -- it's orphaned permanently, and since ensureBrokerSession also can't see it, the next session spawns a duplicate broker for the same workspace, compounding the leak. The same mechanism affects job/status state (state.json, individual job detail files), not just the broker. Reads now check every candidate root (current primary, then the tmpdir fallback), not just the current invocation's primary -- writes are unchanged, still going to the primary root. Applied to loadState() (job list, status, config), readStoredJob() (individual job detail lookups), and loadBrokerSession()/clearBrokerSession(). Known remaining asymmetry: this fixes the direction with concrete evidence in the issue -- state written while CLAUDE_PLUGIN_DATA was unset, later missed by a lookup that has it set. The reverse isn't fixable this way: an unset env var carries no trace of what value it previously held, so there's nothing to check beyond the always-known tmpdir fallback. Fixes openai#636
…turned Both call sites (handleSessionEnd, ensureBrokerSession) act on whatever loadBrokerSession() returns -- tearing that broker down and clearing its record -- but clearBrokerSession deleted every candidate root's broker.json, not just the one that was actually torn down. That's reachable in practice: it's precisely the root-split bug's own historical fallout, where the old lookup could leave a broker registered under one root while a duplicate got spawned under the other. Deleting both records on the next cleanup erases the untorn broker's only metadata, making it permanently untrackable instead of leaving a stale-but-discoverable file behind. Thanks to Codex Review for catching this.
… with loadBrokerSession on malformed files Two more issues from Codex Review, both real: 1. loadState() returned only the first candidate state.json found, not merged. Unlike a broker session (at most one meaningful record, so 'first found' is correct), jobs are a growing collection -- a job started while CLAUDE_PLUGIN_DATA was set and a different job started while it was unset are both real and non-conflicting. Returning only the first root's job list silently hid whichever root wasn't picked, for every status/result/cancel lookup, any time both roots happened to have a state.json. Now merges jobs from every candidate, keeping the more recently updated copy if the same id somehow appears in more than one. 2. loadBrokerSession() skips a candidate it can't parse and moves on, so it can return a fallback session while a malformed primary file exists. clearBrokerSession() selected by existence alone, so it could delete the unrelated malformed primary while leaving the valid fallback record behind -- the one actually loaded and torn down by the caller. Both functions now share a single selectBrokerState() helper (exists AND parses), so they always agree on which candidate is the selected one. Verified the loadState() merge fix doesn't have a side effect on saveState()'s own previousJobs cleanup diff (its per-job file removal resolves paths against the current-root-only resolveJobFile(), so a job living in another root is a no-op there, not a deletion) -- confirmed empirically with a throwaway repro before concluding no further change was needed there. Thanks again to Codex Review.
…t the primary saveState() only ever wrote the new job list to the current primary root. A job that originated entirely in a different root (e.g. added while CLAUDE_PLUGIN_DATA was unset) and later gets filtered out -- cleanupSessionJobs() during SessionEnd loads the merged view, drops jobs for the ending session, and saves the remainder -- never actually disappeared: that other root's own state.json still held its own untouched copy, and the very next loadState() merged it right back in. A removed job could keep reporting as running indefinitely. saveState() now also prunes every other candidate root's own file down to the same retained job-id set (derived from this save's own merged previousJobs diff), so a deletion sticks everywhere. New and updated jobs are unaffected -- they still only ever get written to the primary root, exactly as before; this only ever removes. Also made the individual job-detail-file cleanup in the same loop candidate-aware (resolveJobFileCandidates instead of the primary-only resolveJobFile), for the same reason. Two of the existing tests had to seed their two-root fixtures via direct file writes instead of two independent saveState() calls -- every real caller (updateState()/cleanupSessionJobs()) always derives its job list from a prior loadState(), so seeding via two disjoint, non-full-list saveState() calls doesn't reflect any real call pattern, and (correctly, now) tripped this very fix's own deletion logic during test setup. Thanks again to Codex Review.
…ross roots cleanupSessionJobs() checked only resolveStateFile()'s (the primary candidate's) existence before deciding whether to look for jobs to clean up. loadState() is candidate-aware, but a session whose jobs live only in the fallback root (e.g. started without CLAUDE_PLUGIN_DATA, with SessionEnd later running with it set, flipping which root is primary) was silently skipped: the early check saw no primary file and returned before loadState() was ever called. Fixed by removing the redundant pre-check -- loadState() already returns an empty job list when nothing exists anywhere, and the existing removedJobs.length === 0 check already short-circuits correctly, without the primary-only blind spot. loadState()'s config merge also only ever read the primary candidate's config, unlike jobs (already merged across every root). A boolean flag like stopReviewGate is an opt-in toward stricter/safer behavior, so any candidate setting it true should win over a stale false elsewhere -- e.g. /codex:setup --enable-review-gate running without CLAUDE_PLUGIN_DATA writes it to the fallback root, invisible to a later invocation whose primary is the plugin-data root. Reconciling by "primary wins" could silently downgrade an explicitly-enabled gate. Both found via Codex Review on the PR.
A thread resumed while its automatic thread/unsubscribe was still in flight was never released again: the later release reused the stale pending request, so the shared app-server connection stayed subscribed after every client had closed. If the app-server had processed the unsubscribe after the resume, the new owner would also have lost its subscription. Never reuse a pending unsubscribe. A release chains a fresh request behind any in-flight one and re-checks ownership before sending. A resume or review that provisionally claims a thread waits, bounded to five seconds, for an in-flight unsubscribe of that thread before it is dispatched, so a late unsubscribe cannot overtake the new subscription and a hung cleanup cannot wedge the broker. Reply to a failed request and clear the busy state before releasing provisional owners for the same reason. Add the unsubscribe-delayed and resume-fails-unsubscribe-hangs fixture behaviors, four regression tests, and assert the retry bound in the existing failure test.
When the bounded wait for an in-flight thread/unsubscribe expires, do not send the resume anyway: that would race the outstanding unsubscribe and could leave a tracked owner without an upstream subscription. Reject the request with a retryable error, release the provisional claim, and clear the busy state so other clients continue.
A release that followed a timed-out claim installed a fresh cleanup wrapper that waited for the original unsubscribe for only another five seconds, then settled as skipped once a retried claim owned the thread. The retry could then resume upstream while the original unsubscribe was still in flight. Make a wrapper wait for its predecessor without a bound so the pending entry never settles before every underlying request has, and reject retried claims until then.
An explicit thread/unsubscribe for an unowned thread queued behind a hung automatic unsubscribe without a bound while its socket held the busy slot, so every other client stayed busy. Apply the same bounded wait and retryable rejection to explicit unsubscribes that the provisional claims already use. Two related gaps closed in the same sweep: - A release that arrives while an earlier cleanup is still queued now shares that queued request instead of adding another link to the chain. A queued request re-checks ownership when it sends, so this is safe, and repeated retries against a hung upstream no longer grow an unbounded chain of duplicate unsubscribes. - A child thread whose cleanup is still outstanding is no longer handed to new owners of its parent by a later notification.
A child notification that arrived while a provisional resume or review was waiting or in flight attributed the child to the claiming socket, and a rejected or failed claim released only the parent. The child then stayed owned until the socket closed. Record children inherited through an open claim and release them together with the claim when it is rejected or fails.
…claim A grandchild spawned while a provisional claim was open has an inherited child as its source, not the claimed root, so it was owned but omitted from the rollback. Record descendants whose source is either a claimed root or an already inherited thread.
…eaves When the final owner sends thread/unsubscribe and disconnects before the upstream request fails, nothing retried the cleanup: the owner was already removed, so the close path had nothing to release, and the explicit path restored ownership only to a still-open socket. Schedule the bounded automatic retry in that case. Test fixture: write state atomically so a test never reads a partial document, and add a delayed single-failure unsubscribe behavior for the new regression test.
Upstream PR openai#707 (thossullivan), nine conflicts resolved. The broker holds one upstream app-server connection and subscribes it to every thread its clients touch, but nothing released those subscriptions: a client that disconnected left its threads subscribed for the broker's whole lifetime, and notifications for them kept arriving for whoever connected next. Downstream ownership is now mirrored per socket, so a thread is unsubscribed upstream once its last owner goes away, with retries, and one client can no longer release a thread another client still holds. The two sides had restructured the same message loop for different reasons, so this is not a textual merge: - The PR rewrote the loop into handleLine() on top of upstream main, which has none of this fork's hardening (instance-token auth on broker/shutdown, the busy check, JSON-RPC shape validation, responseSocket, abandoned turns, completions racing a handoff). The auto-merge kept that copy as dead code beside the fork's loop. It is removed; the fork's loop is the one that runs. - The PR's serialization is kept, and matters: with the fork's `async (chunk)` handler a second data event interleaves with an await inside the first, and the per-request ownership claims stop describing who holds which thread — "broker serializes subscription requests from one downstream client" fails exactly that way. The fork's loop body is therefore lifted into handleLine() (`continue` becomes `return`) and driven through the PR's `processing` chain. - In the request path, the PR's provisional claims, bounded wait on an in-flight unsubscribe, thread/unsubscribe routing and claim rollback are grafted into the fork's path, which keeps its streaming handoff, abandoned turns and idle-shutdown re-arming. Both actions now run on a closing socket: releaseThreadOwners() and armIdleShutdown(). - runShutdown() clears the retry timers, which would otherwise fire against a closed app-server. tests/broker-subscriptions.test.mjs spawned the broker without --instance-token, which this fork refuses to start without; the harness now passes one. In tests/fake-codex-fixture.mjs the two nextThread() signatures are merged (`sandbox` plus forkedFromId/parentThreadId), and the four call sites that passed the options object positionally are corrected — a fork or subagent thread now inherits its source's sandbox instead of silently becoming read-only. Verified: node --check on the broker and the fixture, plus a syntax check of the generated fake codex (a template string, so node --check on the fixture does not cover it — the first resolution left an unbalanced brace there that only showed up at runtime); tests/broker-subscriptions.test.mjs 22/22; full npm test 276/276 twice; tsc clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXfvnjSC72HsM6EEPVt2Tg
Upstream PR openai#659 (mittalpk), nine conflicts resolved. CLAUDE_PLUGIN_DATA is only set when the code runs as a plugin hook: a direct CLI call, or a hook whose environment did not carry it, resolves the state root to the tmpdir fallback instead. Two invocations for the same workspace could therefore land on different roots, and SessionEnd or /codex:status would look in one while the jobs and the broker record lived in the other — leaving brokers orphaned and jobs invisible. Writes still go to the primary root; reads now check every candidate, and deletions prune the others so a removal cannot be merged back in by the next read. Resolutions: - state.mjs: the PR's candidate machinery on top of this fork's file. Its workspaceStateDirName() is this fork's resolveWorkspaceKey(), kept under one name; resolveConfigFile() (from openai#731) keeps using it. saveState() keeps the fork's atomic writes — the PR's cross-root pruning writes through writeJsonFileAtomic() too, rather than the plain writeFileSync it shipped with — and the fork's terminal-claim files get the same candidate treatment as job files through a new resolveJobClaimFileCandidates(), which the PR had no reason to know about. - broker-lifecycle.mjs: the PR's selectBrokerState() is adopted whole, since loadBrokerSession() and clearBrokerSession() must agree on which candidate is "the" record; the fork's import block and everything else stay. - session-lifecycle-hook.mjs: the primary-root existsSync() guard gives way to the candidate-aware loadState() check, keeping the fork's imports and its sessionJobs flow. - tests: the fork's broker-lifecycle and state suites with the PR's cases appended. Both conflicts cut through the fork's last test (the closing `});` was the line shared after the marker), and the PR's import header was dropped with the rest of its file, so clearBrokerSession and readStoredJob are added to the fork's imports. Verified: node --check on every touched script; tests/state.test.mjs and tests/broker-lifecycle.test.mjs 47/47; full npm test 286/286 twice; tsc clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXfvnjSC72HsM6EEPVt2Tg
Upstream PR openai#728 (fscfede-beep), nine conflicts resolved. A record says "running" for as long as nobody rewrites it, so a worker killed between turn/start and its terminal write left /codex:status reporting a job that no process was running. reconcileJobLiveness() checks the recorded pid before reporting: a worker that is gone with no thread reads as terminated-unknown, and one that is gone while its Codex turn may still be running stays active with a phase that says so. Every status, result and selection path now goes through it. Where the PR's session-end and cancel rewrites met this fork's, the fork's are kept — they are the later, more careful versions (terminal claims, interrupt budget, straggler handling, record-first cancellation) — and only the PR's distinct insight is grafted in: - Session end: reconciliation decides one thing, not the whole flow. A dead worker still gets its terminal record, which is what stops /codex:status from answering "No job found" for the session that just ended (and what this fork's own "keeps a terminal record for the ending session's dead worker" test asserts). Only when the worker exited after turn/start with a thread but no turn id is the job retained as active instead: nothing can interrupt that turn, so recording it cancelled would claim an outcome that did not happen. - Cancel: the same refusal has to fire before the record-first write, not after it, or the job is already marked cancelled when it raises. It also cannot wait on the turn-identity helper — the worker that would publish the id is already gone. - resolveCancelableJob(): an explicitly named job is resolved against the stored record when reconciliation has moved it out of the active set, but only when a detail file exists on disk. That keeps `/codex:cancel <id>` repairing a stale index (this fork's behavior, three tests) while the PR's own "no detail file, nothing to cancel" case still refuses. Not taken: "session end fully cleans up jobs for the ending session", which asserts the ending session's job files are deleted. This fork deliberately retains a terminal record and its files so a later status query shows a cause instead of "No job found"; the test contradicts that design and the fork test that pins it. tests/runtime.test.mjs is rebuilt rather than merged hunk by hunk: the two sides had added tests at the same offsets, so the conflict split test headers from bodies and a plain union produced one test wearing another's body. The fork's file is taken whole and the PR's seven new tests appended from its own copy. Verified: node --check on every touched script; full npm test 300/300 twice; tsc clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXfvnjSC72HsM6EEPVt2Tg
…eplacement The review on openai#731 asks for exactly this coverage, and this fork already carries the hardening it requests (ensurePrivateDir plus writeJsonFileAtomic instead of mkdirSync plus writeFileSync), so the tests belong here too. - "the durable review-gate config is private" pins 0600 on the file and 0700 on its directory. It discriminates: reverting writeDurableConfig() to the plain writeFileSync the PR shipped with fails it. - "a durable config write that fails mid-write leaves the previous config intact" fails the replacement from inside writeJsonFileAtomic(), after it has created its temporary file, using a value whose toJSON() throws. It asserts the previous config still reads back enabled and that no temporary file is left beside it. This one does not discriminate against the naive implementation (which throws before touching the file either way); what it guards is the regression class where a future rewrite truncates the target before serializing, and the cleanup path of the atomic write. Verified: full npm test 302/302; tsc clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXfvnjSC72HsM6EEPVt2Tg
The review on openai#737 asks for a multi-manager regression alongside the ordering fix. Two tests, with every manager root populated at once (nvm, fnm, asdf, mise): - "prefers a managed toolchain over a system install with several managers present" is the regression for the precedence bug itself. It discriminates where a system node exists: putting /usr/local/bin and the Homebrew paths back ahead of the manager roots fails it, along with the four upstream tests that caught the bug originally. It deliberately does not pin *which* manager wins — the launcher cannot tell which one the project or user selected, since it reads no .nvmrc, .tool-versions or mise config, so asserting one would freeze an arbitrary order as if it were a guarantee. - "lets CODEX_COMPANION_NODE override every installed manager" pins the one way a specific runtime can be selected today, which is the honest answer to the reviewer's "preserve the selected runtime" until the launcher learns to read the active manager. Verified: the first test fails with the pre-fix ordering restored and passes with it in place; full npm test 304/304; tsc clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXfvnjSC72HsM6EEPVt2Tg
CI failed on this PR's head with "dead owner should be reclaimed before stale timeout" in tests/locking.test.mjs — a test neither this branch nor the imports touch. The assertion is the flaky part, not the behavior: acquireLock() checks its deadline only after a failed attempt, so on a loaded runner a single slow attempt still returns successfully at over 500ms of wall clock. Nothing is weakened by dropping it. acquireLock() throws once its deadline passes without acquiring, so a successful return under a 500ms budget with staleMs at 30s already proves the lock was reclaimed because its owner is gone rather than because it aged out. Verified by commenting out reclaimAbandonedLock(): the test fails again, as does the other reclaim test. Verified: tests/locking.test.mjs 5/5 three runs in a row; full npm test 304/304. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXfvnjSC72HsM6EEPVt2Tg
…s is a fork - README "Differences From Upstream" gains openai#659, openai#707 and openai#728, plus a short list of the defects the imports themselves surfaced and this fork fixes (busy-broker refusal, the launcher's inverted precedence, the durable config's private atomic write, the app-server typecheck), and what is deliberately not imported (openai#733, openai#761) with the reason. - CHANGELOG records the three imports under Unreleased. - The plugin, marketplace and package descriptions now say this is a fork of openai/codex-plugin-cc carrying open upstream fixes. Anyone browsing /plugin sees the same description the marketplace lists, and it read as upstream's own plugin until now. The version stays 1.0.6 (npm run check-version passes): describing the fork is not cutting a release. Verified: full npm test 304/304; tsc clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXfvnjSC72HsM6EEPVt2Tg
…utions
A review of the whole day's work (this branch plus the already-merged round)
found eight issues; these are the three that belong to the grafts made here,
plus one they exposed in the previous round. Each has a regression test that
fails without the fix.
- cancel refused *after* taking the terminal claim. claimTerminalStatus()
creates a claim that is never released, so the leaked cancel-intent claim
made the next cancel — or SessionEnd — adopt it and reassert a cancelled
record for the turn the refusal exists to protect. The refusal now runs
before the claim, off the same merged job view.
- The session-end guard reconciled the stale state.json snapshot instead of
the job file the code re-reads ten lines below for exactly that reason. On a
snapshot with pid: null the guard was skipped and the job recorded cancelled
while its turn ran on; on a snapshot missing a turnId the file already had,
it retained a job whose turn could have been interrupted. It now reconciles
the captured values.
- The retain path wrote pid: null, and reconcileJobLiveness() needs a pid: the
record could never be judged again, so it stayed "running" in /codex:status
and was refused by /codex:result and /codex:cancel. The pid and thread id are
written back instead.
- From the previous round: assertResumedSandbox() still asserted the requested
mode on a scoped resume, although buildThreadAccessParams() deliberately
sends a permission profile and no sandbox when read roots are set. Every
`--read-root` resume was refused outright, and the error's own advice
("resume with --sandbox read-only") silently dropped the write grant. The
assertion is skipped when read roots are in play.
Verified: each new test fails with only its fix reverted (stash the source
file, run the suite) and passes with it; full npm test 307/307; tsc clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXfvnjSC72HsM6EEPVt2Tg
…ted code The remaining findings from the independent review. None came from this branch's grafts; all four arrived with imported PRs and are now this fork's. - state.mjs: the cross-root prune rewrote another root's state.json while holding only the primary's lock, so a process whose primary IS that root could lose its whole update to the rename. It now takes that root's own lock and never waits for it (timeoutMs: 0): two processes each holding the other's primary lock would deadlock, and a skipped prune is harmless — the next save redoes it. - state.mjs: loadState() merges booleans across roots with OR, so a stranded `stopReviewGate: true` outvoted an explicit disable forever whenever the durable config could not be read, because setConfig() only ever wrote the primary. It now writes the new config into every existing root (same non-blocking lock discipline), keeping the fail-safe OR without making "disable" unreachable. The merge also folds candidates in reverse so the primary wins for non-boolean keys — the old "first writer wins" branch was dead for every key the defaults define, which is all of them. - session-lifecycle-hook.mjs: setEnv() rewrote the shared CLAUDE_ENV_FILE (read, filter, rename), which drops any export another plugin's SessionStart hook appended in between and discards the file's mode with the replaced file. It appends again, and skips the append when the value the file already resolves to is ours — the shell takes the last export for a key, so openai#748's point (no growth on every session) survives without the data loss. - claude-session-transfer.mjs: a process attaching to a staged copy whose creator had not yet written the marker took no lease, and the creator's release() then deleted the file under it. The lease is now taken unconditionally, and cleanup belongs to whoever leaves last (marker present, no leases left) rather than to whoever created the copy. Regression tests for the first three; each fails with only its own fix reverted. The staging race has no deterministic test — it needs an interleaving between two processes at a specific point — so it rests on the reasoning above. Verified: full npm test 310/310; tsc clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXfvnjSC72HsM6EEPVt2Tg
…ound of fixes - The retained orphan kept its dead pid, which made it reapable: another session's SessionEnd failed the record without an interrupt and it stopped pinning the broker, tearing the runtime down under the turn the retain protects. The verdict is persisted instead — pid: null plus workerExited — and reconcileJobLiveness() reads that flag, so the record stays truthful in /codex:status while a pid-less active record keeps the broker up under the existing staleness bound. The test now also ends a second session and asserts the job survives it. - Skipping assertResumedSandbox() for scoped resumes also dropped the escalation check. A dedicated one replaces it: a thread running with the sandbox disabled cannot be scoped by a permission profile, so --read-root on it is refused rather than silently promising a scope. - saveState() deleted a dropped job's files from every root while the prune of another root's state.json is best-effort, so a contended prune left a record to be merged back — and rewritten into the primary — with its detail file, claim and log already gone. Each root's files now go with its own record. - setEnv() appended without ensuring the file ends in a newline, so a preceding hook's unterminated line and ours would run together and lose both exports. - The append's own comment (and the README's line for openai#748) claimed the file no longer grows per session, which is false for values that change every session — the session id and transcript path. Both now say what actually holds: unchanged values are skipped, changed ones append, and the shell takes the last export. Bounded growth is the price of never destroying another plugin's export. - release() took the 5s staging lock even when there was nothing to clean up, from a finally, so a busy lock replaced the import error that was unwinding. It is wrapped now, with a lock-free unlink of our own lease as the fallback. Verified: full npm test 310/310; tsc clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXfvnjSC72HsM6EEPVt2Tg
- A retained orphan belongs to the session that is ending, so hasActiveJobsFromOtherSessions() does not speak for it and the same hook run went on to shut the broker down — killing the turn the retain protects. cleanupSessionJobs() now reports what it retained and SessionEnd leaves the runtime up for it. - With no pid, that record's only exit was the day-long staleness rule, so it would block every session's broker shutdown for a day. Its protection is bounded to the broker's own idle window instead (CODEX_BROKER_IDLE_SHUTDOWN_MS, 10 minutes by default): the turn cannot outlive the broker anyway, since its client is gone and the broker idles out on that same timer. The reaper and the broker guard both use the new bound. - The other root's prune deleted a job's files on a retainedIds set computed before the lock, so a job created in that root since the snapshot could lose its detail file, claim and log while its worker ran. Both the record prune and the deletion are now limited to ids the caller's own snapshot held; anything newer is nobody's to drop here. - The scoped-resume escalation check now also runs on a fresh scoped start: a default config can start a thread with the sandbox disabled, which would have made the resume error's own "--fresh" remedy reproduce the refused condition. - setEnv() always opens its append with a newline rather than deciding from the read above it, which still raced a hook appending an unterminated line in between. The test for the file's contents asserts the exports and their order, since blank lines are nothing to the shell that sources it. Verified: full npm test 310/310; tsc clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXfvnjSC72HsM6EEPVt2Tg
… the right remedy The fourth review round, with one finding it called blocking. - Blocking: with CODEX_BROKER_IDLE_SHUTDOWN_MS=0 — a documented way to disable the broker's idle shutdown — retainedOrphanExpired() never returned true, while SessionEnd now declines to tear the broker down for such a record. The broker, its app-server and its MCP servers would have leaked permanently, and the job stayed "running" with no way to clear it. The window now falls back to the generic staleness bound when the idle timer is disabled, and an unreadable timestamp counts as expired rather than as protected forever: this is the one record that stops a teardown, so "cannot tell" must not mean "keep it alive". Probed both ways: with the timer off a 25h-old orphan is reaped and a 2h-old one is not; with the timer at 10 minutes a 2h-old one is. - The escalation error on a fresh scoped start told the user to "start a fresh thread with --fresh", which is exactly what had just failed. It now names the real cause (the Codex config's sandbox default) and the two real remedies. The message still says "--fresh" on the resume path, where it works. - updateState() passes the snapshot it mutated down to saveStateLocked(), so the other-root prune judges "ids the caller knew about" from that snapshot rather than from a later re-read. A direct saveState() has no snapshot to offer and keeps the re-read, which is now documented. Verified: full npm test 310/310; tsc clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXfvnjSC72HsM6EEPVt2Tg
…out it The fifth review round found nothing blocking. Its two substantive remarks are addressed here: - Both new branches of retainedOrphanExpired() were dead in the suite (the existing retained-orphan test seeds updatedAt in 2099). Two tests now drive them end to end: with the idle timer disabled a two-hour-old orphan is still protected while a 25-hour-old one is reaped, with the timer at ten minutes the two-hour-old one is reaped, and an unreadable timestamp is treated as expired. - Two comments overstated things. The early return in SessionEnd said the broker "idles out on its own timer", which is false precisely in the configuration that made this bound necessary; it now says what ends the wait in both configurations. And the "unlike every other record" framing is gone: isStaleJobRecord() reads an unparseable timestamp conservatively, which is the behavior this function deliberately does not share, not something no other record does. Verified: full npm test 312/312; tsc clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXfvnjSC72HsM6EEPVt2Tg
Review: five rounds, and the last one comes back cleanAn independent review was run over the whole day's work (this PR plus the already-merged round), then re-run after each round of fixes. The fifth round found nothing blocking: "this round closes all three findings correctly — the PR is mergeable." Suite is 312/312, It was worth running. The first pass found eight defects, four of them in the conflict resolutions made here, and each fix round surfaced more — which is the point of doing it adversarially rather than once. Round 1 — eight findingsFour in this branch's grafts, fixed in
Four in imported code, fixed in Rounds 2–4 — the fixes' own defects
Round 5Three low findings, two addressed in Every fix carries a regression test that fails with only its own fix reverted, except the staging-lease race, which needs an interleaving between two processes and rests on the reasoning in its commit. Generated by Claude Code |
…it carries The README listed imported PR numbers and flags, but never said what the plugin does now — most of this fork's divergence is behavior under failure, which is invisible from a list of links. - "What The Background Runtime Guarantees" up front: state is checked rather than trusted, session end never reports an outcome that did not happen, one broker per workspace retired by the last session out, a finished job keeps a record with its cause, nothing written is world-readable or half-written, and Node is found where it is actually installed. - /codex:status documents the two reconciled states a dead worker produces, including worker-exited-turn-unknown and why the plugin cannot resolve it. - /codex:result says a non-crashing failure keeps its error text. - /codex:cancel documents its order of operations and the three outcomes that are not a plain cancellation: already finished, a repaired stale record, and the refusal when the turn id was never recorded. - Background Runtime Limits notes that the broker idle window also bounds a retained orphan, and what happens when that timer is disabled. - A new "Where State Lives" table: the two plugin-data roots and why both are real, the durable review-gate config under CODEX_HOME, and that Codex threads and auth stay with the Codex CLI. - The fork-fixes list gains the defects the review rounds found, and says they came from an adversarial review re-run after each round. Verified: full npm test 312/312; npm run check-version passes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXfvnjSC72HsM6EEPVt2Tg
Second round of imports from
openai/codex-plugin-cc, one merge commit each so any of them can be reverted on its own — plus the defects an adversarial review found in those merges, and a README that finally says what this fork does. The fourth candidate, openai#733, is deliberately not here (see below).Imported
b4f788eb59bdffCLAUDE_PLUGIN_DATAroot was invisible to an invocation that resolved to another, orphaning brokers and hiding jobsc8aa8a8Each merge commit records its own resolution. Three are worth calling out here.
openai#707 required rebuilding the message loop, not merging it. The PR rewrote the loop into
handleLine()on top of upstreammain, which has none of this fork's hardening (instance-token auth onbroker/shutdown, the busy check, JSON-RPC shape validation,responseSocket, abandoned turns, completions racing a handoff) — and the auto-merge kept that copy as dead code beside the fork's loop. It is removed. Its serialization is kept and matters: with the fork'sasync (chunk)handler a second data event interleaves with an await inside the first, and the per-request ownership claims stop describing who holds which thread.broker serializes subscription requests from one downstream clientfails exactly that way. So the fork's loop body is lifted intohandleLine()and driven through the PR'sprocessingchain.It also surfaced a latent bug in
tests/fake-codex-fixture.mjs: merging the twonextThread()signatures left four call sites passing the options object positionally, which made a forked or subagent thread silentlyread-onlyinstead of inheriting its source's sandbox.openai#659 keeps this fork's atomic writes. The PR's cross-root pruning shipped with a plain
writeFileSync; here it goes throughwriteJsonFileAtomic()like every other state write. The fork's terminal-claim files get the same candidate-root treatment as job files through a newresolveJobClaimFileCandidates(), which the PR had no reason to know about.openai#728 is grafted, not adopted. Where its session-end and cancel rewrites met this fork's, the fork's are kept (terminal claims, interrupt budget, straggler handling, record-first cancellation) and only its distinct insight is taken: a dead worker still gets its terminal record — that is what stops
/codex:statusanswering "No job found" for the session that just ended — except when it exited afterturn/startwith a thread but no turn id, where nothing can interrupt that turn and recording it cancelled would claim an outcome that did not happen.Not taken from openai#728:
session end fully cleans up jobs for the ending session, which asserts the ending session's job files are deleted. This fork retains a terminal record so a later status query shows a cause, and has its own test pinning that.openai#733 is not imported
Its central behavioral test,
cancelled queued task is never reclaimed by a late-starting worker, already passes on this fork: the cancelled job is not reclaimed, Codex is never invoked, the record stays cancelled. The only difference is the worker's exit code (1 withJob … was cancelled before it started.rather than 0). Its other "behavioral" test asserts the shape of the source (trackedBody.indexOf("try {") < indexOf("writeJobFile")), so it fails on any fork whose code is arranged differently.What would remain is a second cancellation mechanism —
.cancelledand.removedmarker files — beside the terminal claims this fork already uses, i.e. two sources of truth for the same decision, and.removedserves a session-end semantics this fork deliberately does not have.Review: five rounds
An independent review was run over the merges and re-run after each round of fixes. The full account is in a comment; the fifth round's verdict is "nothing blocking remains — the PR is mergeable."
The first pass found eight defects, four of them in the conflict resolutions made here, including two that quietly undid the protections they were part of: the cancel refusing after taking the job's unreleasable terminal claim (so the next cancel turned that claim into a bogus
cancelledrecord for the turn being protected), and the session-end guard reading the stalestate.jsonsnapshot instead of the job file the code re-reads ten lines below for that very purpose. The other four were in imported code: a cross-root prune running under the wrong lock, a strandedstopReviewGate: trueoutvoting an explicit disable forever,CLAUDE_ENV_FILErewritten rather than appended (dropping other plugins' exports and the file's mode), and a staging-lease race that let one process delete a file another was reading.Rounds two through four were the fixes' own defects — most of them around the record retained for a turn that cannot be interrupted, which in turn became reapable, then unbounded, then a permanent broker leak under
CODEX_BROKER_IDLE_SHUTDOWN_MS=0. That last one was called blocking and is fixed; the window now falls back to the staleness bound, and an unreadable timestamp counts as expired.Every fix carries a regression test that fails with only its own fix reverted, except the staging-lease race, which needs an interleaving between two processes and rests on the reasoning in its commit.
Tests asked for by the upstream reviews
Both reviews on the upstream PRs this fork had already patched ask for coverage; it belongs here too.
184df24): the durable review-gate config is 0600 in a 0700 directory (reverting to the PR's plainwriteFileSyncfails it), and a replacement that fails mid-write leaves the previous config intact with no temporary file beside it.ed2d434), with every manager root populated at once: a managed toolchain beats a system install (restoring the pre-fix ordering fails it, along with the four upstream tests that caught the bug), andCODEX_COMPANION_NODEoverrides every manager. Which manager wins is deliberately not pinned — the launcher reads no.nvmrc,.tool-versionsor mise config, so asserting one would freeze an arbitrary order as a guarantee.CI
916fcfcfixes the only failure CI reported, intests/locking.test.mjs— a test neither this branch nor the imports touch. The assertion was the flaky part, not the behavior:acquireLock()checks its deadline only after a failed attempt, so on a loaded runner one slow attempt still returns successfully past the 500ms the test measured. Nothing is weakened by dropping that measurement —acquireLock()throws when its deadline passes, so a successful return under a 500ms budget withstaleMsat 30s already proves the lock was reclaimed because its owner is gone. Verified by commenting outreclaimAbandonedLock(): the test fails again.Docs and metadata
625c770and877e58brewrite the landing page around what the plugin now guarantees rather than which PRs it carries:/codex:statusdocuments the two reconciled states a dead worker produces,/codex:resultthat a non-crashing failure keeps its error text,/codex:cancelits order of operations and the three outcomes that are not a plain cancellation;CODEX_HOME, and that threads and auth stay with the Codex CLI), and the note that the broker idle window also bounds a retained orphan;The plugin, marketplace and
package.jsondescriptions now say this is a fork ofopenai/codex-plugin-cccarrying open upstream fixes — until now/pluginlisted it with upstream's own description. The version stays 1.0.6 (npm run check-versionpasses): these merges do not cut a release.Testing
npm test: 312/312, green on repeated full runs.npx tsc -p tsconfig.app-server.jsonclean, CI green on the head.🤖 Generated with Claude Code
https://claude.ai/code/session_01UXfvnjSC72HsM6EEPVt2Tg