Skip to content

fix(mcp): keep explore's already-sent tracking per calling context - #1621

Draft
AtkinsChang wants to merge 4 commits into
colbymchenry:mainfrom
AtkinsChang:fix/1620-explore-dedup-session-buckets
Draft

fix(mcp): keep explore's already-sent tracking per calling context#1621
AtkinsChang wants to merge 4 commits into
colbymchenry:mainfrom
AtkinsChang:fix/1620-explore-dedup-session-buckets

Conversation

@AtkinsChang

@AtkinsChang AtkinsChang commented Aug 26, 2026

Copy link
Copy Markdown

Fixes #1620

Problem

The explore already-sent record (the source of "Already sent earlier in this conversation" back-references) is scoped to the MCP connection. Harnesses like Claude Code route a subagent's tool calls over the same connection as the main agent, and a compaction empties the context while the connection lives on — in both cases the agent is handed pointers to source its current context never held.

What this does (4 commits)

  1. fix(mcp) — per-context bucketing. The session record is bucketed by an optional, host-injected sessionId on codegraph_explore. No id → the shared default bucket, byte-for-byte today's behavior (goldens unchanged). Ids are opaque Map keys (never logged/echoed/pathed); over-long ids are sha256-hashed rather than truncated so distinct ids can never collide into one bucket. The schema doesn't advertise the parameter by default; CODEGRAPH_MCP_EXPLORE_SESSION_PARAM=1 declares it for hosts that validate args (hook-injected fields reach the server either way — verified on Claude Code 2.1.246).
  2. feat(mcp) — the reset path. A one-shot control line on the daemon socket ({"codegraph_control":1,"op":"clear-session-record","sessionId":…}) clears one caller's buckets across live sessions; it can never clear the shared default bucket. Two always-exit-0 hook entry points drive it: codegraph hooks pre-tool-use stamps sessionId (<session_id> for the main agent, <session_id>:<agent_id> for a subagent) onto explore calls via the host's PreToolUse updatedInput; codegraph hooks post-compact reads the compact hook payload and sends the control line. --agent codex additionally pairs permissionDecision:"allow" with the rewrite — codex requires the pair (and treats the decision as approval-inert) while Claude Code would act on it, so it is emitted only where the wiring explicitly declares codex.
  3. feat(installer) — Claude Code wiring. codegraph install adds two hooks to Claude settings: PreToolUse (matcher mcp__.*__codegraph_explore) → hooks pre-tool-use --agent claude, and PostCompacthooks post-compact. Surgical merge, idempotent, uninstall reverses; user hooks untouched.
  4. feat(installer) — Codex wiring. Same two entries into ~/.codex/hooks.json (global) / ./.codex/hooks.json (local), with the same PostCompact event (codex fires it only on successful compaction, and every failure path in all four compaction implementations returns before the history rewrite commits — a failed compaction leaves the agent's context intact, so skipping the reset there is correct). Entries are appended, never reordered — codex's hook-trust key includes the group index, so reordering would silently un-trust existing user hooks. [hooks.state] is never written: codex asks the user to review & trust the new entries on next run.

Compatibility

  • Hosts with no hooks wired: identical behavior (default bucket, schema unchanged).
  • A malformed/absent id can never clear or pollute the shared bucket; eviction and hashing both fail toward re-serving.

@AtkinsChang
AtkinsChang force-pushed the fix/1620-explore-dedup-session-buckets branch 5 times, most recently from 80d7988 to a7754af Compare August 26, 2026 14:29
@AtkinsChang

Copy link
Copy Markdown
Author

@colbymchenry Before I polish this further: are you okay with the general approach here?

Since an MCP connection can span multiple contexts (main/subagents, and across compaction), this PR:

  • buckets already-sent state by host session_id / agent_id
  • uses the host compact hook to reset that bucket

So CodeGraph relies on the harness to define context lifecycle rather than trying to infer it.

Is this ownership model acceptable, or would you prefer a different approach?

`codegraph_explore`'s cross-call dedup keyed its record per MCP connection,
but one connection carries several agent contexts — harnesses route a
subagent's calls over its parent's — so a fresh subagent was told source was
"already sent" to a context that never received it, and it Read the file.

The record is now bucketed by an optional caller-supplied `sessionId` a host
hook injects. It is Map-key-only client data: trimmed, never logged, echoed,
or used in a path; anything unusable collapses to the default bucket, which is
byte-for-byte today's behaviour for hosts that inject nothing. Past 128 chars
an id is hashed rather than cut — truncation would collapse two ids sharing a
prefix into one shared record, reintroducing the bug this fixes. The LRU bound
spans buckets, so a wide fan-out evicts the least recently active caller — a
re-serve, the safe direction.

The schema does NOT advertise the parameter. A host hook's replacement input
reaches the server whether or not the property is declared, so declaring it
buys the hook path nothing while exposing a knob an agent could set by hand.
`CODEGRAPH_MCP_EXPLORE_SESSION_PARAM=1` declares it where a host validates
arguments against the schema, or a harness passes it explicitly; the handler
accepts it either way.
…ssion

A bucket has to be forgettable: after a compact the agent no longer holds the
source the server believes it was sent, so its next explore comes back as
pointers to text that is gone — the shape that costs a Read. Only the host
knows when that happens, so the reset arrives from outside the MCP
conversation.

The daemon now answers a one-shot control line: a connection whose first line
carries the `codegraph_control` marker is a command, never a session, so it is
never registered as a client and never holds the idle timer. It sweeps every
live session for the named bucket and replies with how much it dropped; an
unknown op or a non-string id is refused rather than thrown, since a daemon
that dies on a bad control line takes every connected session with it. A
malformed line or a client hello still reaches the transport verbatim.

A hidden `codegraph hooks` namespace carries the two entry points a host
invokes: `pre-tool-use` stamps the calling context id onto a
`codegraph_explore` call, `post-compact` relays the reset to the project's
daemon. Both follow `prompt-hook`'s contract — always exit 0, stdout carries
the hook protocol and nothing else.

The context id is the host session plus the agent id when a subagent is
calling. A bare agent id would assume the host numbers its agents uniquely
across all sessions; scoping each to its session drops that assumption, and
both hooks derive through the same function so a reset always names the bucket
the stamp filled.

`pre-tool-use --agent codex` additionally pairs the rewrite with
`permissionDecision`, which codex requires or it silently discards the
rewrite, and which is inert for its approvals. No other agent gets the field:
Claude Code acts on it, so "allow" would override a user who chose not to
allowlist codegraph. The agent is named by the wiring that installed the hook,
never inferred from the payload, and an unrecognized name omits the field.
Per-caller bucketing only engages if something injects the caller id, so
without the hooks installed no real user gets the fix — the record stays keyed
per MCP connection and a subagent is still told source was "already sent".

The Claude target now merges two entries into the settings file for the
install's scope: PreToolUse, matched on the tool suffix
`mcp__.*__codegraph_explore` because the MCP server name is the user's to
rename, and PostCompact, which fires once compaction has completed and before
the resumed session's first turn — the only moment a reset is correct, since
PreCompact would run while the agent still holds the source. PostCompact
covers manual and automatic compaction alike and supports no matcher, so that
key is omitted rather than written empty.

The PreToolUse command names its agent, so the hook emits Claude's protocol
rather than another's — which here means no `permissionDecision`: Claude acts
on that field, and "allow" would auto-approve a call the user's own permission
flow should decide.

The command prefers the absolute launcher, resolved through the existing
install-method detection, rather than trusting PATH: hooks run under whatever
environment the host hands them, and Claude executes them through Git Bash on
Windows. Entries are recognized by their `hooks <subcommand>` substring with
flags excluded, so an entry whose path or flags have drifted is rewritten in
place instead of joined by a second copy of itself.

The structure surgery — launcher resolution, the merge, and the prune —
lives in the shared target helpers rather than in this target, since it is the
host's event names and file that vary, not the shape of the edit. Each target
keeps its own read/write policy, which is where they genuinely differ.

Surgical throughout: our entries go in as their own groups, appended so a host
that identifies a hook by position never renumbers the user's; uninstall drops
exactly those two commands and prunes only structures left empty; a
matcher-less sibling group survives both directions; a non-array a user put
under one of these events is never overwritten; and a re-run on an unchanged
file leaves the bytes alone. Claude only — Codex is immune by construction and
Cursor/opencode have no equivalent hook system.
Codex carries the same problem the Claude wiring solved: without a hook to
stamp the calling context, the server's already-sent record stays keyed per
MCP connection. Its per-thread connections mean it never needs the stamp for
isolation, but the post-compact reset still has to name a bucket.

Codex discovers hooks from a `hooks.json` in each config layer's `.codex/`
folder, which maps onto the global/local split the TOML config already uses.
Two entries go in: PreToolUse, whose matcher is a regex over the tool name,
and PostCompact, which codex gates behind compaction SUCCESS in three of its
four compaction paths. That gate is what we want: in all four paths every
failure returns before the history rewrite commits, so a failed compaction
leaves the agent's context — and the record describing it — intact, and
skipping the reset is exactly right. PreCompact would instead clear a
still-valid record on every failed attempt. This matches how the Claude
target wires the same reset.

The PreToolUse command declares `--agent codex`, which is what makes the hook
pair `permissionDecision` with its rewrite. Codex requires that pair or it
silently drops the rewrite; no other agent receives the field.

Both entries are recorded as trusted in `[hooks.state]` as they are written,
so codex does not stop to ask about them. Its review flow guards hooks that
arrive from somewhere the user didn't choose; an installer they just invoked,
writing its own two entries pointing at its own binary, is not that threat,
and a local install's state still sits behind codex's project-trust gate.
Trust is held per ENTRY — a hash of each handler's normalized identity — so
this can only ever record the two entries the installer wrote; the user's
hooks are unreachable from there and their records are never read or
rewritten. The hash reimplements a codex internal and is pinned by a fixture
test against real entries codex wrote; if it ever stops agreeing, the entry
simply reads as needing review, which is what happens today.

An unparseable hooks.json is left exactly as it is rather than backed up and
replaced: codex would otherwise run our file in place of config we failed to
read.

The contract suite's "sibling MCP server" case keyed off the first .json in
describePaths as a proxy for "this target's MCP config". hooks.json is written
but holds no MCP entry, so that proxy now asks detect() for the config path
directly. Its "Claude-only" hooks case becomes "Claude and Codex only", and
now also checks the remaining targets' own config files rather than only
Claude's. The array-of-tables case no longer requires the user's `[[history]]`
tables to END the file, since the trust records append after them — it
requires them to survive byte-for-byte, which is the actual guarantee.
@AtkinsChang
AtkinsChang force-pushed the fix/1620-explore-dedup-session-buckets branch from a7754af to e4be07c Compare August 27, 2026 05:11
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.

codegraph_explore tells a fresh subagent that source was "already sent" to a different context

1 participant