context-bridge Architecture
August 4, 2026 · View on GitHub
Switch agents. Not context.
How the bridge works inside. For usage see the README; for working on it see DEVELOPMENT.md.
Design principles
- Native sessions are preserved, never replaced. Claude keeps its own session, Codex its own thread, Grok and Antigravity their own session directories. The bridge orchestrates them and re-implements none of them. Delete the bridge and all four still work with their own CLIs.
- Official mechanisms wherever one exists. Import, resume, hooks, plugins and skills are all vendor-supported surfaces. The bridge adds only what no vendor ships: the mapping between sessions, the way back, and switching repeatedly without starting over.
- Deltas, not transcript copies. Every agent already holds its own history. A switch carries only what the target has not seen.
- No API keys, no extra billing. Every CLI runs under the subscription the user already has. The bridge never reads or stores credentials.
- Nothing important is silent. A dropped input, a deletion, a delta that never arrived, a parser that no longer understands a file: each of these is reported. Most of this document's odder decisions come from this one rule.
System overview
shell
└── bridge launcher, zero-dependency Node CLI
└── exactly one agent at a time
claude ⇄ codex ⇄ grok ⇄ antigravity ⇄ opencode twenty directed routes
~/.claude/projects/… ~/.codex/sessions/… ~/.grok/sessions/…
native session native thread native session
~/.gemini/antigravity-cli/brain/… ~/.local/share/opencode/opencode.db
native conversation native session, in a database
.bridge/state.json ← what links them, references only
.bridge/config.json ← per-agent launch flags for this project
| Component | Role |
|---|---|
bridge CLI (src/) | launcher loop, state, delta engine, doctor, hook endpoints |
src/agents/ | one adapter per agent; the only place vendor knowledge lives |
Claude plugin (plugin/) | /bridge skill and the SessionStart / Stop / UserPromptSubmit hooks |
Codex hooks (~/.codex/hooks.json) | the same three events, installed by doctor --fix, merged into whatever is already there |
Shared skill (codex/SKILL.md) | $bridge <agent> for Codex, Grok, Antigravity and OpenCode |
.bridge/state.json | project-local links, watermarks, pending markers. References, never content |
The adapter contract
Adding an agent used to mean touching a dozen files. It is now one module in
src/agents/ implementing a narrow contract, and the registry is what every
other part of the system iterates over: the doctor's route table, checkpoint
pruning, the delta engine. A pruning rule that hard-coded one pair once meant
Grok's checkpoints were never cleaned up at all, which is why nothing enumerates
agents by hand any more.
The contract covers exactly the parts that are genuinely per-vendor: discovery, rehydrating a reference, the resume and start commands, parsing activity since a watermark, the idle signal, flags that would break the session link, health, a harmless headless probe, and the two parser canaries below.
Three of its methods are optional, present only where an agent needs them.
kickoffArgs returns a one-line opening prompt for a hook-injecting agent, whose
hook delivers context but not a turn (see Delivery below). preResume returns a
command the launcher runs before the interactive session opens, for an agent that
takes its delta neither on the command line nor through a hook: OpenCode keeps its
sessions in a SQLite database and the delta is written straight in, authless,
because the alternative was a paid, authenticated model call. An adapter that does
not implement one of these is simply never asked for it.
Watermarks are opaque
The two-agent design assumed time was universal. Claude and Codex timestamp
their records, so their watermark is an ISO instant. Grok's chat rows carry no
timestamps at all, so its watermark is a compound {rows, ts} counting rows in
the chat file and the newest event timestamp beside it. Using time there would
have silently resent the whole conversation on every switch.
So a watermark is whatever the adapter says it is. Callers persist it and hand it back untouched, never compare one agent's to another's, and never look inside.
What crosses, and what does not
A delta is a bounded plain-text block with four sections:
[Bridge Context Update]
Conversation what was said, from the native session files
Decisions what was decided, and what was rejected and why
Work files touched, commits, diffstat, from git
Next the current objective and what is still open
Conversation and Work are deterministic: session records newer than the
watermark, plus git status --porcelain and log/diff --stat since the
recorded checkpoint. No summarisation call is added anywhere.
Decisions and Next come from the departing agent, written in the same turn the user triggered the switch. They carry intent, which neither files nor git can show.
Tool calls and their output never cross. They are enormous, shaped differently by every vendor, and not replayable in another agent. On this repository a raw Claude session file is 14.9MB of which 285KB is conversation: the tool output is the noise. The receiving agent debugs against the repository itself rather than against a stale account of somebody else's run. The honest cost is that a failure which lived only in tool output, and which nobody wrote down, does not travel.
Each bounded delta is capped, and the middle is cut rather than the ends, so the beginning and the latest exchange both survive. Beside it, every handoff also writes an un-truncated companion holding every message verbatim. That exists because a size cap once clipped long prose in the middle of drafting it.
knownBy: why chains keep their history
The naive model is a single sync timestamp per agent, and it loses material as soon as there are three agents: hand from Claude to Grok to Codex, and Codex receives only what Grok said.
State therefore holds knownBy[target][source]: for each pair, how far into the
source's own stream material has been packed for that target. A handoff gathers
from every agent whose watermark for the target is behind, labels each block by
who produced it, and commits the new watermarks only once the delta is actually
delivered. Committing at write time would mark the departing agent's final
answer as delivered before it had been written.
This ledger is also the difference from tools that copy a session on every switch. Copying needs no such bookkeeping because it starts over each time.
Delivery: three roads, chosen in advance
A delta reaches its target one of three ways.
Hook. Claude and Codex both accept hookSpecificOutput.additionalContext
from a SessionStart hook, which places the delta inside the conversation. The
hook renames the delta file to *.consumed before emitting, which is what makes
handing it over happen exactly once even across a crash or a race: whoever
renames the file owns it. Handed over is as far as this goes; whether the model
then attends to it is not something any of this can observe.
A hook delivers context, not a turn. The delta lands as background, and the
agent has nothing to answer, so it sits idle until a human types. kickoffArgs
is the other half: a one-line prompt appended to the resume command that opens
the turn, carrying no delta so the handoff cannot land twice. It fires whenever
the delivery went by hook, whether that is the agent's permanent road (Claude)
or its road for this handoff (Codex, once its hooks are trusted).
Prompt. The delta rides as the opening message of the resumed session. This works everywhere and shapes the session around the delivery. Grok uses it permanently: its hooks fire but their output is ignored for passive events.
Store. OpenCode has neither road: no usable hook, and a resume command that
cannot be handed an opening message. It keeps each session in a local SQLite
database, so preResume writes the delta straight into that database as one
idempotent, transactional insert, authless and with no model call. The write IS
the delivery, so the launcher commits it on the insert's success rather than
watching for later activity, and leaves it pending on failure so the next launch
retries. The context is present when the TUI opens; the turn, uniquely among the
five, is the person's to open, because OpenCode exposes no seam to open it for
them.
The road has to be chosen before the agent starts, because nothing can be
injected into a session already running, and whether a hook will fire cannot be
known: Codex runs hooks only after the user reviews them once with /hooks,
that trust can be withdrawn silently, and neither state is readable from
outside. So pendingInjection.via records the choice at handoff time, and
exactly one deliverer honours it, which is what makes delivering twice
impossible rather than merely unlikely.
When the choice turns out wrong the launcher says so after the agent exits,
names the file the delta is still sitting in, and points at /hooks. Nothing is
resent automatically; the next handoff supersedes that delta anyway. A delayed
delta is a cost worth paying, a silent one is not.
The first switch is different, for everyone
The target has no session yet, so something must be created. Claude → Codex uses
OpenAI's official transfer (codex-plugin-cc, externalAgentConfig/import
underneath), which seeds a real thread with the whole conversation; the returned
thread id is captured programmatically. Every other first switch opens a new
session whose opening prompt is the conversation.
Re-running that import for a changed transcript creates a brand-new thread every time, because the import ledger is append-only. So it runs once per project, the pair is persisted, and from then on the same session is resumed with a delta.
Project state
.bridge/state.json, versioned and written atomically, migrated forward with a
.v<n>.backup kept and a refusal to read anything newer than this build
understands. References only:
{
"version": 5,
"project": "<absolute path>",
"activeLane": "main",
"lanes": {
"main": {
"activeAgent": "claude",
"agents": {
"claude": { "id": "…", "transcriptPath": "…", "mark": "2026-07-21T…", "idle": false },
"codex": { "id": "…", "transcriptPath": "…", "mark": "2026-07-21T…", "idle": false, "hookSeen": "…" },
"grok": { "id": "…", "transcriptPath": "…", "mark": { "rows": 262, "ts": "…" }, "idle": false }
},
"knownBy": { "grok": { "claude": "…", "codex": "…" } },
"pendingHandoff": { "target": "codex", "ready": true, "requestedAt": "…" },
"pendingInjection": { "agent": "codex", "via": "hook", "deltaFile": "…", "sources": {} },
"git": { "sha": "…", "recordedAt": "…" }
}
},
"launchers": { "71272": { "pid": 71272, "lane": "main", "stateVersion": 5, "recordedAt": "…" } }
}
Everything one line of work owns — its agent links, watermarks, pending markers
and git snapshot — lives under lanes[<name>], and activeLane names the one in
force. Readers never index lanes directly: state loaded for a lane exposes that
lane's fields at the top level (s.agents, s.pendingHandoff, …) through an
active-lane view, so the whole codebase reads as if there were one lane and a
switch is a single pointer move. A fresh install is one lane called main; a
project that never opens a second one never notices the layer.
Two writers are serialised by two locked primitives. mutateState read-modify-
writes exactly one lane under an exclusive lock, and refuses to resurrect a lane
an existing project has removed (a delayed hook drops its write instead of
recreating the lane empty). mutateProject writes the whole file, for the
lane create / switch / remove commands that are about the set of lanes rather
than the work inside one.
Transcripts are deliberately not duplicated here. The native files already are
the transcripts; copying them would double the on-disk footprint of sensitive
conversation, and references plus watermarks are enough to compute every delta.
.bridge/ is added to the project's .gitignore automatically.
launchers records each live launcher by pid and the lane it opened. It exists
because a launcher started before an upgrade cannot read a newer state file — it
says so and asks to be restarted rather than waiting for a switch that can never
come — and because knowing which lane a launcher holds lets lane rm and
unlink refuse only when a launcher is live on the lane they touch, not on any.
Checkpoints are delivery artifacts
Checkpoint files are packages in transit, not memory. The canonical record is
each agent's native transcript plus knownBy.
So retention follows the delivery lifecycle rather than a clock: an un-truncated companion is dropped once its reader hands off. That is an event, not a proof. It means the agent had a live session in which the companion was available to it, which is the strongest thing anything here can observe; nobody watches whether a file was opened. A small newest-N cap backstops a target that never hands off again. Bounded deltas are kept longer for auditing, and a pending injection is never deleted under any flag. Re-issuing a handoff supersedes the previous undelivered one instead of leaving it on disk forever.
Session linking
Claude records itself through its SessionStart hook. Codex does the same now:
its hook input carries session_id and transcript_path, so linking is a fact
it tells us rather than something inferred from the newest file on disk.
For an agent started without hooks, the launcher links the session it started
itself, through adoptStartedSession on the adapter. Grok publishes a live
registry of open sessions at ~/.grok/active_sessions.json keyed by pid and
cwd, which identifies our own child exactly. That registry empties the moment a
session closes, so linking runs while the child is alive and once more after
it exits; post-exit alone would strand every session whose terminal was killed.
When several candidates match, none is adopted and the bridge says so. A user with a second session of the same agent open in another terminal must never have it taken.
A hook only records what it was installed for. Each hook command declares its
agent (internal-hook session-start --agent codex) and refuses when the
environment says it woke up somewhere else, which matters because Grok loads
Claude's own ~/.claude/settings.json hooks by default.
There is exactly one such marker today: GROK_HOOK_EVENT, which Grok's hook
runner injects into every hook process. Codex has no equivalent, so nothing
detects it, and that gap is left open rather than filled. Two earlier attempts
went wrong in opposite directions: refusing on CODEX_THREAD_ID, which is
ambient session environment inherited by every child, made Claude's own hook
refuse itself, and a CODEX_HOOK_EVENT was then added that does not exist
anywhere in the shipped binary. Detection stays negative and fails towards
working: demanding positive proof of identity would disable the bridge the day a
vendor renames a variable.
Launcher
The process tree stays flat:
shell
└── bridge
└── claude (exits) →
└── codex (exits) →
└── grok …
Never nested. Each agent is a direct child spawned with stdio: "inherit", so
the TUI gets the real terminal. Nested TUIs would stack raw-mode terminals,
break Ctrl+C, and leak processes.
The parent ignores SIGINT, because Ctrl+C typed inside an agent belongs to
that agent. A missing binary produces a doctor hint; an unexpected exit
preserves state and explains how to continue.
Switching without cutting a turn in half
The current agent closes itself after a handoff, which is a guarded termination.
SIGTERM goes out only when a handoff is persisted, the agent is idle, and
idleness survived a debounce and a final re-read of state.
Idleness is either something an agent says or something we infer. Claude and
Codex both report the end of a turn through their Stop hook, which is cheaper
and truer than inference: it arrives when the turn ends rather than when the
file is next flushed, and it does not depend on a field name a vendor may
rename. The marker is read first, and re-reading the transcript remains the
fallback, because hooks do not run until they are trusted and a launcher
listening only for a marker would wait forever.
Hard rules: the launcher signals only the exact child pid it spawned, never by
process name, and never SIGKILL, because a clean shutdown and a flushed
session file both depend on SIGTERM. If idleness cannot be confirmed within a
generous window it prints a fallback and does nothing destructive.
Two canaries
Both exist because of failures that produce no error at all.
Session readability. Installed and logged in says nothing about whether the bridge can still read what an agent writes, and for a while the doctor's routes claimed readiness on that basis alone. Session formats are internal to each vendor: a renamed field ships in a point release and every handoff quietly returns an empty delta. So each adapter runs its own parse path over the linked session, which cost 98ms measured across the three that existed when it was added. An empty session is readable, a fresh project is neutral and never red, and rows that parse into nothing recognisable are the drift signal.
Discovery. Finding a session and reading one are different code, and the second kind of failure is just as quiet. A rollout head record was parsed into a fixed 16KB buffer while codex-cli embeds its base instructions there and the record grew to 22KB; every parse failed, no rollout matched any project, and Codex discovery returned null for every session on the machine, silently, because a failed parse looks exactly like "a different project". Each adapter now reports whether its discovery reader can still name what is stored on disk.
An unreadable session takes its routes off green and the exit code with it.
Per-agent launch flags
Arming an agent is a moment, not a preference: you work with approvals on, and
then decide, now, that this agent should stop asking. So flags are typed on the
launcher command line and apply to that launch, --cb-save-args promotes them
into .bridge/config.json, and --cb-clear-args takes it back. Nobody edits
the file by hand.
Saved defaults come first and typed flags come last, which relies on a CLI
taking the last occurrence of a repeated flag; that holds for all five agents
and is convention rather than law. A flag that would break the session link is
refused when it is saved, so the complaint reaches whoever wrote it. Flags that
change what an agent may do without asking are announced on a plain line at
every launch, and bridge status lists what is armed, because a saved bypass
nobody can find is one nobody can undo.
bridge doctor
Every assumption is checked rather than assumed, and the wording is deliberate.
Routes say CONFIGURED, meaning installed, configured, and its session still
parses; they used to say READY, which read as proof that a switch would work.
--deep asks each agent a real one-line question and reports LIVE or
BROKEN, and it is not the default because it is slow and depends on the
network.
--fix bootstraps missing pieces using only official mechanisms and asks before
every change. It can install the Codex hooks, and then says plainly that Codex
will not run them until they are reviewed once with /hooks, because that trust
is not readable and claiming otherwise would be a green tick over an unknown.
Security and privacy
- Local only: no SaaS, no accounts, no telemetry, no server.
- No API keys read, requested or stored. Auth checks test for existence and never print secret values.
- State holds references, timestamps and bounded delta files. Transcripts stay where the vendors put them.
- Deltas travel only inside the CLIs' own subscription-authenticated calls.
- Global CLI configuration is never mutated without confirmation.
Known limits
- Verified on macOS. The suite runs on Linux in CI, but the vendor directory layouts there are unverified. Windows is unsupported.
- One linked session per agent per lane.
bridge unlink <agent>forgets just that one; deleting.bridge/still relinks everything at once and takes the saved launch flags with it, but is no longer needed to relink a single agent. - Grok cannot receive a delta through a hook, and that is a limit in Grok.
- Codex stores sessions by date rather than by project, so its discovery check answers for the machine rather than for one project.
- Every vendor session format is internal. The parsers are defensive and the canaries shout when they stop matching, but a CLI release can still require an update here.