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/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."""
|