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/memory_sync.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Instruction/memory file sync: CLAUDE.md <-> AGENTS.md, plus opt-in MCP
|
|
2
|
+
server copying.
|
|
3
|
+
|
|
4
|
+
v1 mapping: shared content lives in a marker-delimited common block; text
|
|
5
|
+
outside the markers is tool-specific and never touched.
|
|
6
|
+
|
|
7
|
+
<!-- tandem:shared:begin -->
|
|
8
|
+
...shared instructions...
|
|
9
|
+
<!-- tandem:shared:end -->
|
|
10
|
+
|
|
11
|
+
Rules (documented in README):
|
|
12
|
+
- A file without markers contributes its ENTIRE content as the shared block
|
|
13
|
+
but is itself never modified (we don't rewrite user files to add markers).
|
|
14
|
+
- The counterpart file is created/refreshed with the shared block wrapped in
|
|
15
|
+
markers; anything it keeps outside the markers survives.
|
|
16
|
+
- When both files carry markers, the newer mtime wins the shared block.
|
|
17
|
+
- When both exist and neither has markers we refuse to guess and report it.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import re
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from . import paths
|
|
28
|
+
from .util import write_file_atomic
|
|
29
|
+
|
|
30
|
+
BEGIN = "<!-- tandem:shared:begin -->"
|
|
31
|
+
END = "<!-- tandem:shared:end -->"
|
|
32
|
+
|
|
33
|
+
_BLOCK_RE = re.compile(
|
|
34
|
+
re.escape(BEGIN) + r"\n?(.*?)\n?" + re.escape(END), re.S
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
FILES = {"claude": "CLAUDE.md", "codex": "AGENTS.md"}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class MemorySyncReport:
|
|
42
|
+
actions: list[str] = field(default_factory=list)
|
|
43
|
+
warnings: list[str] = field(default_factory=list)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _shared_block(text: str) -> str | None:
|
|
47
|
+
m = _BLOCK_RE.search(text)
|
|
48
|
+
return m.group(1).strip() if m else None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _replace_block(text: str, shared: str) -> str:
|
|
52
|
+
new_block = f"{BEGIN}\n{shared}\n{END}"
|
|
53
|
+
if _BLOCK_RE.search(text):
|
|
54
|
+
# replacement via callable: content must be literal, never treated
|
|
55
|
+
# as backreference syntax
|
|
56
|
+
return _BLOCK_RE.sub(lambda _m: new_block, text, count=1)
|
|
57
|
+
prefix = text.rstrip() + "\n\n" if text.strip() else ""
|
|
58
|
+
return prefix + new_block + "\n"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def sync_memory_files(cwd: str) -> MemorySyncReport:
|
|
62
|
+
"""Reflect shared instructions between CLAUDE.md and AGENTS.md in cwd."""
|
|
63
|
+
report = MemorySyncReport()
|
|
64
|
+
root = Path(cwd)
|
|
65
|
+
paths_ = {k: root / v for k, v in FILES.items()}
|
|
66
|
+
texts = {}
|
|
67
|
+
for k, p in paths_.items():
|
|
68
|
+
try:
|
|
69
|
+
texts[k] = p.read_text() if p.exists() else None
|
|
70
|
+
except OSError as exc:
|
|
71
|
+
report.warnings.append(f"cannot read {p.name}: {exc}")
|
|
72
|
+
return report
|
|
73
|
+
|
|
74
|
+
existing = [k for k, t in texts.items() if t is not None]
|
|
75
|
+
if not existing:
|
|
76
|
+
return report
|
|
77
|
+
|
|
78
|
+
if len(existing) == 1:
|
|
79
|
+
src = existing[0]
|
|
80
|
+
dst = "codex" if src == "claude" else "claude"
|
|
81
|
+
shared = _shared_block(texts[src])
|
|
82
|
+
if shared is None:
|
|
83
|
+
shared = texts[src].strip()
|
|
84
|
+
if not shared:
|
|
85
|
+
return report
|
|
86
|
+
write_file_atomic(paths_[dst], _replace_block("", shared))
|
|
87
|
+
report.actions.append(
|
|
88
|
+
f"created {FILES[dst]} from {FILES[src]} (shared block)"
|
|
89
|
+
)
|
|
90
|
+
return report
|
|
91
|
+
|
|
92
|
+
blocks = {k: _shared_block(texts[k]) for k in ("claude", "codex")}
|
|
93
|
+
if blocks["claude"] is None and blocks["codex"] is None:
|
|
94
|
+
report.warnings.append(
|
|
95
|
+
f"both {FILES['claude']} and {FILES['codex']} exist without tandem "
|
|
96
|
+
f"markers; not merging. Wrap the shared part of one of them in "
|
|
97
|
+
f"'{BEGIN}' / '{END}' to enable sync."
|
|
98
|
+
)
|
|
99
|
+
return report
|
|
100
|
+
|
|
101
|
+
# Effective shared content per side: the marker block, or (for a
|
|
102
|
+
# marker-less file) its whole content.
|
|
103
|
+
effective = {
|
|
104
|
+
k: blocks[k] if blocks[k] is not None else texts[k].strip()
|
|
105
|
+
for k in ("claude", "codex")
|
|
106
|
+
}
|
|
107
|
+
if effective["claude"] == effective["codex"]:
|
|
108
|
+
return report
|
|
109
|
+
|
|
110
|
+
newer = max(("claude", "codex"), key=lambda k: paths_[k].stat().st_mtime)
|
|
111
|
+
older = "codex" if newer == "claude" else "claude"
|
|
112
|
+
if blocks[older] is None:
|
|
113
|
+
# never rewrite a user file that has no markers
|
|
114
|
+
report.warnings.append(
|
|
115
|
+
f"{FILES[older]} has no tandem markers; not updating it from "
|
|
116
|
+
f"{FILES[newer]}."
|
|
117
|
+
)
|
|
118
|
+
return report
|
|
119
|
+
write_file_atomic(
|
|
120
|
+
paths_[older], _replace_block(texts[older], effective[newer])
|
|
121
|
+
)
|
|
122
|
+
report.actions.append(
|
|
123
|
+
f"updated shared block in {FILES[older]} from {FILES[newer]}"
|
|
124
|
+
)
|
|
125
|
+
return report
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# -- MCP config copy (opt-in via `tandem sync-mcp`) --------------------------
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def claude_mcp_servers() -> dict[str, dict]:
|
|
132
|
+
"""Global stdio MCP servers from ~/.claude.json."""
|
|
133
|
+
cfg = Path.home() / ".claude.json"
|
|
134
|
+
try:
|
|
135
|
+
data = json.loads(cfg.read_text())
|
|
136
|
+
except (OSError, json.JSONDecodeError):
|
|
137
|
+
return {}
|
|
138
|
+
servers = data.get("mcpServers") or {}
|
|
139
|
+
return {
|
|
140
|
+
name: s
|
|
141
|
+
for name, s in servers.items()
|
|
142
|
+
if isinstance(s, dict) and s.get("command")
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def codex_mcp_servers() -> dict[str, dict]:
|
|
147
|
+
"""[mcp_servers.*] from ~/.codex/config.toml."""
|
|
148
|
+
import tomllib
|
|
149
|
+
|
|
150
|
+
cfg = paths.codex_home() / "config.toml"
|
|
151
|
+
try:
|
|
152
|
+
with open(cfg, "rb") as f:
|
|
153
|
+
data = tomllib.load(f)
|
|
154
|
+
except (OSError, ValueError):
|
|
155
|
+
return {}
|
|
156
|
+
servers = data.get("mcp_servers") or {}
|
|
157
|
+
return {
|
|
158
|
+
name: s
|
|
159
|
+
for name, s in servers.items()
|
|
160
|
+
if isinstance(s, dict) and s.get("command")
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _toml_str(value: str) -> str:
|
|
165
|
+
return json.dumps(value) # JSON string escaping is valid TOML
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def copy_mcp(report: MemorySyncReport | None = None) -> MemorySyncReport:
|
|
169
|
+
"""Bidirectional additive copy of stdio MCP servers: each side gains the
|
|
170
|
+
servers it is missing; existing definitions are never modified."""
|
|
171
|
+
report = report or MemorySyncReport()
|
|
172
|
+
claude_servers = claude_mcp_servers()
|
|
173
|
+
codex_servers = codex_mcp_servers()
|
|
174
|
+
|
|
175
|
+
# claude -> codex: append TOML sections for missing servers
|
|
176
|
+
missing_in_codex = {
|
|
177
|
+
n: s for n, s in claude_servers.items() if n not in codex_servers
|
|
178
|
+
}
|
|
179
|
+
if missing_in_codex:
|
|
180
|
+
cfg = paths.codex_home() / "config.toml"
|
|
181
|
+
text = cfg.read_text() if cfg.exists() else ""
|
|
182
|
+
chunks = [text.rstrip() + "\n" if text.strip() else ""]
|
|
183
|
+
for name, s in missing_in_codex.items():
|
|
184
|
+
chunks.append(f"\n# added by tandem sync-mcp\n[mcp_servers.{name}]")
|
|
185
|
+
chunks.append(f"command = {_toml_str(s['command'])}")
|
|
186
|
+
args = s.get("args") or []
|
|
187
|
+
chunks.append(
|
|
188
|
+
"args = [" + ", ".join(_toml_str(a) for a in args) + "]"
|
|
189
|
+
)
|
|
190
|
+
env = s.get("env") or {}
|
|
191
|
+
if env:
|
|
192
|
+
chunks.append(f"\n[mcp_servers.{name}.env]")
|
|
193
|
+
for k, v in env.items():
|
|
194
|
+
chunks.append(f"{k} = {_toml_str(str(v))}")
|
|
195
|
+
report.actions.append(f"added MCP server {name!r} to codex config.toml")
|
|
196
|
+
write_file_atomic(cfg, "\n".join(chunks) + "\n")
|
|
197
|
+
|
|
198
|
+
# codex -> claude: merge into ~/.claude.json mcpServers
|
|
199
|
+
missing_in_claude = {
|
|
200
|
+
n: s for n, s in codex_servers.items() if n not in claude_servers
|
|
201
|
+
}
|
|
202
|
+
if missing_in_claude:
|
|
203
|
+
cfg = Path.home() / ".claude.json"
|
|
204
|
+
try:
|
|
205
|
+
data = json.loads(cfg.read_text()) if cfg.exists() else {}
|
|
206
|
+
except json.JSONDecodeError:
|
|
207
|
+
report.warnings.append("~/.claude.json is not valid JSON; skipping")
|
|
208
|
+
return report
|
|
209
|
+
servers = data.setdefault("mcpServers", {})
|
|
210
|
+
for name, s in missing_in_claude.items():
|
|
211
|
+
entry: dict = {"command": s["command"], "args": list(s.get("args") or [])}
|
|
212
|
+
if s.get("env"):
|
|
213
|
+
entry["env"] = {k: str(v) for k, v in s["env"].items()}
|
|
214
|
+
servers[name] = entry
|
|
215
|
+
report.actions.append(f"added MCP server {name!r} to ~/.claude.json")
|
|
216
|
+
write_file_atomic(cfg, json.dumps(data, indent=2) + "\n")
|
|
217
|
+
|
|
218
|
+
if not report.actions:
|
|
219
|
+
report.actions.append("MCP configs already in sync (nothing copied)")
|
|
220
|
+
return report
|
tandem/ops.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Session operations shared by the CLI: catch-up drain, cursor
|
|
2
|
+
fast-forward, switch, and one-off routing.
|
|
3
|
+
|
|
4
|
+
Echo suppression: every entry tandem appends to a shadow is, by
|
|
5
|
+
construction, already represented in the other file. So whenever a harness
|
|
6
|
+
changes role (switch, or a one-off turn routed to it), we first drain the
|
|
7
|
+
outgoing source's unsynced tail, then fast-forward the incoming source's
|
|
8
|
+
cursor to end-of-file — from that point only genuinely new turns flow, and
|
|
9
|
+
nothing synced ever bounces back.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import subprocess
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from . import paths
|
|
19
|
+
from .harness import get_adapter, other
|
|
20
|
+
from .runner import TailLoop, await_codex_rollout
|
|
21
|
+
from .state import PairedSession, StateStore
|
|
22
|
+
from .sync import SyncEngine, SyncSetupError
|
|
23
|
+
|
|
24
|
+
# seam for tests (patching subprocess.run itself would also intercept the
|
|
25
|
+
# CLI version probes)
|
|
26
|
+
_run = subprocess.run
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def source_transcript(session: PairedSession, source: str) -> Path | None:
|
|
30
|
+
sid = getattr(session, f"{source}_session_id")
|
|
31
|
+
if not sid:
|
|
32
|
+
return None
|
|
33
|
+
adapter = get_adapter(source)
|
|
34
|
+
return adapter.transcript_path(session.cwd, sid)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def drain_source(
|
|
38
|
+
store: StateStore, session: PairedSession, source: str,
|
|
39
|
+
*, flush_dangling: bool = False,
|
|
40
|
+
) -> int:
|
|
41
|
+
"""Translate any unsynced tail of `source`'s file into the other file.
|
|
42
|
+
Pure local file I/O. Returns lines consumed. With flush_dangling=True,
|
|
43
|
+
close any still-unpaired tool calls with placeholder results afterwards
|
|
44
|
+
(required when the source is being handed off: both replay APIs reject
|
|
45
|
+
a dangling call)."""
|
|
46
|
+
transcript = source_transcript(session, source)
|
|
47
|
+
if transcript is None:
|
|
48
|
+
return 0
|
|
49
|
+
engine = SyncEngine(store, session, source)
|
|
50
|
+
loop = TailLoop(store, session, source, transcript, engine)
|
|
51
|
+
total = 0
|
|
52
|
+
while True:
|
|
53
|
+
n = loop.drain()
|
|
54
|
+
total += n
|
|
55
|
+
if n == 0:
|
|
56
|
+
break
|
|
57
|
+
if loop.errors:
|
|
58
|
+
raise SyncSetupError("; ".join(loop.errors))
|
|
59
|
+
if flush_dangling:
|
|
60
|
+
engine.flush_dangling(loop.ctx, loop.cursor)
|
|
61
|
+
return total
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def fast_forward(store: StateStore, session: PairedSession, source: str) -> None:
|
|
65
|
+
"""Mark everything currently in `source`'s file as already-synced."""
|
|
66
|
+
transcript = source_transcript(session, source)
|
|
67
|
+
cursor = store.get_cursor(session.tandem_id, source)
|
|
68
|
+
if transcript is None:
|
|
69
|
+
cursor.byte_offset = 0
|
|
70
|
+
cursor.line_index = 0
|
|
71
|
+
else:
|
|
72
|
+
data = transcript.read_bytes()
|
|
73
|
+
cursor.byte_offset = len(data)
|
|
74
|
+
cursor.line_index = data.count(b"\n")
|
|
75
|
+
cursor.pending.pop("intent", None)
|
|
76
|
+
store.save_cursor(cursor)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def unsynced_lines(session: PairedSession, store: StateStore, source: str) -> int:
|
|
80
|
+
transcript = source_transcript(session, source)
|
|
81
|
+
if transcript is None:
|
|
82
|
+
return 0
|
|
83
|
+
cursor = store.get_cursor(session.tandem_id, source)
|
|
84
|
+
try:
|
|
85
|
+
data = transcript.read_bytes()
|
|
86
|
+
except OSError:
|
|
87
|
+
return 0
|
|
88
|
+
if len(data) <= cursor.byte_offset:
|
|
89
|
+
return 0
|
|
90
|
+
return data[cursor.byte_offset :].count(b"\n")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def switch_session(store: StateStore, session: PairedSession):
|
|
94
|
+
"""Flip active/shadow. Returns (new_active, problems-with-new-active-file,
|
|
95
|
+
memory-sync report). Instant: catch-up drain of the old source + cursor
|
|
96
|
+
fast-forward, no bulk re-conversion."""
|
|
97
|
+
from .doctor import validate_transcript
|
|
98
|
+
|
|
99
|
+
old_active, new_active = session.active, session.shadow
|
|
100
|
+
|
|
101
|
+
# If codex never ran (id pending), its shadow file does not exist yet;
|
|
102
|
+
# create it now so the flip has something to resume.
|
|
103
|
+
if new_active == "codex" and not session.codex_session_id:
|
|
104
|
+
_create_codex_shadow_late(store, session)
|
|
105
|
+
session = store.get_session(session.tandem_id) or session
|
|
106
|
+
|
|
107
|
+
drain_source(store, session, old_active, flush_dangling=True)
|
|
108
|
+
fast_forward(store, session, new_active)
|
|
109
|
+
store.set_active(session.tandem_id, new_active)
|
|
110
|
+
|
|
111
|
+
from .memory_sync import sync_memory_files
|
|
112
|
+
|
|
113
|
+
memory_report = sync_memory_files(session.cwd)
|
|
114
|
+
|
|
115
|
+
problems: list[str] = []
|
|
116
|
+
transcript = source_transcript(session, new_active)
|
|
117
|
+
if transcript is None:
|
|
118
|
+
if new_active == "claude" and session.claude_session_id:
|
|
119
|
+
# claude never launched; it will be created fresh on next run
|
|
120
|
+
problems = []
|
|
121
|
+
else:
|
|
122
|
+
problems = ["transcript for newly active harness does not exist yet"]
|
|
123
|
+
else:
|
|
124
|
+
problems = validate_transcript(new_active, transcript,
|
|
125
|
+
getattr(session, f"{new_active}_session_id"))
|
|
126
|
+
return new_active, problems, memory_report
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _create_codex_shadow_late(store: StateStore, session: PairedSession) -> None:
|
|
130
|
+
from .constants import SEED_NOTE
|
|
131
|
+
from .runner import ctx_from_cursor
|
|
132
|
+
|
|
133
|
+
adapter = get_adapter("codex")
|
|
134
|
+
sid = adapter.mint_session_id()
|
|
135
|
+
cursor = store.get_cursor(session.tandem_id, session.active)
|
|
136
|
+
ctx = ctx_from_cursor(session, session.active, cursor)
|
|
137
|
+
ctx.codex_session_id = sid
|
|
138
|
+
note = SEED_NOTE.format(
|
|
139
|
+
tandem_id=session.tandem_id, other=get_adapter(session.active).display_name
|
|
140
|
+
)
|
|
141
|
+
adapter.create_shadow_transcript(session.cwd, sid, ctx, note)
|
|
142
|
+
store.set_native_session_id(session.tandem_id, "codex", sid)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def run_oneoff(
|
|
146
|
+
store: StateStore, session: PairedSession, target: str, prompt: str
|
|
147
|
+
) -> int:
|
|
148
|
+
"""Route one prompt to `target` as a single native non-interactive turn,
|
|
149
|
+
then sync that turn into the other file. Exactly one model (target's) is
|
|
150
|
+
invoked."""
|
|
151
|
+
adapter = get_adapter(target)
|
|
152
|
+
sid = getattr(session, f"{target}_session_id")
|
|
153
|
+
|
|
154
|
+
# Catch up the active side first, then mark the target's whole file as
|
|
155
|
+
# known so only the new turn flows back afterwards. (When target IS the
|
|
156
|
+
# active side there is nothing to fast-forward — its cursor is live.)
|
|
157
|
+
drain_source(store, session, session.active, flush_dangling=True)
|
|
158
|
+
if target != session.active and sid and source_transcript(session, target) is not None:
|
|
159
|
+
fast_forward(store, session, target)
|
|
160
|
+
|
|
161
|
+
started = time.time()
|
|
162
|
+
if target == "codex" and not sid:
|
|
163
|
+
# codex never ran: no session to resume; a fresh exec creates one.
|
|
164
|
+
argv = [adapter.binary, "exec", "--skip-git-repo-check", prompt]
|
|
165
|
+
else:
|
|
166
|
+
argv = adapter.oneoff_argv(sid, prompt)
|
|
167
|
+
code = _run(argv, cwd=session.cwd).returncode
|
|
168
|
+
|
|
169
|
+
if target == "codex" and not sid:
|
|
170
|
+
rollout = await_codex_rollout(session.cwd, started, timeout=10)
|
|
171
|
+
if rollout:
|
|
172
|
+
new_sid = paths.codex_rollout_session_id(rollout)
|
|
173
|
+
if new_sid:
|
|
174
|
+
store.set_native_session_id(session.tandem_id, "codex", new_sid)
|
|
175
|
+
session = store.get_session(session.tandem_id) or session
|
|
176
|
+
fast_forward_to_zero = store.get_cursor(session.tandem_id, "codex")
|
|
177
|
+
fast_forward_to_zero.byte_offset = 0
|
|
178
|
+
fast_forward_to_zero.line_index = 0
|
|
179
|
+
store.save_cursor(fast_forward_to_zero)
|
|
180
|
+
|
|
181
|
+
# Echo suppression (see the module docstring): this drain appends
|
|
182
|
+
# tandem's translation of the target's turn to the OTHER harness's file.
|
|
183
|
+
# Those appends are by construction already represented in the target's
|
|
184
|
+
# file, so the echo side's cursor has to move past them — otherwise its
|
|
185
|
+
# next drain translates them straight back, duplicating call ids and text.
|
|
186
|
+
echo_side = other(target)
|
|
187
|
+
echo_pre_size = _file_size(source_transcript(session, echo_side))
|
|
188
|
+
echo_pre_offset = store.get_cursor(session.tandem_id, echo_side).byte_offset
|
|
189
|
+
|
|
190
|
+
drain_source(store, session, target, flush_dangling=True)
|
|
191
|
+
|
|
192
|
+
# Only when the echo side was fully synced before the drain is everything
|
|
193
|
+
# now in its file known to be ours. If it had an unsynced tail (a
|
|
194
|
+
# concurrent writer), fast-forwarding would swallow a live turn: leave the
|
|
195
|
+
# cursor alone and let the normal drain pick both up.
|
|
196
|
+
if echo_pre_size is not None and echo_pre_offset == echo_pre_size:
|
|
197
|
+
fast_forward(store, session, echo_side)
|
|
198
|
+
return code
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _file_size(path: Path | None) -> int | None:
|
|
202
|
+
if path is None:
|
|
203
|
+
return None
|
|
204
|
+
try:
|
|
205
|
+
return path.stat().st_size
|
|
206
|
+
except OSError:
|
|
207
|
+
return None
|
tandem/paths.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Filesystem locations for tandem and both wrapped harnesses.
|
|
2
|
+
|
|
3
|
+
Everything here was derived by inspecting the files written by the installed
|
|
4
|
+
CLIs (claude 2.1.220, codex-cli 0.145.0) — see docs/formats.md.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def tandem_home() -> Path:
|
|
15
|
+
return Path(os.environ.get("TANDEM_HOME", Path.home() / ".tandem"))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def state_db_path() -> Path:
|
|
19
|
+
return tandem_home() / "state.db"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def quarantine_dir(tandem_id: str) -> Path:
|
|
23
|
+
return tandem_home() / "quarantine" / tandem_id
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def log_dir() -> Path:
|
|
27
|
+
return tandem_home() / "logs"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# --- Claude Code ------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
def claude_home() -> Path:
|
|
33
|
+
return Path(os.environ.get("CLAUDE_CONFIG_DIR", Path.home() / ".claude"))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def claude_munge_cwd(cwd: str | Path) -> str:
|
|
37
|
+
"""Claude Code names each project dir after the cwd with every character
|
|
38
|
+
outside [A-Za-z0-9] replaced by '-'. Observed: /private/tmp/claude-501/x
|
|
39
|
+
-> -private-tmp-claude-501-x (leading '/' becomes a leading '-',
|
|
40
|
+
pre-existing '-' survive unchanged)."""
|
|
41
|
+
return re.sub(r"[^A-Za-z0-9]", "-", str(cwd))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def claude_project_dir(cwd: str | Path) -> Path:
|
|
45
|
+
return claude_home() / "projects" / claude_munge_cwd(cwd)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def claude_transcript_path(cwd: str | Path, session_id: str) -> Path:
|
|
49
|
+
return claude_project_dir(cwd) / f"{session_id}.jsonl"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# --- Codex ------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
def codex_home() -> Path:
|
|
55
|
+
return Path(os.environ.get("CODEX_HOME", Path.home() / ".codex"))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def codex_sessions_dir() -> Path:
|
|
59
|
+
return codex_home() / "sessions"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_ROLLOUT_RE = re.compile(
|
|
63
|
+
r"rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-([0-9a-f-]{36})\.jsonl$"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def codex_rollout_session_id(path: Path) -> str | None:
|
|
68
|
+
"""The session UUID is embedded in the rollout filename."""
|
|
69
|
+
m = _ROLLOUT_RE.search(path.name)
|
|
70
|
+
return m.group(1) if m else None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def find_codex_rollout(session_id: str) -> Path | None:
|
|
74
|
+
"""Locate a rollout file by session id. Filenames embed the id, so this
|
|
75
|
+
never needs to open files."""
|
|
76
|
+
root = codex_sessions_dir()
|
|
77
|
+
if not root.is_dir():
|
|
78
|
+
return None
|
|
79
|
+
for p in root.glob(f"**/rollout-*-{session_id}.jsonl"):
|
|
80
|
+
return p
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def iter_codex_rollouts_newest_first() -> list[Path]:
|
|
85
|
+
root = codex_sessions_dir()
|
|
86
|
+
if not root.is_dir():
|
|
87
|
+
return []
|
|
88
|
+
return sorted(
|
|
89
|
+
root.glob("**/rollout-*.jsonl"),
|
|
90
|
+
key=lambda p: p.stat().st_mtime,
|
|
91
|
+
reverse=True,
|
|
92
|
+
)
|
tandem/ptyrun.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""PTY passthrough: run the real harness CLI with its untouched native UX.
|
|
2
|
+
|
|
3
|
+
The child gets a real pty (raw-mode stdin, terminal resize via SIGWINCH
|
|
4
|
+
forwarding, control bytes delivered through the pty line discipline). Tandem
|
|
5
|
+
never reads meaning from the terminal stream — transcript files are the
|
|
6
|
+
source of truth — so this module just pumps bytes.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import io
|
|
12
|
+
import os
|
|
13
|
+
import select
|
|
14
|
+
import signal
|
|
15
|
+
import struct
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import termios
|
|
19
|
+
import tty
|
|
20
|
+
|
|
21
|
+
from ptyprocess import PtyProcess
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _winsize(fd: int) -> tuple[int, int]:
|
|
25
|
+
try:
|
|
26
|
+
import fcntl
|
|
27
|
+
|
|
28
|
+
packed = fcntl.ioctl(fd, termios.TIOCGWINSZ, struct.pack("HHHH", 0, 0, 0, 0))
|
|
29
|
+
rows, cols = struct.unpack("HHHH", packed)[:2]
|
|
30
|
+
return (rows or 24, cols or 80)
|
|
31
|
+
except OSError:
|
|
32
|
+
return (24, 80)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def run_in_pty(argv: list[str], cwd: str | None = None, env: dict | None = None) -> int:
|
|
36
|
+
"""Run argv on a pty, mirroring the controlling terminal. Returns the
|
|
37
|
+
child's exit status. Falls back to a plain subprocess when stdin is not a
|
|
38
|
+
tty (tests, pipes)."""
|
|
39
|
+
try:
|
|
40
|
+
stdin_fd = sys.stdin.fileno()
|
|
41
|
+
is_tty = os.isatty(stdin_fd)
|
|
42
|
+
except (ValueError, OSError, io.UnsupportedOperation):
|
|
43
|
+
is_tty = False
|
|
44
|
+
if not is_tty:
|
|
45
|
+
return subprocess.run(argv, cwd=cwd, env=env).returncode
|
|
46
|
+
|
|
47
|
+
child = PtyProcess.spawn(
|
|
48
|
+
argv, cwd=cwd, env=env or dict(os.environ), dimensions=_winsize(stdin_fd)
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def on_winch(signum, frame):
|
|
52
|
+
rows, cols = _winsize(stdin_fd)
|
|
53
|
+
try:
|
|
54
|
+
child.setwinsize(rows, cols)
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
def on_term(signum, frame):
|
|
59
|
+
try:
|
|
60
|
+
child.kill(signal.SIGTERM)
|
|
61
|
+
except Exception:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
old_winch = signal.signal(signal.SIGWINCH, on_winch)
|
|
65
|
+
old_term = signal.signal(signal.SIGTERM, on_term)
|
|
66
|
+
old_attrs = termios.tcgetattr(stdin_fd)
|
|
67
|
+
try:
|
|
68
|
+
tty.setraw(stdin_fd)
|
|
69
|
+
stdin_open = True
|
|
70
|
+
while child.isalive():
|
|
71
|
+
rlist = [child.fd] + ([stdin_fd] if stdin_open else [])
|
|
72
|
+
try:
|
|
73
|
+
ready, _, _ = select.select(rlist, [], [], 0.2)
|
|
74
|
+
except InterruptedError:
|
|
75
|
+
continue # signal (e.g. SIGWINCH) — loop again
|
|
76
|
+
if child.fd in ready:
|
|
77
|
+
try:
|
|
78
|
+
data = child.read(65536)
|
|
79
|
+
except EOFError:
|
|
80
|
+
break
|
|
81
|
+
if not data:
|
|
82
|
+
break
|
|
83
|
+
os.write(sys.stdout.fileno(), data)
|
|
84
|
+
if stdin_open and stdin_fd in ready:
|
|
85
|
+
data = os.read(stdin_fd, 65536)
|
|
86
|
+
if data:
|
|
87
|
+
child.write(data)
|
|
88
|
+
else:
|
|
89
|
+
child.sendeof()
|
|
90
|
+
stdin_open = False
|
|
91
|
+
finally:
|
|
92
|
+
termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_attrs)
|
|
93
|
+
signal.signal(signal.SIGWINCH, old_winch)
|
|
94
|
+
signal.signal(signal.SIGTERM, old_term)
|
|
95
|
+
try:
|
|
96
|
+
child.wait()
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|
|
99
|
+
return child.exitstatus if child.exitstatus is not None else 1
|