tandem-cli 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tandem_cli-0.1.0/.gitignore +7 -0
- tandem_cli-0.1.0/.superpowers/sdd/.gitignore +1 -0
- tandem_cli-0.1.0/LICENSE +21 -0
- tandem_cli-0.1.0/PKG-INFO +179 -0
- tandem_cli-0.1.0/README.md +153 -0
- tandem_cli-0.1.0/docs/formats.md +102 -0
- tandem_cli-0.1.0/docs/plans/2026-07-29-native-tool-call-translation.md +1593 -0
- tandem_cli-0.1.0/docs/specs/2026-07-29-native-tool-call-translation-design.md +265 -0
- tandem_cli-0.1.0/docs/superpowers/plans/2026-07-29-tandem-shell.md +950 -0
- tandem_cli-0.1.0/docs/superpowers/specs/2026-07-29-tandem-shell-design.md +122 -0
- tandem_cli-0.1.0/pyproject.toml +46 -0
- tandem_cli-0.1.0/src/tandem/__init__.py +1 -0
- tandem_cli-0.1.0/src/tandem/cli.py +358 -0
- tandem_cli-0.1.0/src/tandem/compat.py +62 -0
- tandem_cli-0.1.0/src/tandem/constants.py +23 -0
- tandem_cli-0.1.0/src/tandem/converter.py +140 -0
- tandem_cli-0.1.0/src/tandem/doctor.py +265 -0
- tandem_cli-0.1.0/src/tandem/events.py +107 -0
- tandem_cli-0.1.0/src/tandem/harness/__init__.py +16 -0
- tandem_cli-0.1.0/src/tandem/harness/base.py +87 -0
- tandem_cli-0.1.0/src/tandem/harness/claude_code.py +338 -0
- tandem_cli-0.1.0/src/tandem/harness/codex.py +340 -0
- tandem_cli-0.1.0/src/tandem/memory_sync.py +220 -0
- tandem_cli-0.1.0/src/tandem/ops.py +207 -0
- tandem_cli-0.1.0/src/tandem/paths.py +92 -0
- tandem_cli-0.1.0/src/tandem/ptyrun.py +99 -0
- tandem_cli-0.1.0/src/tandem/runner.py +237 -0
- tandem_cli-0.1.0/src/tandem/shell.py +207 -0
- tandem_cli-0.1.0/src/tandem/state.py +227 -0
- tandem_cli-0.1.0/src/tandem/summarize.py +37 -0
- tandem_cli-0.1.0/src/tandem/sync.py +221 -0
- tandem_cli-0.1.0/src/tandem/tailer.py +141 -0
- tandem_cli-0.1.0/src/tandem/toolmap.py +307 -0
- tandem_cli-0.1.0/src/tandem/util.py +88 -0
- tandem_cli-0.1.0/tests/conftest.py +131 -0
- tandem_cli-0.1.0/tests/golden/claude-probe.jsonl +17 -0
- tandem_cli-0.1.0/tests/golden/codex-probe.jsonl +34 -0
- tandem_cli-0.1.0/tests/test_cli.py +170 -0
- tandem_cli-0.1.0/tests/test_converter.py +175 -0
- tandem_cli-0.1.0/tests/test_memory_doctor.py +144 -0
- tandem_cli-0.1.0/tests/test_ops.py +286 -0
- tandem_cli-0.1.0/tests/test_parse.py +190 -0
- tandem_cli-0.1.0/tests/test_paths_compat.py +49 -0
- tandem_cli-0.1.0/tests/test_ptyrun.py +14 -0
- tandem_cli-0.1.0/tests/test_shell.py +402 -0
- tandem_cli-0.1.0/tests/test_state.py +147 -0
- tandem_cli-0.1.0/tests/test_sync.py +189 -0
- tandem_cli-0.1.0/tests/test_tail.py +150 -0
- tandem_cli-0.1.0/tests/test_toolmap.py +605 -0
- tandem_cli-0.1.0/uv.lock +298 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
*
|
tandem_cli-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bhavya Agarwal
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tandem-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Meta-harness that runs Claude Code and Codex CLI with live trace sync.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Bhavya6187/tandem
|
|
6
|
+
Project-URL: Repository, https://github.com/Bhavya6187/tandem
|
|
7
|
+
Project-URL: Issues, https://github.com/Bhavya6187/tandem/issues
|
|
8
|
+
Author-email: Bhavya Agarwal <bhavya.6187@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agents,claude-code,cli,codex,session-sync
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: POSIX
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Requires-Dist: click>=8.1
|
|
22
|
+
Requires-Dist: pexpect>=4.9
|
|
23
|
+
Requires-Dist: pydantic>=2.7
|
|
24
|
+
Requires-Dist: watchdog>=4.0
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# tandem
|
|
28
|
+
|
|
29
|
+
A meta-harness that runs **Claude Code** and **OpenAI Codex CLI** as one
|
|
30
|
+
paired session. Work in either agent and switch between them at any time
|
|
31
|
+
without losing context: while one harness is **active**, the other is a
|
|
32
|
+
**shadow** whose native session file is kept continuously up to date by
|
|
33
|
+
translating the active harness's transcript entries into the shadow's own
|
|
34
|
+
format. Switching is instant — the shadow was always resume-ready via that
|
|
35
|
+
tool's native `--resume`/`resume` mechanism.
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
$ tandem # fresh paired claude+codex session; enters claude
|
|
39
|
+
$ tandem --active codex # fresh session with codex active instead
|
|
40
|
+
tandem (claude)> switch # after exiting claude: continue instantly in codex
|
|
41
|
+
tandem (codex)> exit
|
|
42
|
+
to continue this session: tandem resume a1b2c3d4e5f6
|
|
43
|
+
$ tandem resume # continue the most recent session here
|
|
44
|
+
$ tandem resume <id> # continue a specific session (id from the exit hint,
|
|
45
|
+
# run from that session's own directory)
|
|
46
|
+
$ tandem run --on codex "second opinion: why is this test flaky?"
|
|
47
|
+
$ tandem status # pairing, roles, sync position
|
|
48
|
+
$ tandem switch # one-shot flip; does not enter a harness
|
|
49
|
+
$ tandem doctor [--live] # verify both sessions are resumable
|
|
50
|
+
$ tandem sync # manual catch-up translation (local file I/O only)
|
|
51
|
+
$ tandem sync-mcp # opt-in: copy MCP server configs between the tools
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Install
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
uv tool install tandem-cli # or: pip install tandem-cli
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Requires Python 3.11+ and the `claude` and `codex` CLIs on your PATH.
|
|
61
|
+
|
|
62
|
+
## How it works
|
|
63
|
+
|
|
64
|
+
- **Per-command single-model invariant.** Only one model is ever invoked per
|
|
65
|
+
user command — the active harness's (or, for `run --on`, the target's).
|
|
66
|
+
The shadow side is pure local file I/O: tandem tails the active
|
|
67
|
+
transcript, translates each entry, and appends to the shadow's session
|
|
68
|
+
file. The shadow's model is never called to "catch up".
|
|
69
|
+
- **A persistent prompt, not the OS shell.** Leaving the harness lands you
|
|
70
|
+
at `tandem (claude)>` instead of back in your shell. There, `switch`
|
|
71
|
+
flips roles and drops you straight into the other tool (no re-launch
|
|
72
|
+
needed), Enter or `resume` re-enters the current one, and
|
|
73
|
+
`status`/`sync`/`doctor`/`run --on …`/`sync-mcp` run the same
|
|
74
|
+
implementations as their one-shot forms, always against this session.
|
|
75
|
+
`exit` (or Ctrl-D) returns to your shell and prints
|
|
76
|
+
`to continue this session: tandem resume <id>` — pass that id to
|
|
77
|
+
`tandem resume <id>` from the session's own directory. Every one-shot
|
|
78
|
+
command also works from the OS shell, targeting the directory's most
|
|
79
|
+
recently used session; one-shot `tandem switch` only flips, it does not
|
|
80
|
+
enter a harness.
|
|
81
|
+
- **PTY passthrough.** `tandem` launches the real CLI on a pty (raw mode,
|
|
82
|
+
SIGWINCH resize forwarding, signals through the line discipline). Tandem
|
|
83
|
+
never scrapes terminal output; the transcript files are the source of
|
|
84
|
+
truth. Turn-complete hooks (`claude --settings` Stop hook, `codex -c
|
|
85
|
+
notify=[...]`) are wired per-invocation as wake-up signals, with
|
|
86
|
+
fs-watching (watchdog, polling fallback) as the data path and fallback.
|
|
87
|
+
If the user's codex config already sets `notify`, tandem leaves it alone.
|
|
88
|
+
- **Append-only sync.** Each new transcript entry is translated as it lands
|
|
89
|
+
(no bulk re-export at switch time). Appends are whole-line + fsync, and a
|
|
90
|
+
write-ahead intent in the sync cursor makes translation exactly-once
|
|
91
|
+
across crashes. On restart, sync resumes from the last confirmed entry.
|
|
92
|
+
- **Tool calls are translated natively.** The harnesses have different tool
|
|
93
|
+
vocabularies (Read/Edit/Bash vs exec_command/apply_patch), so each
|
|
94
|
+
completed call+result pair is re-expressed in the shadow's own vocabulary
|
|
95
|
+
— `Bash` ↔ `exec_command`, `Edit`/`Write` ↔ `apply_patch`, `TodoWrite` ↔
|
|
96
|
+
`update_plan` — and lands as a real tool-call record, so shadow history
|
|
97
|
+
reads as the shadow's own work. Anything that would not map truthfully
|
|
98
|
+
(and every unrecognized tool) passes through verbatim with its name and
|
|
99
|
+
arguments intact. A call whose result never arrived is closed with a
|
|
100
|
+
`(tool result not recorded)` placeholder when the source is handed off,
|
|
101
|
+
since both replay APIs reject a dangling call.
|
|
102
|
+
- **Attribution.** Every synced *text* message is tagged `[via claude-code]`
|
|
103
|
+
/ `[via codex]` (tandem's own notes use `[tandem]`), so interleaved
|
|
104
|
+
histories stay legible to both the user and the models. Tool activity is
|
|
105
|
+
untagged — it is mirrored as native records, not prose.
|
|
106
|
+
- **Error localization.** An entry that fails translation becomes a
|
|
107
|
+
placeholder in the shadow —
|
|
108
|
+
`[tandem: turn N could not be translated from <tool> — <reason>; raw
|
|
109
|
+
entry quarantined at ~/.tandem/quarantine/...]` — at most one per turn,
|
|
110
|
+
and sync continues. The shadow is never corrupted or truncated.
|
|
111
|
+
- **Memory files.** A fresh `tandem` launch and every switch sync
|
|
112
|
+
CLAUDE.md ↔ AGENTS.md: shared content lives in a
|
|
113
|
+
`<!-- tandem:shared:begin/end -->` block (newer file wins), tool-specific
|
|
114
|
+
text outside the block is preserved, and a file without markers is read
|
|
115
|
+
from but never rewritten. Git state is never touched.
|
|
116
|
+
|
|
117
|
+
## Compatibility
|
|
118
|
+
|
|
119
|
+
Session formats are internal to the CLIs and drift between releases. Tandem
|
|
120
|
+
pins what it was built against (observed formats documented in
|
|
121
|
+
[docs/formats.md](docs/formats.md)):
|
|
122
|
+
|
|
123
|
+
| CLI | tested | accepted range |
|
|
124
|
+
| --- | --- | --- |
|
|
125
|
+
| Claude Code | 2.1.220 | ≥ 2.0, < 3 |
|
|
126
|
+
| Codex CLI | 0.145.0 | ≥ 0.140, < 0.150 |
|
|
127
|
+
|
|
128
|
+
Outside the range tandem warns and asks you to run `tandem doctor`
|
|
129
|
+
(`--live` performs a real one-word resume per harness — two small model
|
|
130
|
+
calls). Format knowledge is isolated per tool in
|
|
131
|
+
`src/tandem/harness/claude_code.py` and `src/tandem/harness/codex.py`.
|
|
132
|
+
|
|
133
|
+
## Where things live
|
|
134
|
+
|
|
135
|
+
- `~/.tandem/state.db` — SQLite: session pairing + per-source sync cursors
|
|
136
|
+
(override the directory with `TANDEM_HOME`)
|
|
137
|
+
- `~/.tandem/quarantine/<session>/` — raw entries that failed translation
|
|
138
|
+
- `~/.claude/projects/<munged-cwd>/<session-id>.jsonl` — claude transcript
|
|
139
|
+
- `~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<session-id>.jsonl` — codex
|
|
140
|
+
rollout (`CLAUDE_CONFIG_DIR` / `CODEX_HOME` honored)
|
|
141
|
+
|
|
142
|
+
Claude session ids are minted by tandem (`claude --session-id`); codex mints
|
|
143
|
+
its own on first run and tandem captures it from the new rollout file.
|
|
144
|
+
|
|
145
|
+
## Swapping in a different converter
|
|
146
|
+
|
|
147
|
+
The sync engine talks to a small adapter interface
|
|
148
|
+
(`tandem.converter.TraceConverter`):
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
class TraceConverter(Protocol):
|
|
152
|
+
def translate_entry(entry, direction, ctx) -> list[TargetEntry] | TranslationError
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`ReferenceConverter` implements it via a normalized event model
|
|
156
|
+
(`tandem/events.py`) derived from the observed formats. Pass your own
|
|
157
|
+
converter to `SyncEngine(store, session, source, converter=...)`.
|
|
158
|
+
|
|
159
|
+
## Install & development
|
|
160
|
+
|
|
161
|
+
Requires Python 3.11+ and both CLIs on PATH. No network calls of tandem's
|
|
162
|
+
own; all model calls happen inside the wrapped CLIs under your existing
|
|
163
|
+
auth.
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
pipx install . # or: uv tool install .
|
|
167
|
+
uv sync && uv run pytest # development
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Dependencies (deliberately small): `click` (CLI), `pydantic` v2 (event
|
|
171
|
+
schema), `watchdog` (transcript tailing), `pexpect`/ptyprocess (PTY
|
|
172
|
+
passthrough); state is stdlib `sqlite3`.
|
|
173
|
+
|
|
174
|
+
## v1 non-goals
|
|
175
|
+
|
|
176
|
+
One active harness at a time (no concurrent dual-active). No other agents
|
|
177
|
+
yet (the adapter interface is generic). No cloud sync or telemetry.
|
|
178
|
+
Compaction state is not translated — on switch, the newly active tool's own
|
|
179
|
+
compaction handles an over-long context.
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# tandem
|
|
2
|
+
|
|
3
|
+
A meta-harness that runs **Claude Code** and **OpenAI Codex CLI** as one
|
|
4
|
+
paired session. Work in either agent and switch between them at any time
|
|
5
|
+
without losing context: while one harness is **active**, the other is a
|
|
6
|
+
**shadow** whose native session file is kept continuously up to date by
|
|
7
|
+
translating the active harness's transcript entries into the shadow's own
|
|
8
|
+
format. Switching is instant — the shadow was always resume-ready via that
|
|
9
|
+
tool's native `--resume`/`resume` mechanism.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
$ tandem # fresh paired claude+codex session; enters claude
|
|
13
|
+
$ tandem --active codex # fresh session with codex active instead
|
|
14
|
+
tandem (claude)> switch # after exiting claude: continue instantly in codex
|
|
15
|
+
tandem (codex)> exit
|
|
16
|
+
to continue this session: tandem resume a1b2c3d4e5f6
|
|
17
|
+
$ tandem resume # continue the most recent session here
|
|
18
|
+
$ tandem resume <id> # continue a specific session (id from the exit hint,
|
|
19
|
+
# run from that session's own directory)
|
|
20
|
+
$ tandem run --on codex "second opinion: why is this test flaky?"
|
|
21
|
+
$ tandem status # pairing, roles, sync position
|
|
22
|
+
$ tandem switch # one-shot flip; does not enter a harness
|
|
23
|
+
$ tandem doctor [--live] # verify both sessions are resumable
|
|
24
|
+
$ tandem sync # manual catch-up translation (local file I/O only)
|
|
25
|
+
$ tandem sync-mcp # opt-in: copy MCP server configs between the tools
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
uv tool install tandem-cli # or: pip install tandem-cli
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Requires Python 3.11+ and the `claude` and `codex` CLIs on your PATH.
|
|
35
|
+
|
|
36
|
+
## How it works
|
|
37
|
+
|
|
38
|
+
- **Per-command single-model invariant.** Only one model is ever invoked per
|
|
39
|
+
user command — the active harness's (or, for `run --on`, the target's).
|
|
40
|
+
The shadow side is pure local file I/O: tandem tails the active
|
|
41
|
+
transcript, translates each entry, and appends to the shadow's session
|
|
42
|
+
file. The shadow's model is never called to "catch up".
|
|
43
|
+
- **A persistent prompt, not the OS shell.** Leaving the harness lands you
|
|
44
|
+
at `tandem (claude)>` instead of back in your shell. There, `switch`
|
|
45
|
+
flips roles and drops you straight into the other tool (no re-launch
|
|
46
|
+
needed), Enter or `resume` re-enters the current one, and
|
|
47
|
+
`status`/`sync`/`doctor`/`run --on …`/`sync-mcp` run the same
|
|
48
|
+
implementations as their one-shot forms, always against this session.
|
|
49
|
+
`exit` (or Ctrl-D) returns to your shell and prints
|
|
50
|
+
`to continue this session: tandem resume <id>` — pass that id to
|
|
51
|
+
`tandem resume <id>` from the session's own directory. Every one-shot
|
|
52
|
+
command also works from the OS shell, targeting the directory's most
|
|
53
|
+
recently used session; one-shot `tandem switch` only flips, it does not
|
|
54
|
+
enter a harness.
|
|
55
|
+
- **PTY passthrough.** `tandem` launches the real CLI on a pty (raw mode,
|
|
56
|
+
SIGWINCH resize forwarding, signals through the line discipline). Tandem
|
|
57
|
+
never scrapes terminal output; the transcript files are the source of
|
|
58
|
+
truth. Turn-complete hooks (`claude --settings` Stop hook, `codex -c
|
|
59
|
+
notify=[...]`) are wired per-invocation as wake-up signals, with
|
|
60
|
+
fs-watching (watchdog, polling fallback) as the data path and fallback.
|
|
61
|
+
If the user's codex config already sets `notify`, tandem leaves it alone.
|
|
62
|
+
- **Append-only sync.** Each new transcript entry is translated as it lands
|
|
63
|
+
(no bulk re-export at switch time). Appends are whole-line + fsync, and a
|
|
64
|
+
write-ahead intent in the sync cursor makes translation exactly-once
|
|
65
|
+
across crashes. On restart, sync resumes from the last confirmed entry.
|
|
66
|
+
- **Tool calls are translated natively.** The harnesses have different tool
|
|
67
|
+
vocabularies (Read/Edit/Bash vs exec_command/apply_patch), so each
|
|
68
|
+
completed call+result pair is re-expressed in the shadow's own vocabulary
|
|
69
|
+
— `Bash` ↔ `exec_command`, `Edit`/`Write` ↔ `apply_patch`, `TodoWrite` ↔
|
|
70
|
+
`update_plan` — and lands as a real tool-call record, so shadow history
|
|
71
|
+
reads as the shadow's own work. Anything that would not map truthfully
|
|
72
|
+
(and every unrecognized tool) passes through verbatim with its name and
|
|
73
|
+
arguments intact. A call whose result never arrived is closed with a
|
|
74
|
+
`(tool result not recorded)` placeholder when the source is handed off,
|
|
75
|
+
since both replay APIs reject a dangling call.
|
|
76
|
+
- **Attribution.** Every synced *text* message is tagged `[via claude-code]`
|
|
77
|
+
/ `[via codex]` (tandem's own notes use `[tandem]`), so interleaved
|
|
78
|
+
histories stay legible to both the user and the models. Tool activity is
|
|
79
|
+
untagged — it is mirrored as native records, not prose.
|
|
80
|
+
- **Error localization.** An entry that fails translation becomes a
|
|
81
|
+
placeholder in the shadow —
|
|
82
|
+
`[tandem: turn N could not be translated from <tool> — <reason>; raw
|
|
83
|
+
entry quarantined at ~/.tandem/quarantine/...]` — at most one per turn,
|
|
84
|
+
and sync continues. The shadow is never corrupted or truncated.
|
|
85
|
+
- **Memory files.** A fresh `tandem` launch and every switch sync
|
|
86
|
+
CLAUDE.md ↔ AGENTS.md: shared content lives in a
|
|
87
|
+
`<!-- tandem:shared:begin/end -->` block (newer file wins), tool-specific
|
|
88
|
+
text outside the block is preserved, and a file without markers is read
|
|
89
|
+
from but never rewritten. Git state is never touched.
|
|
90
|
+
|
|
91
|
+
## Compatibility
|
|
92
|
+
|
|
93
|
+
Session formats are internal to the CLIs and drift between releases. Tandem
|
|
94
|
+
pins what it was built against (observed formats documented in
|
|
95
|
+
[docs/formats.md](docs/formats.md)):
|
|
96
|
+
|
|
97
|
+
| CLI | tested | accepted range |
|
|
98
|
+
| --- | --- | --- |
|
|
99
|
+
| Claude Code | 2.1.220 | ≥ 2.0, < 3 |
|
|
100
|
+
| Codex CLI | 0.145.0 | ≥ 0.140, < 0.150 |
|
|
101
|
+
|
|
102
|
+
Outside the range tandem warns and asks you to run `tandem doctor`
|
|
103
|
+
(`--live` performs a real one-word resume per harness — two small model
|
|
104
|
+
calls). Format knowledge is isolated per tool in
|
|
105
|
+
`src/tandem/harness/claude_code.py` and `src/tandem/harness/codex.py`.
|
|
106
|
+
|
|
107
|
+
## Where things live
|
|
108
|
+
|
|
109
|
+
- `~/.tandem/state.db` — SQLite: session pairing + per-source sync cursors
|
|
110
|
+
(override the directory with `TANDEM_HOME`)
|
|
111
|
+
- `~/.tandem/quarantine/<session>/` — raw entries that failed translation
|
|
112
|
+
- `~/.claude/projects/<munged-cwd>/<session-id>.jsonl` — claude transcript
|
|
113
|
+
- `~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<session-id>.jsonl` — codex
|
|
114
|
+
rollout (`CLAUDE_CONFIG_DIR` / `CODEX_HOME` honored)
|
|
115
|
+
|
|
116
|
+
Claude session ids are minted by tandem (`claude --session-id`); codex mints
|
|
117
|
+
its own on first run and tandem captures it from the new rollout file.
|
|
118
|
+
|
|
119
|
+
## Swapping in a different converter
|
|
120
|
+
|
|
121
|
+
The sync engine talks to a small adapter interface
|
|
122
|
+
(`tandem.converter.TraceConverter`):
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
class TraceConverter(Protocol):
|
|
126
|
+
def translate_entry(entry, direction, ctx) -> list[TargetEntry] | TranslationError
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`ReferenceConverter` implements it via a normalized event model
|
|
130
|
+
(`tandem/events.py`) derived from the observed formats. Pass your own
|
|
131
|
+
converter to `SyncEngine(store, session, source, converter=...)`.
|
|
132
|
+
|
|
133
|
+
## Install & development
|
|
134
|
+
|
|
135
|
+
Requires Python 3.11+ and both CLIs on PATH. No network calls of tandem's
|
|
136
|
+
own; all model calls happen inside the wrapped CLIs under your existing
|
|
137
|
+
auth.
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
pipx install . # or: uv tool install .
|
|
141
|
+
uv sync && uv run pytest # development
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Dependencies (deliberately small): `click` (CLI), `pydantic` v2 (event
|
|
145
|
+
schema), `watchdog` (transcript tailing), `pexpect`/ptyprocess (PTY
|
|
146
|
+
passthrough); state is stdlib `sqlite3`.
|
|
147
|
+
|
|
148
|
+
## v1 non-goals
|
|
149
|
+
|
|
150
|
+
One active harness at a time (no concurrent dual-active). No other agents
|
|
151
|
+
yet (the adapter interface is generic). No cloud sync or telemetry.
|
|
152
|
+
Compaction state is not translated — on switch, the newly active tool's own
|
|
153
|
+
compaction handles an over-long context.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Observed session formats
|
|
2
|
+
|
|
3
|
+
Everything tandem knows about the two native session formats was observed on
|
|
4
|
+
this machine by creating throwaway sessions and reading the files the CLIs
|
|
5
|
+
wrote. Versions observed:
|
|
6
|
+
|
|
7
|
+
| CLI | version | session storage |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| Claude Code (`claude`) | 2.1.220 | `~/.claude/projects/<munged-cwd>/<sessionId>.jsonl` |
|
|
10
|
+
| Codex CLI (`codex`) | 0.145.0 | `~/.codex/sessions/YYYY/MM/DD/rollout-<YYYY-MM-DDThh-mm-ss>-<uuidv7>.jsonl` |
|
|
11
|
+
|
|
12
|
+
Env overrides honored: `CLAUDE_CONFIG_DIR` (claude home), `CODEX_HOME` (codex
|
|
13
|
+
home), `TANDEM_HOME` (tandem state).
|
|
14
|
+
|
|
15
|
+
## Claude Code transcript (claude 2.1.220)
|
|
16
|
+
|
|
17
|
+
- One JSONL file per session, named `<sessionId>.jsonl` (sessionId is a
|
|
18
|
+
UUIDv4). Project directory name = cwd with every character outside
|
|
19
|
+
`[A-Za-z0-9]` replaced by `-` (observed: `/private/tmp/claude-501/x` →
|
|
20
|
+
`-private-tmp-claude-501-x`).
|
|
21
|
+
- Conversation entries carry `uuid` + `parentUuid` forming a chain, plus
|
|
22
|
+
`sessionId`, `cwd`, `version`, `gitBranch`, `timestamp` (ISO-8601 ms UTC
|
|
23
|
+
`Z`), `isSidechain`, `userType`, `entrypoint`.
|
|
24
|
+
- Entry `type`s observed:
|
|
25
|
+
- `user` — `message: {role: "user", content: <string | block list>}`.
|
|
26
|
+
A plain string content = a real user prompt. A block list with
|
|
27
|
+
`tool_result` blocks = a tool result; the sibling field `toolUseResult`
|
|
28
|
+
holds the structured result (e.g. `{type: "create", filePath, content,
|
|
29
|
+
structuredPatch}` for Write, `{stdout, stderr, interrupted}` for Bash),
|
|
30
|
+
and `sourceToolAssistantUUID` points at the entry with the `tool_use`.
|
|
31
|
+
- `assistant` — `message` is an API message; **one content block per JSONL
|
|
32
|
+
line**, consecutive lines share `message.id`. Block types: `thinking`
|
|
33
|
+
(signature-bound), `text`, `tool_use` (`id`, `name`, `input`).
|
|
34
|
+
`stop_reason` on each line (`tool_use` / `end_turn`).
|
|
35
|
+
- `attachment` — hook output, skill/agent listings, deferred tool deltas.
|
|
36
|
+
Not conversation content.
|
|
37
|
+
- `queue-operation` (enqueue/dequeue of the prompt), `last-prompt`
|
|
38
|
+
(`leafUuid` pointer), `summary`, `system` — bookkeeping.
|
|
39
|
+
- `permission-mode`, `ai-title`, `pr-link`, `relocated`, `worktree-state`,
|
|
40
|
+
`file-history-snapshot` + `file-history-delta` — uuid-less session
|
|
41
|
+
metadata (permission mode, AI-generated title, linked PR, worktree
|
|
42
|
+
moves, file-backup tracking). Not conversation content; claude resumes
|
|
43
|
+
transcripts containing them without complaint.
|
|
44
|
+
- Resume: `claude --resume <sessionId>` (from the same cwd). A new session
|
|
45
|
+
can be pinned to a chosen id with `claude --session-id <uuid>`.
|
|
46
|
+
- Turn boundary: a `user` entry with string content starts a turn; an
|
|
47
|
+
`assistant` line with `stop_reason: "end_turn"` ends it. The `Stop` hook
|
|
48
|
+
(injectable per-invocation via `--settings '<json>'`) fires at turn end.
|
|
49
|
+
|
|
50
|
+
## Codex rollout (codex-cli 0.145.0)
|
|
51
|
+
|
|
52
|
+
- One JSONL file per session under a date-sharded dir; the session id
|
|
53
|
+
(UUIDv7) is embedded in the filename. `~/.codex/session_index.jsonl` maps
|
|
54
|
+
`{id, thread_name, updated_at}`. `~/.codex/history.jsonl` is a global
|
|
55
|
+
prompt history.
|
|
56
|
+
- Every line is `{timestamp, type, payload}`.
|
|
57
|
+
- `type` values observed:
|
|
58
|
+
- `session_meta` — first line: `{session_id, id, timestamp, cwd,
|
|
59
|
+
originator, cli_version, source, thread_source, model_provider,
|
|
60
|
+
base_instructions, history_mode: "legacy", context_window}`.
|
|
61
|
+
- `response_item` — the **model-facing** history. `payload.type`:
|
|
62
|
+
- `message` — `role` developer/user/assistant; content blocks
|
|
63
|
+
`input_text` (developer/user) or `output_text` (assistant); assistant
|
|
64
|
+
messages carry `phase: "commentary" | "final_answer"`.
|
|
65
|
+
Note: role=user response_items include injected context (permissions,
|
|
66
|
+
plugins, environment) — the real user prompt is identified by the
|
|
67
|
+
matching `event_msg/user_message`.
|
|
68
|
+
- `reasoning` — `encrypted_content`, not portable.
|
|
69
|
+
- `function_call` — `{name, arguments: <json string>, call_id}` (e.g.
|
|
70
|
+
`exec_command`).
|
|
71
|
+
- `function_call_output` — `{call_id, output}` (chunked shell output with
|
|
72
|
+
exit code header).
|
|
73
|
+
- `custom_tool_call` / `custom_tool_call_output` — `apply_patch` with
|
|
74
|
+
`input` = patch text (`*** Begin Patch ...`).
|
|
75
|
+
- `event_msg` — the **UI-facing** stream. `payload.type`: `task_started`
|
|
76
|
+
(`turn_id`), `user_message`, `agent_message` (`phase`), `token_count`,
|
|
77
|
+
`patch_apply_end` (`{stdout, success, changes: {path: {type, content}}}`),
|
|
78
|
+
`task_complete` (`last_agent_message`).
|
|
79
|
+
- `turn_context` — per turn: cwd, approval_policy, sandbox_policy, model.
|
|
80
|
+
- `world_state` — environment snapshot.
|
|
81
|
+
- Resume: `codex resume <session-id>` (interactive) and
|
|
82
|
+
`codex exec resume <session-id> "<prompt>"` (one-shot). Turn-complete
|
|
83
|
+
notification hook: `-c 'notify=["/bin/sh","-c","..."]'` per invocation.
|
|
84
|
+
- Turn boundary: `event_msg/task_started` … `event_msg/task_complete`.
|
|
85
|
+
|
|
86
|
+
## Decisions on the spec's open questions
|
|
87
|
+
|
|
88
|
+
1. **File discovery** — as above; codex rollouts are found by globbing the
|
|
89
|
+
session id in the filename (no file reads needed); claude transcripts by
|
|
90
|
+
deterministic munged-cwd + session id path.
|
|
91
|
+
2. **Hooks vs fs-watch** — both CLIs accept per-invocation hook wiring
|
|
92
|
+
(`claude --settings` Stop hook; `codex -c notify=[...]`), so tandem wires
|
|
93
|
+
a turn-complete hook that touches a sentinel file used to flush the sync
|
|
94
|
+
loop promptly. The transcript tailer (watchdog, polling fallback) remains
|
|
95
|
+
the source of truth and the fallback when hooks fail — hook output is
|
|
96
|
+
never parsed, only used as a wake-up signal.
|
|
97
|
+
3. **Placeholder format** — untranslatable entries become, in the shadow's
|
|
98
|
+
native text form:
|
|
99
|
+
`[tandem: turn {turn} could not be translated from {source} — {reason};
|
|
100
|
+
raw entry quarantined at {path}]`
|
|
101
|
+
with the raw source entry written to
|
|
102
|
+
`~/.tandem/quarantine/<tandem-id>/<source>-line-<n>.json`.
|