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/doctor.py ADDED
@@ -0,0 +1,265 @@
1
+ """tandem doctor: is this pairing healthy, and would both sessions resume?
2
+
3
+ validate_transcript() is the structural "dry resume" — it checks that a
4
+ session file is something its CLI can load without spending a model call.
5
+ run_doctor() layers version checks, pairing checks, sync-state checks and an
6
+ optional live mode (a real one-word resume per harness — costs two model
7
+ calls) on top.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import subprocess
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+
17
+ _CLAUDE_ENTRY_TYPES = {
18
+ "user", "assistant", "attachment", "system", "summary",
19
+ "queue-operation", "last-prompt", "progress", "file-history-snapshot",
20
+ "mode", # {"type":"mode","mode":"normal",...} observed on --resume runs
21
+ # uuid-less metadata entries claude 2.1.220 interleaves with conversation
22
+ "permission-mode", "ai-title", "file-history-delta", "pr-link",
23
+ "relocated", "worktree-state",
24
+ }
25
+ _CODEX_LINE_TYPES = {
26
+ "session_meta", "response_item", "event_msg", "turn_context",
27
+ "world_state", "compacted",
28
+ }
29
+
30
+
31
+ def validate_transcript(harness: str, path: Path, session_id: str | None) -> list[str]:
32
+ """Return a list of problems (empty = looks resumable)."""
33
+ problems: list[str] = []
34
+ try:
35
+ raw = path.read_bytes()
36
+ except OSError as exc:
37
+ return [f"cannot read transcript: {exc}"]
38
+ if not raw.strip():
39
+ return ["transcript is empty"]
40
+
41
+ entries: list[tuple[int, dict]] = []
42
+ for i, line in enumerate(raw.splitlines()):
43
+ if not line.strip():
44
+ continue
45
+ try:
46
+ obj = json.loads(line)
47
+ except json.JSONDecodeError:
48
+ problems.append(f"line {i}: not valid JSON")
49
+ continue
50
+ if not isinstance(obj, dict):
51
+ problems.append(f"line {i}: not a JSON object")
52
+ continue
53
+ entries.append((i, obj))
54
+ if not entries:
55
+ return problems + ["no parseable entries"]
56
+
57
+ if harness == "claude":
58
+ problems += _validate_claude(entries, session_id)
59
+ else:
60
+ problems += _validate_codex(entries, session_id)
61
+ return problems
62
+
63
+
64
+ def _validate_claude(entries: list[tuple[int, dict]], session_id: str | None) -> list[str]:
65
+ problems = []
66
+ seen_uuids: set[str] = set()
67
+ convo = 0
68
+ for i, e in entries:
69
+ etype = e.get("type")
70
+ if etype not in _CLAUDE_ENTRY_TYPES:
71
+ problems.append(f"line {i}: unknown entry type {etype!r}")
72
+ continue
73
+ if etype in ("user", "assistant"):
74
+ convo += 1
75
+ if session_id and e.get("sessionId") not in (None, session_id):
76
+ problems.append(
77
+ f"line {i}: sessionId {e.get('sessionId')!r} != {session_id!r}"
78
+ )
79
+ if not e.get("uuid"):
80
+ problems.append(f"line {i}: conversation entry missing uuid")
81
+ parent = e.get("parentUuid")
82
+ if parent and parent not in seen_uuids:
83
+ # claude tolerates forward/dangling parents poorly; flag it
84
+ problems.append(f"line {i}: parentUuid {parent!r} not seen earlier")
85
+ msg = e.get("message")
86
+ if not isinstance(msg, dict) or "content" not in msg:
87
+ problems.append(f"line {i}: malformed message")
88
+ if e.get("uuid"):
89
+ seen_uuids.add(e["uuid"])
90
+ if convo == 0:
91
+ problems.append("no conversation entries (user/assistant)")
92
+ return problems
93
+
94
+
95
+ def _validate_codex(entries: list[tuple[int, dict]], session_id: str | None) -> list[str]:
96
+ problems = []
97
+ first = entries[0][1]
98
+ if first.get("type") != "session_meta":
99
+ problems.append("first line is not session_meta")
100
+ else:
101
+ payload = first.get("payload") or {}
102
+ meta_id = payload.get("id") or payload.get("session_id")
103
+ if session_id and meta_id != session_id:
104
+ problems.append(f"session_meta id {meta_id!r} != {session_id!r}")
105
+ for i, e in entries:
106
+ etype = e.get("type")
107
+ if etype not in _CODEX_LINE_TYPES:
108
+ problems.append(f"line {i}: unknown line type {etype!r}")
109
+ continue
110
+ if "payload" not in e:
111
+ problems.append(f"line {i}: missing payload")
112
+ if etype == "response_item":
113
+ p = e.get("payload") or {}
114
+ if p.get("type") == "message" and not isinstance(p.get("content"), list):
115
+ problems.append(f"line {i}: message content is not a list")
116
+ if not any(e.get("type") == "response_item" for _, e in entries):
117
+ problems.append("no response_item entries (model context would be empty)")
118
+ return problems
119
+
120
+
121
+ # -- full doctor -------------------------------------------------------------
122
+
123
+
124
+ @dataclass
125
+ class Check:
126
+ status: str # 'ok' | 'warn' | 'fail'
127
+ message: str
128
+
129
+
130
+ @dataclass
131
+ class DoctorReport:
132
+ checks: list[Check] = field(default_factory=list)
133
+
134
+ def ok(self, msg: str) -> None:
135
+ self.checks.append(Check("ok", msg))
136
+
137
+ def warn(self, msg: str) -> None:
138
+ self.checks.append(Check("warn", msg))
139
+
140
+ def fail(self, msg: str) -> None:
141
+ self.checks.append(Check("fail", msg))
142
+
143
+ @property
144
+ def failed(self) -> bool:
145
+ return any(c.status == "fail" for c in self.checks)
146
+
147
+
148
+ def run_doctor(store, session, live: bool = False) -> DoctorReport:
149
+ from . import compat, ops, paths
150
+ from .harness import get_adapter
151
+
152
+ report = DoctorReport()
153
+
154
+ for hid in ("claude", "codex"):
155
+ adapter = get_adapter(hid)
156
+ v = adapter.detect_version()
157
+ if v is None:
158
+ report.fail(f"{adapter.display_name}: not found on PATH")
159
+ elif not adapter.version_supported(v):
160
+ report.warn(
161
+ f"{adapter.display_name}: version {v!r} outside supported range "
162
+ f"(tested against {compat.COMPAT[hid].tested}); session formats "
163
+ f"may have drifted — treat sync results with suspicion"
164
+ )
165
+ else:
166
+ report.ok(f"{adapter.display_name}: {v}")
167
+
168
+ if session is None:
169
+ report.fail("no tandem session for this directory (run `tandem` to start one)")
170
+ return report
171
+
172
+ report.ok(
173
+ f"paired session {session.tandem_id} (active: "
174
+ f"{get_adapter(session.active).display_name})"
175
+ )
176
+
177
+ transcripts: dict[str, Path | None] = {}
178
+ for hid in ("claude", "codex"):
179
+ adapter = get_adapter(hid)
180
+ sid = getattr(session, f"{hid}_session_id")
181
+ if not sid:
182
+ report.warn(f"{hid}: session id pending (harness has not run yet)")
183
+ transcripts[hid] = None
184
+ continue
185
+ path = adapter.transcript_path(session.cwd, sid)
186
+ transcripts[hid] = path
187
+ if path is None:
188
+ if hid == session.active:
189
+ report.warn(f"{hid}: transcript not created yet (first run pending)")
190
+ else:
191
+ report.fail(f"{hid}: shadow transcript missing for session {sid}")
192
+ continue
193
+ problems = validate_transcript(hid, path, sid)
194
+ if problems:
195
+ for p in problems[:5]:
196
+ report.fail(f"{hid}: {p}")
197
+ if len(problems) > 5:
198
+ report.fail(f"{hid}: ... {len(problems) - 5} more problems")
199
+ else:
200
+ report.ok(f"{hid}: transcript resumable by structure ({path.name})")
201
+
202
+ for source in ("claude", "codex"):
203
+ cursor = store.get_cursor(session.tandem_id, source)
204
+ if cursor.pending.get("intent"):
205
+ report.warn(
206
+ f"sync from {source}: unresolved write intent (crash during "
207
+ f"append); it will be resolved automatically on next sync"
208
+ )
209
+ if cursor.failed_turns:
210
+ report.warn(
211
+ f"sync from {source}: {cursor.failed_turns} turn(s) fell back "
212
+ f"to placeholders"
213
+ )
214
+ behind = ops.unsynced_lines(session, store, source)
215
+ if behind and source == session.active:
216
+ report.warn(
217
+ f"sync from {source}: {behind} line(s) awaiting translation "
218
+ f"(run `tandem sync`)"
219
+ )
220
+
221
+ qdir = paths.quarantine_dir(session.tandem_id)
222
+ qfiles = sorted(qdir.iterdir()) if qdir.is_dir() else []
223
+ if qfiles:
224
+ report.warn(
225
+ f"{len(qfiles)} quarantined raw entr(y/ies) under {qdir}"
226
+ )
227
+
228
+ if live:
229
+ _live_resume_checks(report, session, transcripts)
230
+ return report
231
+
232
+
233
+ def _live_resume_checks(report: DoctorReport, session, transcripts) -> None:
234
+ """Real resumes: one tiny model call per harness, run in the session cwd."""
235
+ from .harness import get_adapter
236
+
237
+ prompt = "tandem doctor live check - reply with exactly: ok"
238
+ if session.claude_session_id and transcripts.get("claude"):
239
+ argv = get_adapter("claude").oneoff_argv(session.claude_session_id, prompt)
240
+ _live_one(report, "claude", argv, session.cwd)
241
+ else:
242
+ report.warn("claude: skipping live resume (no transcript yet)")
243
+ if session.codex_session_id and transcripts.get("codex"):
244
+ argv = get_adapter("codex").oneoff_argv(session.codex_session_id, prompt)
245
+ _live_one(report, "codex", argv, session.cwd)
246
+ else:
247
+ report.warn("codex: skipping live resume (no transcript yet)")
248
+
249
+
250
+ def _live_one(report: DoctorReport, hid: str, argv: list[str], cwd: str) -> None:
251
+ try:
252
+ out = subprocess.run(
253
+ argv, cwd=cwd, capture_output=True, text=True, timeout=180
254
+ )
255
+ except (OSError, subprocess.TimeoutExpired) as exc:
256
+ report.fail(f"{hid}: live resume errored: {exc}")
257
+ return
258
+ if out.returncode == 0:
259
+ report.ok(f"{hid}: live resume succeeded")
260
+ else:
261
+ tail = (out.stderr or out.stdout or "").strip().splitlines()
262
+ report.fail(
263
+ f"{hid}: live resume exited {out.returncode}"
264
+ + (f" - {tail[-1][:160]}" if tail else "")
265
+ )
tandem/events.py ADDED
@@ -0,0 +1,107 @@
1
+ """Normalized internal event model.
2
+
3
+ This schema is derived from the entries actually written by claude 2.1.220
4
+ and codex-cli 0.145.0 (docs/formats.md), not from any published spec. It is
5
+ the hub of the sync pipeline: each harness adapter parses its native entries
6
+ into these events, and renders these events into its native entries.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Annotated, Any, Literal, Union
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field
14
+
15
+ Agent = Literal["claude", "codex", "tandem", "user"]
16
+
17
+
18
+ class _EventBase(BaseModel):
19
+ model_config = ConfigDict(extra="forbid")
20
+
21
+ source: Agent
22
+ timestamp: str | None = None
23
+ turn_index: int = 0
24
+ raw_line_index: int | None = None
25
+
26
+
27
+ class UserMessage(_EventBase):
28
+ kind: Literal["user_message"] = "user_message"
29
+ text: str
30
+
31
+
32
+ class AssistantMessage(_EventBase):
33
+ kind: Literal["assistant_message"] = "assistant_message"
34
+ text: str
35
+ phase: Literal["final", "commentary"] = "final"
36
+ model: str | None = None
37
+
38
+
39
+ class ToolCall(_EventBase):
40
+ """A tool invocation by the active model. Paired with its ToolResult and
41
+ re-expressed in the shadow harness's own tool vocabulary, then rendered
42
+ as a native call/result pair (see converter policy and toolmap)."""
43
+
44
+ kind: Literal["tool_call"] = "tool_call"
45
+ call_id: str
46
+ tool: str
47
+ arguments: dict[str, Any] | str
48
+
49
+
50
+ class ToolResult(_EventBase):
51
+ kind: Literal["tool_result"] = "tool_result"
52
+ call_id: str | None = None
53
+ tool: str | None = None
54
+ output: str = ""
55
+ is_error: bool = False
56
+ # Harness-specific structured result when available (e.g. Claude's
57
+ # toolUseResult with structuredPatch, or Codex patch_apply_end changes).
58
+ structured: dict[str, Any] | None = None
59
+
60
+
61
+ class Thinking(_EventBase):
62
+ """Reasoning blocks. Claude's are signature-bound, Codex's encrypted;
63
+ neither is portable, so converters drop these."""
64
+
65
+ kind: Literal["thinking"] = "thinking"
66
+
67
+
68
+ class SystemEvent(_EventBase):
69
+ """Session meta, hooks, attachments, compaction markers, token counts —
70
+ anything that is not conversation content. Converters skip these (or
71
+ summarize compaction)."""
72
+
73
+ kind: Literal["system"] = "system"
74
+ subtype: str = ""
75
+ text: str = ""
76
+
77
+
78
+ NormalizedEvent = Annotated[
79
+ Union[UserMessage, AssistantMessage, ToolCall, ToolResult, Thinking, SystemEvent],
80
+ Field(discriminator="kind"),
81
+ ]
82
+
83
+
84
+ class SessionContext(BaseModel):
85
+ """Mutable per-direction translation context, persisted in the sync
86
+ cursor so a restart resumes mid-turn without losing tool-call pairings."""
87
+
88
+ model_config = ConfigDict(extra="forbid")
89
+
90
+ tandem_id: str
91
+ cwd: str
92
+ direction: Literal["claude->codex", "codex->claude"]
93
+ turn_index: int = 0
94
+ # call_id -> serialized ToolCall event awaiting its result
95
+ pending_calls: dict[str, dict[str, Any]] = Field(default_factory=dict)
96
+ # uuid of the last entry in the Claude shadow file (parentUuid chain)
97
+ claude_leaf_uuid: str | None = None
98
+ # message.id shared by the current contiguous run of rendered assistant
99
+ # entries; any rendered user-side entry resets it (a real transcript has
100
+ # one id per API response, and regrouping by id must not strand a
101
+ # tool_use away from its tool_result)
102
+ claude_run_msg_id: str | None = None
103
+ claude_session_id: str | None = None
104
+ codex_session_id: str | None = None
105
+ # model claude itself last used; rendered assistant entries carry it so
106
+ # `claude --resume` can restore the session model (it rejects "<synced>")
107
+ claude_model: str | None = None
@@ -0,0 +1,16 @@
1
+ from .base import HarnessAdapter
2
+ from .claude_code import ClaudeCodeAdapter
3
+ from .codex import CodexAdapter
4
+
5
+ ADAPTERS: dict[str, HarnessAdapter] = {
6
+ "claude": ClaudeCodeAdapter(),
7
+ "codex": CodexAdapter(),
8
+ }
9
+
10
+
11
+ def get_adapter(harness_id: str) -> HarnessAdapter:
12
+ return ADAPTERS[harness_id]
13
+
14
+
15
+ def other(harness_id: str) -> str:
16
+ return "codex" if harness_id == "claude" else "claude"
tandem/harness/base.py ADDED
@@ -0,0 +1,87 @@
1
+ """Per-harness adapter interface.
2
+
3
+ Each supported CLI gets exactly one module implementing this interface, so a
4
+ session-format change in one tool touches one file. The interface is generic
5
+ over harnesses (a future Gemini CLI adapter would slot in here) but v1 ships
6
+ only claude_code and codex.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from abc import ABC, abstractmethod
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from ..events import NormalizedEvent, SessionContext
16
+
17
+
18
+ class HarnessAdapter(ABC):
19
+ id: str # 'claude' | 'codex'
20
+ display_name: str
21
+ binary: str # executable name on PATH
22
+
23
+ # -- environment ---------------------------------------------------------
24
+
25
+ @abstractmethod
26
+ def detect_version(self) -> str | None:
27
+ """Raw --version output of the installed CLI, or None if missing."""
28
+
29
+ @abstractmethod
30
+ def version_supported(self, version_text: str) -> bool: ...
31
+
32
+ # -- session files -------------------------------------------------------
33
+
34
+ @abstractmethod
35
+ def transcript_path(self, cwd: str, session_id: str) -> Path | None:
36
+ """Expected transcript location for (cwd, session id). For harnesses
37
+ whose filename embeds a creation timestamp (codex) this returns the
38
+ discovered existing file, or None if not created yet."""
39
+
40
+ @abstractmethod
41
+ def mint_session_id(self) -> str:
42
+ """Mint a new session id in this harness's native id format."""
43
+
44
+ @abstractmethod
45
+ def create_shadow_transcript(
46
+ self, cwd: str, session_id: str, ctx: SessionContext, note: str
47
+ ) -> Path:
48
+ """Create a resume-ready transcript file containing only tandem-seeded
49
+ content (no model was involved). Used when this harness starts life as
50
+ the shadow."""
51
+
52
+ # -- launching the real CLI ----------------------------------------------
53
+
54
+ @abstractmethod
55
+ def interactive_argv(self, session_id: str | None, fresh: bool) -> list[str]:
56
+ """argv to run the harness interactively, pinned/resumed to session_id.
57
+ fresh=True means the session file does not exist yet."""
58
+
59
+ @abstractmethod
60
+ def oneoff_argv(self, session_id: str, prompt: str) -> list[str]:
61
+ """argv for a single non-interactive turn appended to session_id's
62
+ native session (claude -p / codex exec resume)."""
63
+
64
+ @abstractmethod
65
+ def hook_argv_extra(self, sentinel: Path) -> list[str]:
66
+ """Extra per-invocation argv that wires a turn-complete hook touching
67
+ `sentinel`, without mutating the user's persistent config. The tailer
68
+ uses the sentinel as a flush signal; fs-watching remains the
69
+ fallback."""
70
+
71
+ # -- transcript parsing / rendering (the converter halves) ---------------
72
+
73
+ @abstractmethod
74
+ def parse_entry(self, raw: dict[str, Any], ctx: SessionContext) -> list[NormalizedEvent]:
75
+ """Translate one native transcript entry into normalized events.
76
+ Unknown/irrelevant entry shapes yield SystemEvents (never raise)."""
77
+
78
+ @abstractmethod
79
+ def render_events(
80
+ self, events: list[NormalizedEvent], ctx: SessionContext
81
+ ) -> list[dict[str, Any]]:
82
+ """Render normalized events into native transcript entries ready to
83
+ append to this harness's session file (the shadow direction)."""
84
+
85
+ @abstractmethod
86
+ def render_placeholder(self, text: str, ctx: SessionContext) -> list[dict[str, Any]]:
87
+ """Native entries for an untranslatable-turn placeholder."""