fix(codex): show the context the session actually routed to - #95
Merged
Conversation
`use_context` connected a context and `get_context` served it, and then `$neatcontext:status` and `$neatcontext:list`, run a second later, reported that nothing was connected at all. The two halves were reading different files. Codex exports CODEX_THREAD_ID to the processes it starts through its shell tool — the CLI a skill runs — and to nothing else. The MCP bridge is started with a scrubbed environment that no CODEX_* variable survives into, its MCP client advertises no `roots` and answers `roots/list` with an empty list, and its parent process is not the one the SessionStart hook is spawned from, so the pointer file keyed on the host process never joined them either. The bridge wrote the machine-wide selection; the CLI read the per-thread one; neither could see the disagreement. So the Codex adapter stops claiming a session identity it cannot give every one of its processes: one selection, one routing mode, one set of declines, shared by the bridge, the hook, and the skills. That is coarser than per-thread — two Codex windows share it — but it is what this host exposes, and a shared answer that is true beats a private one only half the plugin can see. The Copilot adapter refuses the same trade for the same reason. NEATCONTEXT_SESSION_ID is the way back for any host that can inject one id everywhere. The thread-drift machinery built on the old assumption goes with it: the pointer writes, the bridge's published session, and the drift warnings that could never fire. `pruneHostPointers` stays, to sweep the files older versions left behind. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
In Codex, the session auto-routes to a context —
use_contextreports the switch,get_contextserves that context's profile — and then$neatcontext:statusand$neatcontext:list, run moments later, both report that nothing is connected.Reproduced on this machine, against the real state the user's Codex window left behind:
Why it happened
The two halves of the plugin were reading different files, and nothing in either path could observe it. What Codex actually gives each process — all verified against Codex 0.147 on this machine, not assumed:
CODEX_THREAD_IDis in the shell environment (confirmed in Codex's own spawn log)session_idon stdinPATH,TEMP, the platform basics and nothing else; noCODEX_*variable at allThe host-pointer channel that was supposed to bridge that gap keys on
process.ppid, and the hook and the bridge do not share one. From a single Codex run on this machine:There is no other route: Codex's MCP client advertises no
rootscapability and answersroots/listwith{"roots":[]}, and a plugin server'scwdmust point inside the plugin, so the bridge cannot even name the workspace. Every routing decision the bridge has recorded on this machine says"sessionId": null— it has been writing the machine-wide selection all along, while$neatcontext:use/statuswrote and read the per-thread one.The fix
The Codex adapter stops scoping on a value only half of its processes can see. One selection, one routing mode, one set of declines, shared by the bridge, the hook, and the skills.
That is coarser than per-thread — two Codex windows on a machine share it — but it is the honest scope for what this host exposes, and it is already what the bridge (and therefore all grounding) was doing. The Copilot adapter refuses the same trade for the same reason, in a comment that describes this bug exactly: "a variable only some of this plugin's processes see is worse than none."
NEATCONTEXT_SESSION_IDremains the way back for any host that can inject one id into every process.The machinery built on the old assumption goes with it: the pointer writes, the bridge's published session, and the
$neatcontext:use/statusdrift warnings that could never fire on Codex.pruneHostPointersstays in the SessionStart hook, to sweep the pointer files older versions left on users' machines. The now-inertenv_vars: ["CODEX_THREAD_ID"]passthrough is dropped from.mcp.json.Mode settings were split the same way (
$neatcontext:mode manualwas invisible to the bridge that enforces it) and are fixed by the same change.Behavior change
Docs and skill descriptions that promised per-thread isolation are corrected rather than left to mislead:
$neatcontext:use,$neatcontext:disconnectand$neatcontext:modenow say the connection is shared across Codex sessions on the machine. No migration: the shared file is the one the bridge was already writing, so whatever a session had routed to stays connected and becomes visible tostatusimmediately.Tests
tests/codex-session-drift.test.mjsencoded the old contract and is replaced bytests/codex-session-scope.test.mjs, which spawns the three process kinds the way Codex spawns them — bridge with no thread id, CLI with one, hook with a third host key — and holds them to one answer: whatuse_contextconnects is whatstatusandlistreport, what a skill connects is whatget_contextserves, the mode a skill sets is the mode the bridge enforces, and a pointer file left by an older version cannot re-split the scope.npm test573 passing,npm run check,e2e:commands,e2e:extensions,e2e:no-nudgeall green; diff coverage reports all 60 changed source lines covered.