Skip to content

fix(codex): show the context the session actually routed to - #95

Merged
tanglearncode merged 1 commit into
mainfrom
fix/codex-shared-session-scope
Aug 22, 2026
Merged

fix(codex): show the context the session actually routed to#95
tanglearncode merged 1 commit into
mainfrom
fix/codex-shared-session-scope

Conversation

@tanglearncode

@tanglearncode tanglearncode commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

The bug

In Codex, the session auto-routes to a context — use_context reports the switch, get_context serves that context's profile — and then $neatcontext:status and $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:

$ CODEX_THREAD_ID=01a02647-… node src/codex/neatcontext-cli.mjs status   # before
No context is connected yet. Use `$neatcontext:use` to pick one.

$ CODEX_THREAD_ID=01a02647-… node src/codex/neatcontext-cli.mjs status   # after
Connected context: NeatContext platform control-plane implementation

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:

process how Codex starts it thread id?
skill CLI shell tool yesCODEX_THREAD_ID is in the shell environment (confirmed in Codex's own spawn log)
SessionStart hook its own parent process yessession_id on stdin
MCP bridge scrubbed environment no — probed a plugin-shaped MCP server: it receives PATH, TEMP, the platform basics and nothing else; no CODEX_* variable at all

The 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:

plugin-hosts/pid-219336.json         {"sessionId":"01a02675-…","source":"session-start"}   ← hook
plugin-hosts/pid-224804.bridge.json  {"sessionId":null}                                    ← bridge

There is no other route: Codex's MCP client advertises no roots capability and answers roots/list with {"roots":[]}, and a plugin server's cwd must 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/status wrote 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_ID remains 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/status drift warnings that could never fire on Codex. pruneHostPointers stays in the SessionStart hook, to sweep the pointer files older versions left on users' machines. The now-inert env_vars: ["CODEX_THREAD_ID"] passthrough is dropped from .mcp.json.

Mode settings were split the same way ($neatcontext:mode manual was 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:disconnect and $neatcontext:mode now 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 to status immediately.

Tests

  • tests/codex-session-drift.test.mjs encoded the old contract and is replaced by tests/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: what use_context connects is what status and list report, what a skill connects is what get_context serves, 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 test 573 passing, npm run check, e2e:commands, e2e:extensions, e2e:no-nudge all green; diff coverage reports all 60 changed source lines covered.

`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>
@tanglearncode
tanglearncode merged commit df64199 into main Aug 22, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant