tandem-cli 0.1.0__py3-none-any.whl
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/__init__.py +1 -0
- tandem/cli.py +358 -0
- tandem/compat.py +62 -0
- tandem/constants.py +23 -0
- tandem/converter.py +140 -0
- tandem/doctor.py +265 -0
- tandem/events.py +107 -0
- tandem/harness/__init__.py +16 -0
- tandem/harness/base.py +87 -0
- tandem/harness/claude_code.py +338 -0
- tandem/harness/codex.py +340 -0
- tandem/memory_sync.py +220 -0
- tandem/ops.py +207 -0
- tandem/paths.py +92 -0
- tandem/ptyrun.py +99 -0
- tandem/runner.py +237 -0
- tandem/shell.py +207 -0
- tandem/state.py +227 -0
- tandem/summarize.py +37 -0
- tandem/sync.py +221 -0
- tandem/tailer.py +141 -0
- tandem/toolmap.py +307 -0
- tandem/util.py +88 -0
- tandem_cli-0.1.0.dist-info/METADATA +179 -0
- tandem_cli-0.1.0.dist-info/RECORD +28 -0
- tandem_cli-0.1.0.dist-info/WHEEL +4 -0
- tandem_cli-0.1.0.dist-info/entry_points.txt +2 -0
- tandem_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
tandem/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
tandem/cli.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""tandem — run Claude Code and Codex CLI as one paired session."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from . import compat, paths
|
|
11
|
+
from .constants import SEED_NOTE
|
|
12
|
+
from .events import SessionContext
|
|
13
|
+
from .harness import get_adapter, other
|
|
14
|
+
from .state import PairedSession, StateStore
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _cwd() -> str:
|
|
18
|
+
return str(Path.cwd())
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Set by the tandem prompt (shell.py) around each dispatched command so it
|
|
22
|
+
# acts on that shell's own session. Without it, a second `tandem` in the same
|
|
23
|
+
# directory becomes the cwd-MRU and silently steals `status`/`sync`/`run --on`
|
|
24
|
+
# typed in the first shell.
|
|
25
|
+
_SESSION_ID: str | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _resolve_session(store: StateStore) -> PairedSession | None:
|
|
29
|
+
if _SESSION_ID is not None:
|
|
30
|
+
return store.get_session(_SESSION_ID)
|
|
31
|
+
return store.latest_session_for_cwd(_cwd())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _require_session(store: StateStore) -> PairedSession:
|
|
35
|
+
session = _resolve_session(store)
|
|
36
|
+
if session is None:
|
|
37
|
+
click.echo(
|
|
38
|
+
"No tandem session for this directory. Run `tandem` to start one.",
|
|
39
|
+
err=True,
|
|
40
|
+
)
|
|
41
|
+
sys.exit(1)
|
|
42
|
+
store.touch_used(session.tandem_id)
|
|
43
|
+
return session
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _check_versions(warn_only: bool = False) -> dict[str, str | None]:
|
|
47
|
+
versions: dict[str, str | None] = {}
|
|
48
|
+
for hid in ("claude", "codex"):
|
|
49
|
+
adapter = get_adapter(hid)
|
|
50
|
+
v = adapter.detect_version()
|
|
51
|
+
versions[hid] = v
|
|
52
|
+
if v is None:
|
|
53
|
+
msg = f"{adapter.display_name} ({adapter.binary}) not found on PATH."
|
|
54
|
+
if warn_only:
|
|
55
|
+
click.secho(f"warning: {msg}", fg="yellow", err=True)
|
|
56
|
+
else:
|
|
57
|
+
click.secho(f"error: {msg}", fg="red", err=True)
|
|
58
|
+
sys.exit(1)
|
|
59
|
+
elif not adapter.version_supported(v):
|
|
60
|
+
tested = compat.COMPAT[hid].tested
|
|
61
|
+
click.secho(
|
|
62
|
+
f"warning: {adapter.display_name} version {v!r} is outside the "
|
|
63
|
+
f"range tandem was built against (tested: {tested}). "
|
|
64
|
+
f"Run `tandem doctor` before trusting sync.",
|
|
65
|
+
fg="yellow",
|
|
66
|
+
err=True,
|
|
67
|
+
)
|
|
68
|
+
return versions
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@click.group(invoke_without_command=True)
|
|
72
|
+
@click.version_option(package_name="tandem")
|
|
73
|
+
@click.option(
|
|
74
|
+
"--active",
|
|
75
|
+
type=click.Choice(["claude", "codex"]),
|
|
76
|
+
default="claude",
|
|
77
|
+
show_default=True,
|
|
78
|
+
help="Initially active harness for the fresh session.",
|
|
79
|
+
)
|
|
80
|
+
@click.pass_context
|
|
81
|
+
def main(ctx: click.Context, active: str) -> None:
|
|
82
|
+
"""Run Claude Code and Codex as one paired session.
|
|
83
|
+
|
|
84
|
+
With no subcommand, pairs a fresh session and enters the active
|
|
85
|
+
harness; `tandem resume` continues an earlier one.
|
|
86
|
+
"""
|
|
87
|
+
if ctx.invoked_subcommand is None:
|
|
88
|
+
_interactive(active)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _pair_session(store: StateStore, cwd: str, active: str) -> PairedSession:
|
|
92
|
+
"""Create a fresh paired session: state row, seeded shadow transcript,
|
|
93
|
+
write-ahead cursor, memory sync. Echoes what it did."""
|
|
94
|
+
shadow = other(active)
|
|
95
|
+
claude_sid = get_adapter("claude").mint_session_id()
|
|
96
|
+
codex_sid = None if active == "codex" else get_adapter("codex").mint_session_id()
|
|
97
|
+
session = store.create_session(cwd, active, claude_sid, codex_sid)
|
|
98
|
+
|
|
99
|
+
ctx = SessionContext(
|
|
100
|
+
tandem_id=session.tandem_id,
|
|
101
|
+
cwd=cwd,
|
|
102
|
+
direction="claude->codex" if active == "claude" else "codex->claude",
|
|
103
|
+
claude_session_id=claude_sid,
|
|
104
|
+
codex_session_id=codex_sid,
|
|
105
|
+
)
|
|
106
|
+
note = SEED_NOTE.format(
|
|
107
|
+
tandem_id=session.tandem_id,
|
|
108
|
+
other=get_adapter(active).display_name,
|
|
109
|
+
)
|
|
110
|
+
# The shadow transcript is created now so it is resume-ready from the
|
|
111
|
+
# first turn. The active side's file is created by the harness itself
|
|
112
|
+
# at first launch (claude is pinned via --session-id; codex mints its
|
|
113
|
+
# own id which tandem captures on first run).
|
|
114
|
+
shadow_adapter = get_adapter(shadow)
|
|
115
|
+
if shadow == "claude":
|
|
116
|
+
shadow_adapter.create_shadow_transcript(cwd, claude_sid, ctx, note)
|
|
117
|
+
cursor_updates = {"claude_leaf_uuid": ctx.claude_leaf_uuid}
|
|
118
|
+
else:
|
|
119
|
+
shadow_adapter.create_shadow_transcript(cwd, codex_sid, ctx, note)
|
|
120
|
+
cursor_updates = {}
|
|
121
|
+
cursor = store.get_cursor(session.tandem_id, active)
|
|
122
|
+
cursor.pending.update(cursor_updates)
|
|
123
|
+
store.save_cursor(cursor)
|
|
124
|
+
|
|
125
|
+
from .memory_sync import sync_memory_files
|
|
126
|
+
|
|
127
|
+
mem = sync_memory_files(cwd)
|
|
128
|
+
click.echo(f"paired {session.tandem_id} ({active} active, {shadow} shadow)")
|
|
129
|
+
for a in mem.actions:
|
|
130
|
+
click.echo(f" memory: {a}")
|
|
131
|
+
for w in mem.warnings:
|
|
132
|
+
click.secho(f" memory: {w}", fg="yellow", err=True)
|
|
133
|
+
if active == "codex":
|
|
134
|
+
click.echo(" note: codex session id will be captured on first run")
|
|
135
|
+
return session
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@main.command()
|
|
139
|
+
def status() -> None:
|
|
140
|
+
"""Show the paired session for this directory."""
|
|
141
|
+
with StateStore() as store:
|
|
142
|
+
session = _require_session(store)
|
|
143
|
+
versions = _check_versions(warn_only=True)
|
|
144
|
+
click.echo(f"tandem session {session.tandem_id} ({session.cwd})")
|
|
145
|
+
click.echo(f" created: {session.created_at}")
|
|
146
|
+
click.echo(f" last sync: {session.last_sync_at or 'never'}")
|
|
147
|
+
for hid in ("claude", "codex"):
|
|
148
|
+
adapter = get_adapter(hid)
|
|
149
|
+
sid = getattr(session, f"{hid}_session_id")
|
|
150
|
+
role = "ACTIVE" if session.active == hid else "shadow"
|
|
151
|
+
path = adapter.transcript_path(session.cwd, sid) if sid else None
|
|
152
|
+
click.echo(f" {adapter.display_name:<12} {role}")
|
|
153
|
+
click.echo(f" version: {versions.get(hid) or 'not installed'}")
|
|
154
|
+
click.echo(f" session: {sid or '(pending first run)'}")
|
|
155
|
+
click.echo(f" file: {path or '(not created yet)'}")
|
|
156
|
+
from . import ops
|
|
157
|
+
|
|
158
|
+
for source in ("claude", "codex"):
|
|
159
|
+
cursor = store.get_cursor(session.tandem_id, source)
|
|
160
|
+
behind = ops.unsynced_lines(session, store, source)
|
|
161
|
+
if cursor.updated_at or cursor.failed_turns or behind:
|
|
162
|
+
line = (
|
|
163
|
+
f" sync from {source}: line {cursor.line_index}, "
|
|
164
|
+
f"turn {cursor.turn_index}, failed turns: {cursor.failed_turns}"
|
|
165
|
+
)
|
|
166
|
+
if behind and source == session.active:
|
|
167
|
+
line += f", {behind} lines awaiting translation"
|
|
168
|
+
click.echo(line)
|
|
169
|
+
qdir = paths.quarantine_dir(session.tandem_id)
|
|
170
|
+
if qdir.is_dir() and any(qdir.iterdir()):
|
|
171
|
+
click.echo(f" quarantine: {qdir} (has entries)")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@main.command()
|
|
175
|
+
@click.argument("tandem_id", required=False)
|
|
176
|
+
def resume(tandem_id: str | None) -> None:
|
|
177
|
+
"""Resume a paired session (most recent for this directory by default).
|
|
178
|
+
|
|
179
|
+
The id is printed when you leave a session, and shown by `tandem status`.
|
|
180
|
+
"""
|
|
181
|
+
cwd = _cwd()
|
|
182
|
+
_check_versions(warn_only=True)
|
|
183
|
+
with StateStore() as store:
|
|
184
|
+
if tandem_id is None:
|
|
185
|
+
session = store.latest_session_for_cwd(cwd)
|
|
186
|
+
if session is None:
|
|
187
|
+
click.echo(
|
|
188
|
+
"No tandem session for this directory. Run `tandem` to start one.",
|
|
189
|
+
err=True,
|
|
190
|
+
)
|
|
191
|
+
sys.exit(1)
|
|
192
|
+
else:
|
|
193
|
+
session = store.get_session(tandem_id)
|
|
194
|
+
if session is None:
|
|
195
|
+
click.secho(f"error: no tandem session {tandem_id!r}.", fg="red", err=True)
|
|
196
|
+
sys.exit(1)
|
|
197
|
+
if session.cwd != cwd:
|
|
198
|
+
click.secho(
|
|
199
|
+
f"error: session {tandem_id} belongs to {session.cwd}; "
|
|
200
|
+
"run `tandem resume` from there.",
|
|
201
|
+
fg="red",
|
|
202
|
+
err=True,
|
|
203
|
+
)
|
|
204
|
+
sys.exit(1)
|
|
205
|
+
store.touch_used(session.tandem_id)
|
|
206
|
+
sys.exit(_enter_session(session))
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _default_sink_factory(store, session, source):
|
|
210
|
+
"""Sync engine by default; TANDEM_LOG_EVENTS=1 switches to the debug
|
|
211
|
+
event logger (no shadow writes)."""
|
|
212
|
+
import os
|
|
213
|
+
|
|
214
|
+
from .runner import EventLogger
|
|
215
|
+
from .sync import SyncEngine
|
|
216
|
+
|
|
217
|
+
if os.environ.get("TANDEM_LOG_EVENTS"):
|
|
218
|
+
return EventLogger(session.tandem_id, source)
|
|
219
|
+
return SyncEngine(store, session, source)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _report_switch(old: str, new_active: str, problems, mem) -> None:
|
|
223
|
+
"""Report the outcome of a role flip. Shared by the one-shot `switch`
|
|
224
|
+
command and the tandem prompt's `switch`, so neither path drops
|
|
225
|
+
memory-sync actions or the may-not-resume advisory."""
|
|
226
|
+
click.echo(
|
|
227
|
+
f"active harness: {get_adapter(old).display_name} -> "
|
|
228
|
+
f"{get_adapter(new_active).display_name}"
|
|
229
|
+
)
|
|
230
|
+
for a in mem.actions:
|
|
231
|
+
click.echo(f" memory: {a}")
|
|
232
|
+
for w in mem.warnings:
|
|
233
|
+
click.secho(f" memory: {w}", fg="yellow", err=True)
|
|
234
|
+
for p in problems:
|
|
235
|
+
click.secho(f" warning: {p}", fg="yellow", err=True)
|
|
236
|
+
if problems:
|
|
237
|
+
click.secho(
|
|
238
|
+
" the newly active session may not resume cleanly; "
|
|
239
|
+
"run `tandem doctor` for details.",
|
|
240
|
+
fg="yellow",
|
|
241
|
+
err=True,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@main.command()
|
|
246
|
+
def switch() -> None:
|
|
247
|
+
"""Make the shadow harness active (instant; no re-conversion)."""
|
|
248
|
+
from . import ops
|
|
249
|
+
|
|
250
|
+
with StateStore() as store:
|
|
251
|
+
session = _require_session(store)
|
|
252
|
+
old = session.active
|
|
253
|
+
try:
|
|
254
|
+
new_active, problems, mem = ops.switch_session(store, session)
|
|
255
|
+
except Exception as exc:
|
|
256
|
+
click.secho(f"switch failed: {exc}", fg="red", err=True)
|
|
257
|
+
sys.exit(1)
|
|
258
|
+
_report_switch(old, new_active, problems, mem)
|
|
259
|
+
click.echo("Run `tandem resume` to continue in the new harness.")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@main.command(name="run")
|
|
263
|
+
@click.option(
|
|
264
|
+
"--on",
|
|
265
|
+
"target",
|
|
266
|
+
type=click.Choice(["claude", "codex"]),
|
|
267
|
+
required=True,
|
|
268
|
+
help="Harness to route this one prompt to.",
|
|
269
|
+
)
|
|
270
|
+
@click.argument("prompt", nargs=-1, required=True)
|
|
271
|
+
def run_cmd(target: str, prompt: tuple[str, ...]) -> None:
|
|
272
|
+
"""Run one prompt on the other harness, then return control.
|
|
273
|
+
|
|
274
|
+
The resulting turn lands in both session files with attribution."""
|
|
275
|
+
from . import ops
|
|
276
|
+
|
|
277
|
+
text = " ".join(prompt)
|
|
278
|
+
with StateStore() as store:
|
|
279
|
+
session = _require_session(store)
|
|
280
|
+
if target == session.active:
|
|
281
|
+
click.secho(
|
|
282
|
+
f"note: {target} is already the active harness; running the "
|
|
283
|
+
"turn there anyway.",
|
|
284
|
+
fg="yellow",
|
|
285
|
+
err=True,
|
|
286
|
+
)
|
|
287
|
+
code = ops.run_oneoff(store, session, target, text)
|
|
288
|
+
sys.exit(code)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@main.command()
|
|
292
|
+
@click.option(
|
|
293
|
+
"--live",
|
|
294
|
+
is_flag=True,
|
|
295
|
+
help="Also perform a real resume on both sessions (costs one small model "
|
|
296
|
+
"call per harness).",
|
|
297
|
+
)
|
|
298
|
+
def doctor(live: bool) -> None:
|
|
299
|
+
"""Validate that both session files are resumable; report drift."""
|
|
300
|
+
from .doctor import run_doctor
|
|
301
|
+
|
|
302
|
+
with StateStore() as store:
|
|
303
|
+
session = _resolve_session(store)
|
|
304
|
+
report = run_doctor(store, session, live=live)
|
|
305
|
+
icons = {"ok": ("✓", "green"), "warn": ("!", "yellow"), "fail": ("✗", "red")}
|
|
306
|
+
for check in report.checks:
|
|
307
|
+
icon, color = icons[check.status]
|
|
308
|
+
click.secho(f" {icon} {check.message}", fg=color if check.status != "ok" else None)
|
|
309
|
+
if report.failed:
|
|
310
|
+
sys.exit(1)
|
|
311
|
+
click.echo("all checks passed" if not any(
|
|
312
|
+
c.status == "warn" for c in report.checks
|
|
313
|
+
) else "passed with warnings")
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@main.command(name="sync-mcp")
|
|
317
|
+
@click.confirmation_option(
|
|
318
|
+
prompt="Copy MCP server definitions between ~/.claude.json and "
|
|
319
|
+
"~/.codex/config.toml (additive, never overwrites existing entries)?"
|
|
320
|
+
)
|
|
321
|
+
def sync_mcp() -> None:
|
|
322
|
+
"""Copy MCP server configs between the two harnesses (opt-in)."""
|
|
323
|
+
from .memory_sync import copy_mcp
|
|
324
|
+
|
|
325
|
+
report = copy_mcp()
|
|
326
|
+
for a in report.actions:
|
|
327
|
+
click.echo(f" {a}")
|
|
328
|
+
for w in report.warnings:
|
|
329
|
+
click.secho(f" warning: {w}", fg="yellow", err=True)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@main.command()
|
|
333
|
+
def sync() -> None:
|
|
334
|
+
"""Catch up shadow translation manually (pure local file I/O)."""
|
|
335
|
+
from . import ops
|
|
336
|
+
|
|
337
|
+
with StateStore() as store:
|
|
338
|
+
session = _require_session(store)
|
|
339
|
+
n = ops.drain_source(store, session, session.active)
|
|
340
|
+
click.echo(f"synced {n} new transcript lines from {session.active}.")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _interactive(active: str) -> None:
|
|
344
|
+
cwd = _cwd()
|
|
345
|
+
_check_versions() # hard: pairing needs both binaries on PATH
|
|
346
|
+
with StateStore() as store:
|
|
347
|
+
session = _pair_session(store, cwd, active)
|
|
348
|
+
sys.exit(_enter_session(session))
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _enter_session(session: PairedSession) -> int:
|
|
352
|
+
from .shell import run_shell
|
|
353
|
+
|
|
354
|
+
return run_shell(session.tandem_id, _default_sink_factory)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
if __name__ == "__main__":
|
|
358
|
+
main()
|
tandem/compat.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Pinned CLI compatibility table and installed-version detection.
|
|
2
|
+
|
|
3
|
+
Both session formats are internal to their CLIs and drift between releases.
|
|
4
|
+
Tandem pins the ranges it was built against; outside a range we warn and
|
|
5
|
+
require `tandem doctor` before enabling sync (see cli.py).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import functools
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class CompatRange:
|
|
19
|
+
tested: str # exact version this code was developed against
|
|
20
|
+
min_version: tuple[int, ...]
|
|
21
|
+
max_exclusive: tuple[int, ...]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Format observations in docs/formats.md correspond to these versions.
|
|
25
|
+
COMPAT: dict[str, CompatRange] = {
|
|
26
|
+
"claude": CompatRange(tested="2.1.220", min_version=(2, 0), max_exclusive=(3,)),
|
|
27
|
+
"codex": CompatRange(tested="0.145.0", min_version=(0, 140), max_exclusive=(0, 150)),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
_VERSION_RE = re.compile(r"(\d+(?:\.\d+)+)")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def parse_version(text: str) -> tuple[int, ...] | None:
|
|
34
|
+
m = _VERSION_RE.search(text)
|
|
35
|
+
if not m:
|
|
36
|
+
return None
|
|
37
|
+
return tuple(int(x) for x in m.group(1).split("."))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@functools.lru_cache(maxsize=8)
|
|
41
|
+
def detect_cli_version(binary: str) -> str | None:
|
|
42
|
+
"""Return the raw version string of an installed CLI, or None if absent.
|
|
43
|
+
Cached for the process lifetime (renderers stamp it on every entry)."""
|
|
44
|
+
if shutil.which(binary) is None:
|
|
45
|
+
return None
|
|
46
|
+
try:
|
|
47
|
+
out = subprocess.run(
|
|
48
|
+
[binary, "--version"], capture_output=True, text=True, timeout=20
|
|
49
|
+
)
|
|
50
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
51
|
+
return None
|
|
52
|
+
if out.returncode != 0:
|
|
53
|
+
return None
|
|
54
|
+
return out.stdout.strip() or out.stderr.strip() or None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def version_supported(harness: str, version_text: str) -> bool:
|
|
58
|
+
rng = COMPAT[harness]
|
|
59
|
+
v = parse_version(version_text)
|
|
60
|
+
if v is None:
|
|
61
|
+
return False
|
|
62
|
+
return rng.min_version <= v < rng.max_exclusive
|
tandem/constants.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Shared literals: attribution markers and placeholder wording."""
|
|
2
|
+
|
|
3
|
+
ATTRIBUTION = {
|
|
4
|
+
"claude": "[via claude-code]",
|
|
5
|
+
"codex": "[via codex]",
|
|
6
|
+
"tandem": "[tandem]",
|
|
7
|
+
"user": "",
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
SEED_NOTE = (
|
|
11
|
+
"[tandem] This session is one half of tandem paired session {tandem_id}. "
|
|
12
|
+
"It was created by tandem (no model has run here yet). Turns executed in "
|
|
13
|
+
"{other} are synced below as context; text messages are tagged "
|
|
14
|
+
"[via claude-code] or [via codex] by the agent that produced them. Tool "
|
|
15
|
+
"calls from the other agent are mirrored as native tool-call records in "
|
|
16
|
+
"this session's own tool vocabulary."
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# Untranslatable-entry placeholder (decision for the spec's open question).
|
|
20
|
+
PLACEHOLDER = (
|
|
21
|
+
"[tandem: turn {turn} could not be translated from {source} — {reason}; "
|
|
22
|
+
"raw entry quarantined at {quarantine}]"
|
|
23
|
+
)
|
tandem/converter.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""TraceConverter adapter interface + the reference implementation.
|
|
2
|
+
|
|
3
|
+
The interface mirrors the spec so an external bidirectional converter can be
|
|
4
|
+
swapped in without touching the sync engine:
|
|
5
|
+
|
|
6
|
+
translate_entry(entry, direction, ctx) -> list[target entries] | TranslationError
|
|
7
|
+
|
|
8
|
+
The reference implementation goes through the normalized event model:
|
|
9
|
+
source adapter parses the native entry, this module applies the sync policy
|
|
10
|
+
(attribution tagging, tool-call pairing into native target-vocabulary pairs
|
|
11
|
+
via `toolmap`, dropping non-portable content), and the target adapter renders
|
|
12
|
+
native entries.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Any, Literal, Protocol, runtime_checkable
|
|
19
|
+
|
|
20
|
+
from . import toolmap
|
|
21
|
+
from .constants import ATTRIBUTION
|
|
22
|
+
from .events import (
|
|
23
|
+
AssistantMessage,
|
|
24
|
+
NormalizedEvent,
|
|
25
|
+
SessionContext,
|
|
26
|
+
ToolCall,
|
|
27
|
+
ToolResult,
|
|
28
|
+
UserMessage,
|
|
29
|
+
)
|
|
30
|
+
from .harness import get_adapter, other
|
|
31
|
+
from .summarize import summarize_orphan_result
|
|
32
|
+
|
|
33
|
+
Direction = Literal["claude->codex", "codex->claude"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class TranslationError:
|
|
38
|
+
reason: str
|
|
39
|
+
entry_summary: str = ""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@runtime_checkable
|
|
43
|
+
class TraceConverter(Protocol):
|
|
44
|
+
def translate_entry(
|
|
45
|
+
self, entry: dict[str, Any], direction: Direction, ctx: SessionContext
|
|
46
|
+
) -> list[dict[str, Any]] | TranslationError: ...
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ReferenceConverter:
|
|
50
|
+
"""parse -> normalize -> policy -> render. Handles user messages,
|
|
51
|
+
assistant text, tool calls and system/compaction events (skip or one-line
|
|
52
|
+
note).
|
|
53
|
+
|
|
54
|
+
Tool activity emits at the result: the call is stashed on arrival, and
|
|
55
|
+
when its result lands the pair is mapped into the target harness's own
|
|
56
|
+
tool vocabulary (`toolmap.map_pair`) and emitted as two adjacent events,
|
|
57
|
+
so the shadow reads as the shadow's own work. A result with no stashed
|
|
58
|
+
call falls back to a prose note — never a lone native tool_result, which
|
|
59
|
+
both replay APIs reject."""
|
|
60
|
+
|
|
61
|
+
def translate_entry(
|
|
62
|
+
self, entry: dict[str, Any], direction: Direction, ctx: SessionContext
|
|
63
|
+
) -> list[dict[str, Any]] | TranslationError:
|
|
64
|
+
source_id, target_id = direction.split("->")
|
|
65
|
+
source = get_adapter(source_id)
|
|
66
|
+
target = get_adapter(target_id)
|
|
67
|
+
try:
|
|
68
|
+
events = source.parse_entry(entry, ctx)
|
|
69
|
+
out_events = self._apply_policy(events, source_id, ctx)
|
|
70
|
+
if not out_events:
|
|
71
|
+
return []
|
|
72
|
+
return target.render_events(out_events, ctx)
|
|
73
|
+
except Exception as exc: # localize any surprise to this one entry
|
|
74
|
+
return TranslationError(
|
|
75
|
+
reason=f"{type(exc).__name__}: {exc}",
|
|
76
|
+
entry_summary=str(entry.get("type", "?")),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def _apply_policy(
|
|
80
|
+
self, events: list[NormalizedEvent], source_id: str, ctx: SessionContext
|
|
81
|
+
) -> list[NormalizedEvent]:
|
|
82
|
+
tag = ATTRIBUTION[source_id]
|
|
83
|
+
out: list[NormalizedEvent] = []
|
|
84
|
+
for ev in events:
|
|
85
|
+
if isinstance(ev, UserMessage):
|
|
86
|
+
out.append(ev.model_copy(update={"text": f"{tag} {ev.text}".strip()}))
|
|
87
|
+
elif isinstance(ev, AssistantMessage):
|
|
88
|
+
if ev.text.strip():
|
|
89
|
+
out.append(ev.model_copy(update={"text": f"{tag} {ev.text}"}))
|
|
90
|
+
elif isinstance(ev, ToolCall):
|
|
91
|
+
ctx.pending_calls[ev.call_id] = ev.model_dump(exclude_none=True)
|
|
92
|
+
elif isinstance(ev, ToolResult):
|
|
93
|
+
stored = ctx.pending_calls.pop(ev.call_id, None) if ev.call_id else None
|
|
94
|
+
if stored:
|
|
95
|
+
# _structured is the codex adapter's out-of-band enrichment
|
|
96
|
+
# channel, not a ToolCall field (which forbids extras)
|
|
97
|
+
stored.pop("_structured", None)
|
|
98
|
+
call = ToolCall.model_validate(stored)
|
|
99
|
+
out.extend(toolmap.map_pair(call, ev, other(source_id)))
|
|
100
|
+
else:
|
|
101
|
+
# no stashed call to pair with, so a native tool_result
|
|
102
|
+
# would dangle and break resume: prose instead
|
|
103
|
+
out.append(
|
|
104
|
+
AssistantMessage(
|
|
105
|
+
source=ev.source,
|
|
106
|
+
timestamp=ev.timestamp,
|
|
107
|
+
turn_index=ev.turn_index,
|
|
108
|
+
text=f"{tag} {summarize_orphan_result(ev)}",
|
|
109
|
+
phase="commentary",
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
elif ev.kind == "system" and ev.subtype == "compaction":
|
|
113
|
+
out.append(
|
|
114
|
+
AssistantMessage(
|
|
115
|
+
source="tandem",
|
|
116
|
+
turn_index=ev.turn_index,
|
|
117
|
+
text=f"{ATTRIBUTION['tandem']} (the {source_id} side compacted "
|
|
118
|
+
"its conversation history here)",
|
|
119
|
+
phase="commentary",
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
# thinking and other system events: dropped
|
|
123
|
+
return out
|
|
124
|
+
|
|
125
|
+
def flush_dangling(self, ctx: SessionContext) -> list[NormalizedEvent]:
|
|
126
|
+
"""Mapped call + placeholder-result pairs for every pending call.
|
|
127
|
+
Both replay APIs reject a call without a result, so a drained source
|
|
128
|
+
must never leave one behind. Clears ctx.pending_calls."""
|
|
129
|
+
target_id = ctx.direction.split("->")[1]
|
|
130
|
+
out: list[NormalizedEvent] = []
|
|
131
|
+
for call_id, stored in list(ctx.pending_calls.items()):
|
|
132
|
+
stored.pop("_structured", None)
|
|
133
|
+
call = ToolCall.model_validate(stored)
|
|
134
|
+
placeholder = ToolResult(
|
|
135
|
+
source=call.source, turn_index=call.turn_index,
|
|
136
|
+
call_id=call_id, output=toolmap.PLACEHOLDER_OUTPUT,
|
|
137
|
+
)
|
|
138
|
+
out.extend(toolmap.map_pair(call, placeholder, target_id))
|
|
139
|
+
ctx.pending_calls.clear()
|
|
140
|
+
return out
|