Skip to main content
Glama

tandem

Each Codex session gets its own dedicated Claude Code session, running in the cmux terminal app. Codex launches that session and drives it directly through a small MCP server (the bridge) over the cmux socket.

Origin: extracted from sporedrive.

Architecture

Codex session --MCP stdio--> tandem (bridge/cmux_bridge) --cmux socket--> Claude Code in a cmux surface
  • The bridge wraps the cmux CLI through argument arrays only: no shell, no generic RPC, no pane deletion, no process cleanup.

  • Sessions are identified by UUID (workspace, surface, Claude session id, pid), never by title or focus.

  • Delivery is accepted only when the submitted text is found in Claude's own transcript (transcript-hash acceptance). Editor staging, a send "OK" or a hook event alone prove nothing. An unconfirmed submit is uncertain and is reconciled under the same request_id, never resent blindly.

  • skill/cmux-driver/ is the Codex skill that holds the driver protocol; skill/cmux-driver/scripts/executor_session.py launches and verifies the dedicated Claude session. From any worktree, call it through the install symlink: python3 "${CODEX_HOME:-$HOME/.codex}/skills/cmux-driver/scripts/executor_session.py".

Related MCP server: Rail Connector MCP

Tools

Tool

Purpose

bridge_discover

Diagnose cmux socket access; list Claude Code sessions visible in cmux

bridge_bind

Bind a controller to one session (writer or monitor), take a lease, run the model gate

bridge_observe

Screen state, context use and events after a sequence number

bridge_submit

Deliver a message; keyed by request_id, replays instead of resending

bridge_wait

Wait for accepted / turn_complete / idle (default 25 s; loop on non-terminal results)

bridge_compact

Compact the session with a checkpoint

bridge_pause

Pause a binding (owner only, reason required)

bridge_resume

Resume a paused binding (owner only)

bridge_release

Drop the leases; never closes the surface or process

Identity and one writer

One Claude per Codex session, enforced: the Codex session's identity is its controller_id, stable for that Codex session. A new writer bind by the same controller_id supersedes its other live writer bindings (they are released, leases dropped; their ids come back as superseded_bindings). Two Codex sessions must never share a controller_id. A worktree has one writer at a time, enforced by leases; a writer bind by a different controller is refused with lease_conflict. Every binding tool (bridge_observe, bridge_submit, bridge_wait, bridge_compact, bridge_pause, bridge_resume, bridge_release) takes controller_id and refuses not_owner unless it matches the one the binding was bound with.

Choose controller_id once per Codex session: the Codex thread/session id if the environment exposes one, else a uuid generated once and kept in your notes. Reuse it on every bridge call. It must be a non-empty, non-whitespace string of at most 200 chars (bad_request otherwise). Worktree identity is the case-insensitive-canonical on-disk path (macOS F_GETPATH), so differently-cased spellings of one directory share one writer lease.

Retention of prompt text (PHI minimisation). While a request is in flight its record under requests/<binding_id>/<request_id>.json holds the delivered text (inline) and a 120-char first_line; a multi-line/long payload also lives in an 0444 file tasks/<binding_id>/<request_id>.txt. Task files are written only after every refusal gate passes (a refused submit writes nothing). Once a request is terminal (accepted, completed, uncertain_foreign) its record keeps only text_sha256/delivered_text_sha256 plus length/line counts (inline delivered_text and first_line are nulled; a task-file record keeps just the Task brief: <path> reference). bridge_release deletes tasks/<binding_id>/ unless a non-terminal request is still in flight on the binding. The in-memory CLI call log keeps only the last 500 calls and redacts send text as <redacted N chars>. Receipts under receipts/ never contain prompt text. Task files of a never-released binding remain until it is released.

Leases

Default TTL 1800 s (lease_ttl_s on bridge_bind, clamped 60..86400). No tool renews a lease. When it lapses, calls refuse with lease_expired; fix it by rebinding the SAME Claude session with the SAME controller_id (do not launch a new one). Distinguish lease_conflict (another controller holds the writer lease at bind time) and lease_lost (this binding was superseded or its lease no longer held).

Pause

bridge_pause(binding_id, controller_id, reason) marks the binding paused. While paused, bridge_submit and bridge_compact refuse with paused; bridge_observe and bridge_wait keep working and observe reports paused. Immediately before every Enter (submit, reconcile of staged text, compact) the bridge re-checks the writer lease and the pause flag under one state-lock hold. A pause landing after staging raises paused, marks the request uncertain, and leaves the payload staged with Enter NOT pressed. For submit: bridge_resume, then call bridge_submit again with the SAME request_id to reconcile. For compact: bridge_resume, clear the staged /compact from the prompt box, call bridge_compact with a NEW request_id (compact is not re-Entered). A rebind on the same surface carries the pause to the new binding, even after release or lease expiry. bridge_release does not clear a pause; it makes the binding unusable. bridge_resume clears it. Residual race: the check and the keystroke are not atomic, so a pause or release landing in that last gap is not caught.

Model gate

Implementation sessions must run on an explicitly chosen, verified model. Configure through the environment of the bridge (and of executor_session.py):

Variable

Meaning

Default

TANDEM_PLANNING_ONLY_MODELS

Comma-separated families (fable, opus, sonnet, haiku) refused for implementation

fable

TANDEM_ALLOW_UNKNOWN_MODEL

1/true/yes/on admits a readable footer naming no known family; expected must then match as text

off

A malformed value fails closed with model_policy_config. Other refusals: model_policy / planning_session (planning-only family), model_required (planning purpose without a model), model_unknown, model_mismatch, model_unverified, model_changed. Full list: skill/cmux-driver/references/bridge-mcp.md.

executor_session.py launch defaults to --focus false, but cmux creates an unfocused terminal with no view (its command would not start), so launch selects the new workspace briefly (~1s) to create the view, then restores your previous selection unless you already switched elsewhere. The receipt records view_realized and focus_restored; if no view appears, launch exits 4 with error_code: no_terminal_view and types nothing. The bridge refuses a viewless surface with no_terminal_view (bind/submit) or reports it per observe; it is not a transport fault and never triggers a resend.

Prompt suggestions. After each turn Claude Code renders a predicted next prompt as dim ghost text in the input editor; plain-text read-screen cannot tell it from typed text, so the screen would classify as staged and submit would refuse busy. executor_session.py launch therefore passes --env CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false to new-workspace (the receipt records prompt_suggestions: "disabled"; the command and argv, hence the pid identity checks, are unchanged). bridge_bind reads that one variable from the Claude process environment (nothing else is kept) and records prompt_suggestions: "disabled"|"enabled_or_unknown" on the binding. Binding a hand-started session requires suggestions off: start it with CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude ... or set promptSuggestionEnabled: false in settings (a settings-only opt-out is not visible to the bridge, so the binding stays enabled_or_unknown and the hint remains). When a binding is not disabled and the editor shows staged with no request of ours in flight, observe and the submit busy refusal carry an instruction naming this. The bridge never clears the editor.

Other environment variables: CMUX_BRIDGE_STATE_DIR, CMUX_BRIDGE_SOCKET, CMUX_BRIDGE_CLI, CMUX_BRIDGE_PASSWORD_FILE, CMUX_BRIDGE_CLAUDE_PROJECTS.

Permissions: bypass by default

executor_session.py launch starts Claude with --dangerously-skip-permissions by default. The executor can run any command and edit any file the user can, without prompting. Launch only in worktrees you are willing to have modified, and pass --permission-mode <mode> to use a stricter posture instead.

State directory is sensitive

State lives in ~/.local/state/tandem (override CMUX_BRIDGE_STATE_DIR), mode 0700: leases, bindings, requests, receipts, locks, tasks, plus launch receipts. Request records contain the full text sent to Claude. Treat it like a transcript; do not commit or share it.

Install

Prerequisites: macOS, cmux, the claude and codex CLIs, uv, Python 3.11+.

  1. ./install.sh links skill/cmux-driver into ${CODEX_HOME:-~/.codex}/skills/ and prints the codex mcp add tandem ... command. Run that command yourself. ./install.sh --uninstall removes the symlink.

  2. Make sure the cmux socket is reachable (see docs/installation.md).

  3. Restart Codex; codex mcp list should show tandem.

Details: docs/installation.md, docs/operations.md.

Testing

  • Offline simulation suite (no cmux needed): uv run --python 3.13 --with pytest python -m pytest bridge/tests -q -p no:cacheprovider

  • Live acceptance (bridge/run_live.sh) drives a real cmux session. Operator-run only, never CI; it requires a clean worktree and a passing offline suite first. Its default interpreter is uv run --quiet --project "$REPO_ROOT" --extra test python; override with PY.

Optional AGENTS.md snippet for Codex

## Driving a Claude Code session

- To supervise or delegate to Claude, use the `cmux-driver` skill; Claude is the sole writer in that worktree.
- Open the executor with `python3 "${CODEX_HOME:-$HOME/.codex}/skills/cmux-driver/scripts/executor_session.py" launch --cwd <worktree>` and an explicit `--model`; reuse a session only after `verify` passes.
- Bind by exact UUIDs, with a stable `controller_id` for this Codex session (never shared with another session).
- Prove delivery by transcript acceptance; keep an `uncertain` request under the same `request_id` and reconcile, never resend.
- Never send competing tasks or compact during conflicting work.

Related MCP Connectors

Related MCP Servers