Meta-harness that runs Claude Code and Codex CLI with live trace sync.
Project description
tandem
A meta-harness that runs Claude Code and OpenAI Codex CLI as one
paired session. Work in either agent and switch between them at any time
without losing context: while one harness is active, the other is a
shadow whose native session file is kept continuously up to date by
translating the active harness's transcript entries into the shadow's own
format. Switching is instant — the shadow was always resume-ready via that
tool's native --resume/resume mechanism.
$ tandem # fresh paired claude+codex session; enters claude
$ tandem --active codex # fresh session with codex active instead
tandem (claude)> switch # after exiting claude: continue instantly in codex
tandem (codex)> exit
to continue this session: tandem resume a1b2c3d4e5f6
$ tandem resume # continue the most recent session here
$ tandem resume <id> # continue a specific session (id from the exit hint,
# run from that session's own directory)
$ tandem run --on codex "second opinion: why is this test flaky?"
$ tandem status # pairing, roles, sync position
$ tandem switch # one-shot flip; does not enter a harness
$ tandem doctor [--live] # verify both sessions are resumable
$ tandem sync # manual catch-up translation (local file I/O only)
$ tandem sync-mcp # opt-in: copy MCP server configs between the tools
Install
uv tool install tandem-cli # or: pip install tandem-cli
Requires Python 3.11+ and the claude and codex CLIs on your PATH.
How it works
- Per-command single-model invariant. Only one model is ever invoked per
user command — the active harness's (or, for
run --on, the target's). The shadow side is pure local file I/O: tandem tails the active transcript, translates each entry, and appends to the shadow's session file. The shadow's model is never called to "catch up". - A persistent prompt, not the OS shell. Leaving the harness lands you
at
tandem (claude)>instead of back in your shell. There,switchflips roles and drops you straight into the other tool (no re-launch needed), Enter orresumere-enters the current one, andstatus/sync/doctor/run --on …/sync-mcprun the same implementations as their one-shot forms, always against this session.exit(or Ctrl-D) returns to your shell and printsto continue this session: tandem resume <id>— pass that id totandem resume <id>from the session's own directory. Every one-shot command also works from the OS shell, targeting the directory's most recently used session; one-shottandem switchonly flips, it does not enter a harness. - PTY passthrough.
tandemlaunches the real CLI on a pty (raw mode, SIGWINCH resize forwarding, signals through the line discipline). Tandem never scrapes terminal output; the transcript files are the source of truth. Turn-complete hooks (claude --settingsStop hook,codex -c notify=[...]) are wired per-invocation as wake-up signals, with fs-watching (watchdog, polling fallback) as the data path and fallback. If the user's codex config already setsnotify, tandem leaves it alone. - Append-only sync. Each new transcript entry is translated as it lands (no bulk re-export at switch time). Appends are whole-line + fsync, and a write-ahead intent in the sync cursor makes translation exactly-once across crashes. On restart, sync resumes from the last confirmed entry.
- Tool calls are translated natively. The harnesses have different tool
vocabularies (Read/Edit/Bash vs exec_command/apply_patch), so each
completed call+result pair is re-expressed in the shadow's own vocabulary
—
Bash↔exec_command,Edit/Write↔apply_patch,TodoWrite↔update_plan— and lands as a real tool-call record, so shadow history reads as the shadow's own work. Anything that would not map truthfully (and every unrecognized tool) passes through verbatim with its name and arguments intact. A call whose result never arrived is closed with a(tool result not recorded)placeholder when the source is handed off, since both replay APIs reject a dangling call. - Attribution. Every synced text message is tagged
[via claude-code]/[via codex](tandem's own notes use[tandem]), so interleaved histories stay legible to both the user and the models. Tool activity is untagged — it is mirrored as native records, not prose. - Error localization. An entry that fails translation becomes a
placeholder in the shadow —
[tandem: turn N could not be translated from <tool> — <reason>; raw entry quarantined at ~/.tandem/quarantine/...]— at most one per turn, and sync continues. The shadow is never corrupted or truncated. - Memory files. A fresh
tandemlaunch and every switch sync CLAUDE.md ↔ AGENTS.md: shared content lives in a<!-- tandem:shared:begin/end -->block (newer file wins), tool-specific text outside the block is preserved, and a file without markers is read from but never rewritten. Git state is never touched.
Compatibility
Session formats are internal to the CLIs and drift between releases. Tandem pins what it was built against (observed formats documented in docs/formats.md):
| CLI | tested | accepted range |
|---|---|---|
| Claude Code | 2.1.220 | ≥ 2.0, < 3 |
| Codex CLI | 0.145.0 | ≥ 0.140, < 0.150 |
Outside the range tandem warns and asks you to run tandem doctor
(--live performs a real one-word resume per harness — two small model
calls). Format knowledge is isolated per tool in
src/tandem/harness/claude_code.py and src/tandem/harness/codex.py.
Where things live
~/.tandem/state.db— SQLite: session pairing + per-source sync cursors (override the directory withTANDEM_HOME)~/.tandem/quarantine/<session>/— raw entries that failed translation~/.claude/projects/<munged-cwd>/<session-id>.jsonl— claude transcript~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<session-id>.jsonl— codex rollout (CLAUDE_CONFIG_DIR/CODEX_HOMEhonored)
Claude session ids are minted by tandem (claude --session-id); codex mints
its own on first run and tandem captures it from the new rollout file.
Swapping in a different converter
The sync engine talks to a small adapter interface
(tandem.converter.TraceConverter):
class TraceConverter(Protocol):
def translate_entry(entry, direction, ctx) -> list[TargetEntry] | TranslationError
ReferenceConverter implements it via a normalized event model
(tandem/events.py) derived from the observed formats. Pass your own
converter to SyncEngine(store, session, source, converter=...).
Install & development
Requires Python 3.11+ and both CLIs on PATH. No network calls of tandem's own; all model calls happen inside the wrapped CLIs under your existing auth.
pipx install . # or: uv tool install .
uv sync && uv run pytest # development
Dependencies (deliberately small): click (CLI), pydantic v2 (event
schema), watchdog (transcript tailing), pexpect/ptyprocess (PTY
passthrough); state is stdlib sqlite3.
v1 non-goals
One active harness at a time (no concurrent dual-active). No other agents yet (the adapter interface is generic). No cloud sync or telemetry. Compaction state is not translated — on switch, the newly active tool's own compaction handles an over-long context.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file tandem_cli-0.1.0.tar.gz.
File metadata
- Download URL: tandem_cli-0.1.0.tar.gz
- Upload date:
- Size: 146.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.14 {"installer":{"name":"uv","version":"0.11.14","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a032405c29d5e41be4d220c70d9c3f1d762b0e161b881a0e9f364c94500aaf57
|
|
| MD5 |
1e27d1553a61d3eec4f8a157ea32ea27
|
|
| BLAKE2b-256 |
729e03781b0dc2261bd83de51e4401ebcc1628c7bfaae4195e43c31843469361
|
File details
Details for the file tandem_cli-0.1.0-py3-none-any.whl.
File metadata
- Download URL: tandem_cli-0.1.0-py3-none-any.whl
- Upload date:
- Size: 55.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.14 {"installer":{"name":"uv","version":"0.11.14","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
cf1a229e7afd2d8968d3697025adc5228a29309944b1bf2022fb18a4f98246ad
|
|
| MD5 |
0527d75a316ed795953d33d2b27e7538
|
|
| BLAKE2b-256 |
07859146e2bc3e750d5ee2a8904f80e274736970f10bfd0f1eb01bc5da87a307
|