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 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