Metadata-Version: 2.4
Name: tandem-cli
Version: 0.1.0
Summary: Meta-harness that runs Claude Code and Codex CLI with live trace sync.
Project-URL: Homepage, https://github.com/Bhavya6187/tandem
Project-URL: Repository, https://github.com/Bhavya6187/tandem
Project-URL: Issues, https://github.com/Bhavya6187/tandem/issues
Author-email: Bhavya Agarwal <bhavya.6187@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: agents,claude-code,cli,codex,session-sync
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Operating System :: POSIX
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Python: >=3.11
Requires-Dist: click>=8.1
Requires-Dist: pexpect>=4.9
Requires-Dist: pydantic>=2.7
Requires-Dist: watchdog>=4.0
Description-Content-Type: text/markdown

# 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, `switch`
  flips roles and drops you straight into the other tool (no re-launch
  needed), Enter or `resume` re-enters the current one, and
  `status`/`sync`/`doctor`/`run --on …`/`sync-mcp` run the same
  implementations as their one-shot forms, always against this session.
  `exit` (or Ctrl-D) returns to your shell and prints
  `to continue this session: tandem resume <id>` — pass that id to
  `tandem 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-shot `tandem switch` only flips, it does not
  enter a harness.
- **PTY passthrough.** `tandem` launches 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 --settings` Stop 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 sets `notify`, 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 `tandem` launch 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](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 with `TANDEM_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_HOME` honored)

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`):

```python
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.

```bash
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.
