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
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""Claude Code adapter.
|
|
2
|
+
|
|
3
|
+
Session format observed on claude 2.1.220 (docs/formats.md):
|
|
4
|
+
- transcript: ~/.claude/projects/<munged-cwd>/<sessionId>.jsonl
|
|
5
|
+
- entries chained by uuid/parentUuid; types: user, assistant, attachment,
|
|
6
|
+
queue-operation, last-prompt, summary, system
|
|
7
|
+
- assistant API messages are split one content block per JSONL line, sharing
|
|
8
|
+
message.id
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import uuid as uuidlib
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from .. import compat, paths
|
|
19
|
+
from ..events import (
|
|
20
|
+
AssistantMessage,
|
|
21
|
+
NormalizedEvent,
|
|
22
|
+
SessionContext,
|
|
23
|
+
SystemEvent,
|
|
24
|
+
Thinking,
|
|
25
|
+
ToolCall,
|
|
26
|
+
ToolResult,
|
|
27
|
+
UserMessage,
|
|
28
|
+
)
|
|
29
|
+
from ..util import append_jsonl_fsync, git_branch, iso_now_ms
|
|
30
|
+
from .base import HarnessAdapter
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _stringify_block_content(content: Any) -> str:
|
|
34
|
+
"""tool_result content is either a string or a list of content blocks."""
|
|
35
|
+
if isinstance(content, str):
|
|
36
|
+
return content
|
|
37
|
+
if isinstance(content, list):
|
|
38
|
+
parts = []
|
|
39
|
+
for c in content:
|
|
40
|
+
if isinstance(c, dict) and c.get("type") == "text":
|
|
41
|
+
parts.append(c.get("text", ""))
|
|
42
|
+
elif isinstance(c, dict):
|
|
43
|
+
parts.append(f"<{c.get('type', 'block')}>")
|
|
44
|
+
return "\n".join(parts)
|
|
45
|
+
return "" if content is None else str(content)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ClaudeCodeAdapter(HarnessAdapter):
|
|
49
|
+
id = "claude"
|
|
50
|
+
display_name = "Claude Code"
|
|
51
|
+
binary = "claude"
|
|
52
|
+
|
|
53
|
+
# -- environment ---------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
def detect_version(self) -> str | None:
|
|
56
|
+
return compat.detect_cli_version(self.binary)
|
|
57
|
+
|
|
58
|
+
def version_supported(self, version_text: str) -> bool:
|
|
59
|
+
return compat.version_supported("claude", version_text)
|
|
60
|
+
|
|
61
|
+
# -- session files -------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def transcript_path(self, cwd: str, session_id: str) -> Path | None:
|
|
64
|
+
p = paths.claude_transcript_path(cwd, session_id)
|
|
65
|
+
return p if p.exists() else None
|
|
66
|
+
|
|
67
|
+
def expected_transcript_path(self, cwd: str, session_id: str) -> Path:
|
|
68
|
+
return paths.claude_transcript_path(cwd, session_id)
|
|
69
|
+
|
|
70
|
+
def mint_session_id(self) -> str:
|
|
71
|
+
return str(uuidlib.uuid4())
|
|
72
|
+
|
|
73
|
+
def _base_entry(self, ctx: SessionContext, entry_type: str) -> dict[str, Any]:
|
|
74
|
+
version = "0.0.0"
|
|
75
|
+
raw = self.detect_version()
|
|
76
|
+
if raw:
|
|
77
|
+
parsed = compat.parse_version(raw)
|
|
78
|
+
if parsed:
|
|
79
|
+
version = ".".join(str(x) for x in parsed)
|
|
80
|
+
return {
|
|
81
|
+
"parentUuid": ctx.claude_leaf_uuid,
|
|
82
|
+
"isSidechain": False,
|
|
83
|
+
"userType": "external",
|
|
84
|
+
"cwd": ctx.cwd,
|
|
85
|
+
"sessionId": ctx.claude_session_id,
|
|
86
|
+
"version": version,
|
|
87
|
+
"gitBranch": git_branch(ctx.cwd),
|
|
88
|
+
"type": entry_type,
|
|
89
|
+
"uuid": str(uuidlib.uuid4()),
|
|
90
|
+
"timestamp": iso_now_ms(),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
def create_shadow_transcript(
|
|
94
|
+
self, cwd: str, session_id: str, ctx: SessionContext, note: str
|
|
95
|
+
) -> Path:
|
|
96
|
+
path = paths.claude_transcript_path(cwd, session_id)
|
|
97
|
+
entry = self._base_entry(ctx, "user")
|
|
98
|
+
entry["message"] = {"role": "user", "content": note}
|
|
99
|
+
append_jsonl_fsync(path, [entry])
|
|
100
|
+
ctx.claude_leaf_uuid = entry["uuid"]
|
|
101
|
+
return path
|
|
102
|
+
|
|
103
|
+
# -- launching -----------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
def interactive_argv(self, session_id: str | None, fresh: bool) -> list[str]:
|
|
106
|
+
assert session_id, "claude sessions are always minted up front"
|
|
107
|
+
if fresh:
|
|
108
|
+
return [self.binary, "--session-id", session_id]
|
|
109
|
+
return [self.binary, "--resume", session_id]
|
|
110
|
+
|
|
111
|
+
def oneoff_argv(self, session_id: str, prompt: str) -> list[str]:
|
|
112
|
+
return [self.binary, "--resume", session_id, "-p", prompt]
|
|
113
|
+
|
|
114
|
+
def hook_argv_extra(self, sentinel: Path) -> list[str]:
|
|
115
|
+
# Per-invocation settings JSON; Stop fires at each completed turn.
|
|
116
|
+
settings = {
|
|
117
|
+
"hooks": {
|
|
118
|
+
"Stop": [
|
|
119
|
+
{
|
|
120
|
+
"hooks": [
|
|
121
|
+
{"type": "command", "command": f"touch '{sentinel}'"}
|
|
122
|
+
]
|
|
123
|
+
}
|
|
124
|
+
]
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return ["--settings", json.dumps(settings)]
|
|
128
|
+
|
|
129
|
+
# -- parsing -------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
def parse_entry(self, raw: dict[str, Any], ctx: SessionContext) -> list[NormalizedEvent]:
|
|
132
|
+
"""One transcript line -> normalized events. Never raises on shape
|
|
133
|
+
surprises; anything unrecognized degrades to a SystemEvent."""
|
|
134
|
+
ts = raw.get("timestamp")
|
|
135
|
+
etype = raw.get("type")
|
|
136
|
+
|
|
137
|
+
def sysev(subtype: str, text: str = "") -> SystemEvent:
|
|
138
|
+
return SystemEvent(
|
|
139
|
+
source="claude", timestamp=ts, turn_index=ctx.turn_index,
|
|
140
|
+
subtype=subtype, text=text,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
if raw.get("isSidechain"):
|
|
144
|
+
return [sysev("sidechain")]
|
|
145
|
+
|
|
146
|
+
if etype == "user":
|
|
147
|
+
if raw.get("isMeta"):
|
|
148
|
+
return [sysev("meta_user")]
|
|
149
|
+
content = (raw.get("message") or {}).get("content")
|
|
150
|
+
if isinstance(content, str):
|
|
151
|
+
ctx.turn_index += 1
|
|
152
|
+
return [
|
|
153
|
+
UserMessage(
|
|
154
|
+
source="user", timestamp=ts,
|
|
155
|
+
turn_index=ctx.turn_index, text=content,
|
|
156
|
+
)
|
|
157
|
+
]
|
|
158
|
+
events: list[NormalizedEvent] = []
|
|
159
|
+
structured = raw.get("toolUseResult")
|
|
160
|
+
for block in content or []:
|
|
161
|
+
btype = block.get("type")
|
|
162
|
+
if btype == "tool_result":
|
|
163
|
+
events.append(
|
|
164
|
+
ToolResult(
|
|
165
|
+
source="claude", timestamp=ts,
|
|
166
|
+
turn_index=ctx.turn_index,
|
|
167
|
+
call_id=block.get("tool_use_id"),
|
|
168
|
+
output=_stringify_block_content(block.get("content")),
|
|
169
|
+
is_error=bool(block.get("is_error")),
|
|
170
|
+
structured=structured if isinstance(structured, dict) else None,
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
elif btype == "text":
|
|
174
|
+
ctx.turn_index += 1
|
|
175
|
+
events.append(
|
|
176
|
+
UserMessage(
|
|
177
|
+
source="user", timestamp=ts,
|
|
178
|
+
turn_index=ctx.turn_index,
|
|
179
|
+
text=block.get("text", ""),
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
else:
|
|
183
|
+
events.append(sysev(f"user_block:{btype}"))
|
|
184
|
+
return events or [sysev("user_empty")]
|
|
185
|
+
|
|
186
|
+
if etype == "assistant":
|
|
187
|
+
msg = raw.get("message") or {}
|
|
188
|
+
events = []
|
|
189
|
+
for block in msg.get("content") or []:
|
|
190
|
+
btype = block.get("type")
|
|
191
|
+
if btype == "text":
|
|
192
|
+
events.append(
|
|
193
|
+
AssistantMessage(
|
|
194
|
+
source="claude", timestamp=ts,
|
|
195
|
+
turn_index=ctx.turn_index,
|
|
196
|
+
text=block.get("text", ""),
|
|
197
|
+
model=msg.get("model"),
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
elif btype == "tool_use":
|
|
201
|
+
events.append(
|
|
202
|
+
ToolCall(
|
|
203
|
+
source="claude", timestamp=ts,
|
|
204
|
+
turn_index=ctx.turn_index,
|
|
205
|
+
call_id=block.get("id", ""),
|
|
206
|
+
tool=block.get("name", ""),
|
|
207
|
+
arguments=block.get("input") or {},
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
elif btype in ("thinking", "redacted_thinking"):
|
|
211
|
+
events.append(
|
|
212
|
+
Thinking(source="claude", timestamp=ts, turn_index=ctx.turn_index)
|
|
213
|
+
)
|
|
214
|
+
else:
|
|
215
|
+
events.append(sysev(f"assistant_block:{btype}"))
|
|
216
|
+
return events or [sysev("assistant_empty")]
|
|
217
|
+
|
|
218
|
+
if etype == "system" and raw.get("subtype") == "compact_boundary":
|
|
219
|
+
return [sysev("compaction", "conversation was compacted")]
|
|
220
|
+
|
|
221
|
+
# attachment, queue-operation, last-prompt, summary, system, unknown
|
|
222
|
+
return [sysev(f"claude:{etype}")]
|
|
223
|
+
|
|
224
|
+
# -- rendering (shadow append) -------------------------------------------
|
|
225
|
+
|
|
226
|
+
def render_events(
|
|
227
|
+
self, events: list[NormalizedEvent], ctx: SessionContext
|
|
228
|
+
) -> list[dict[str, Any]]:
|
|
229
|
+
"""Normalized events -> native transcript entries, chained onto the
|
|
230
|
+
current leaf. Messages and tool activity both render natively;
|
|
231
|
+
consecutive assistant entries share one message.id."""
|
|
232
|
+
out: list[dict[str, Any]] = []
|
|
233
|
+
for ev in events:
|
|
234
|
+
if ev.kind in ("user_message", "tool_result"):
|
|
235
|
+
ctx.claude_run_msg_id = None
|
|
236
|
+
if ev.kind == "user_message":
|
|
237
|
+
entry = self._base_entry(ctx, "user")
|
|
238
|
+
entry["message"] = {"role": "user", "content": ev.text}
|
|
239
|
+
elif ev.kind == "assistant_message":
|
|
240
|
+
entry = self._base_entry(ctx, "assistant")
|
|
241
|
+
entry["message"] = self._assistant_message(
|
|
242
|
+
ctx, entry, [{"type": "text", "text": ev.text}], "end_turn"
|
|
243
|
+
)
|
|
244
|
+
elif ev.kind == "tool_call":
|
|
245
|
+
entry = self._base_entry(ctx, "assistant")
|
|
246
|
+
inp = ev.arguments if isinstance(ev.arguments, dict) else {"input": ev.arguments}
|
|
247
|
+
entry["message"] = self._assistant_message(
|
|
248
|
+
ctx, entry,
|
|
249
|
+
[{"type": "tool_use", "id": ev.call_id, "name": ev.tool,
|
|
250
|
+
"input": inp}],
|
|
251
|
+
"tool_use",
|
|
252
|
+
)
|
|
253
|
+
elif ev.kind == "tool_result":
|
|
254
|
+
entry = self._base_entry(ctx, "user")
|
|
255
|
+
entry["message"] = {
|
|
256
|
+
"role": "user",
|
|
257
|
+
"content": [{"type": "tool_result", "tool_use_id": ev.call_id,
|
|
258
|
+
"content": ev.output, "is_error": ev.is_error}],
|
|
259
|
+
}
|
|
260
|
+
entry["toolUseResult"] = ev.structured or {"stdout": ev.output}
|
|
261
|
+
else:
|
|
262
|
+
continue
|
|
263
|
+
if ev.timestamp:
|
|
264
|
+
entry["timestamp"] = ev.timestamp
|
|
265
|
+
ctx.claude_leaf_uuid = entry["uuid"]
|
|
266
|
+
out.append(entry)
|
|
267
|
+
return out
|
|
268
|
+
|
|
269
|
+
def _assistant_message(
|
|
270
|
+
self, ctx: SessionContext, entry: dict[str, Any],
|
|
271
|
+
content: list[dict[str, Any]], stop_reason: str,
|
|
272
|
+
) -> dict[str, Any]:
|
|
273
|
+
if ctx.claude_run_msg_id is None:
|
|
274
|
+
ctx.claude_run_msg_id = f"msg_tandem_{entry['uuid'][:8]}"
|
|
275
|
+
return {
|
|
276
|
+
"role": "assistant",
|
|
277
|
+
"model": ctx.claude_model or "<synced>",
|
|
278
|
+
"id": ctx.claude_run_msg_id,
|
|
279
|
+
"type": "message",
|
|
280
|
+
"content": content,
|
|
281
|
+
"stop_reason": stop_reason,
|
|
282
|
+
"stop_sequence": None,
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
def render_placeholder(self, text: str, ctx: SessionContext) -> list[dict[str, Any]]:
|
|
286
|
+
# a rendered user-side entry, so it ends the assistant run like any
|
|
287
|
+
# other: no message.id may straddle it
|
|
288
|
+
ctx.claude_run_msg_id = None
|
|
289
|
+
entry = self._base_entry(ctx, "user")
|
|
290
|
+
entry["message"] = {"role": "user", "content": text}
|
|
291
|
+
ctx.claude_leaf_uuid = entry["uuid"]
|
|
292
|
+
return [entry]
|
|
293
|
+
|
|
294
|
+
def _tail_objects(self, transcript: Path) -> list[dict[str, Any]]:
|
|
295
|
+
try:
|
|
296
|
+
with open(transcript, "rb") as f:
|
|
297
|
+
f.seek(0, 2)
|
|
298
|
+
size = f.tell()
|
|
299
|
+
f.seek(max(0, size - 262144))
|
|
300
|
+
tail = f.read().decode("utf-8", errors="replace")
|
|
301
|
+
except OSError:
|
|
302
|
+
return []
|
|
303
|
+
out = []
|
|
304
|
+
for line in tail.splitlines():
|
|
305
|
+
line = line.strip()
|
|
306
|
+
if not line:
|
|
307
|
+
continue
|
|
308
|
+
try:
|
|
309
|
+
obj = json.loads(line)
|
|
310
|
+
except json.JSONDecodeError:
|
|
311
|
+
continue
|
|
312
|
+
if isinstance(obj, dict):
|
|
313
|
+
out.append(obj)
|
|
314
|
+
return out
|
|
315
|
+
|
|
316
|
+
def derive_leaf_uuid(self, transcript: Path) -> str | None:
|
|
317
|
+
"""Last chainable uuid in an existing transcript (the harness itself
|
|
318
|
+
may have appended since tandem last wrote)."""
|
|
319
|
+
leaf = None
|
|
320
|
+
for obj in self._tail_objects(transcript):
|
|
321
|
+
if obj.get("uuid") and obj.get("type") in (
|
|
322
|
+
"user", "assistant", "attachment", "system",
|
|
323
|
+
):
|
|
324
|
+
leaf = obj["uuid"]
|
|
325
|
+
return leaf
|
|
326
|
+
|
|
327
|
+
def derive_last_model(self, transcript: Path) -> str | None:
|
|
328
|
+
"""Model of the last main-thread assistant entry with a real model
|
|
329
|
+
name. Skips the "<synced>" placeholder (pre-fix tandem appends) and
|
|
330
|
+
sidechain entries (subagents may run on a different model)."""
|
|
331
|
+
model = None
|
|
332
|
+
for obj in self._tail_objects(transcript):
|
|
333
|
+
if obj.get("type") != "assistant" or obj.get("isSidechain"):
|
|
334
|
+
continue
|
|
335
|
+
m = (obj.get("message") or {}).get("model")
|
|
336
|
+
if m and m != "<synced>":
|
|
337
|
+
model = m
|
|
338
|
+
return model
|
tandem/harness/codex.py
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""Codex CLI adapter.
|
|
2
|
+
|
|
3
|
+
Session format observed on codex-cli 0.145.0 (docs/formats.md):
|
|
4
|
+
- rollout: ~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<uuidv7>.jsonl
|
|
5
|
+
- each line {timestamp, type, payload}; model-facing history is the
|
|
6
|
+
response_item lines, UI history is the event_msg lines
|
|
7
|
+
- first line is session_meta; ~/.codex/session_index.jsonl indexes threads
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .. import compat, paths
|
|
18
|
+
from ..events import (
|
|
19
|
+
AssistantMessage,
|
|
20
|
+
NormalizedEvent,
|
|
21
|
+
SessionContext,
|
|
22
|
+
SystemEvent,
|
|
23
|
+
Thinking,
|
|
24
|
+
ToolCall,
|
|
25
|
+
ToolResult,
|
|
26
|
+
UserMessage,
|
|
27
|
+
)
|
|
28
|
+
from ..util import append_jsonl_fsync, iso_now_ms, uuid7
|
|
29
|
+
from .base import HarnessAdapter
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CodexAdapter(HarnessAdapter):
|
|
33
|
+
id = "codex"
|
|
34
|
+
display_name = "Codex CLI"
|
|
35
|
+
binary = "codex"
|
|
36
|
+
|
|
37
|
+
# -- environment ---------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
def detect_version(self) -> str | None:
|
|
40
|
+
return compat.detect_cli_version(self.binary)
|
|
41
|
+
|
|
42
|
+
def version_supported(self, version_text: str) -> bool:
|
|
43
|
+
return compat.version_supported("codex", version_text)
|
|
44
|
+
|
|
45
|
+
# -- session files -------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
def transcript_path(self, cwd: str, session_id: str) -> Path | None:
|
|
48
|
+
return paths.find_codex_rollout(session_id)
|
|
49
|
+
|
|
50
|
+
def mint_session_id(self) -> str:
|
|
51
|
+
return uuid7()
|
|
52
|
+
|
|
53
|
+
def create_shadow_transcript(
|
|
54
|
+
self, cwd: str, session_id: str, ctx: SessionContext, note: str
|
|
55
|
+
) -> Path:
|
|
56
|
+
"""Create a rollout file shaped like codex's own, headed by a
|
|
57
|
+
session_meta whose originator marks it as tandem-created."""
|
|
58
|
+
now = datetime.now(timezone.utc)
|
|
59
|
+
day_dir = paths.codex_sessions_dir() / now.strftime("%Y/%m/%d")
|
|
60
|
+
fname = f"rollout-{now.strftime('%Y-%m-%dT%H-%M-%S')}-{session_id}.jsonl"
|
|
61
|
+
path = day_dir / fname
|
|
62
|
+
version = "0.0.0"
|
|
63
|
+
raw = self.detect_version()
|
|
64
|
+
if raw:
|
|
65
|
+
parsed = compat.parse_version(raw)
|
|
66
|
+
if parsed:
|
|
67
|
+
version = ".".join(str(x) for x in parsed)
|
|
68
|
+
ts = iso_now_ms()
|
|
69
|
+
meta = {
|
|
70
|
+
"timestamp": ts,
|
|
71
|
+
"type": "session_meta",
|
|
72
|
+
"payload": {
|
|
73
|
+
"session_id": session_id,
|
|
74
|
+
"id": session_id,
|
|
75
|
+
"timestamp": ts,
|
|
76
|
+
"cwd": cwd,
|
|
77
|
+
"originator": "tandem",
|
|
78
|
+
"cli_version": version,
|
|
79
|
+
"source": "exec",
|
|
80
|
+
"thread_source": "user",
|
|
81
|
+
# codex >= 0.145 interactive thread/resume rejects rollouts
|
|
82
|
+
# without a provider id; "openai" is codex's built-in default
|
|
83
|
+
"model_provider": "openai",
|
|
84
|
+
"history_mode": "legacy",
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
entries = [meta] + self.render_note(note)
|
|
88
|
+
append_jsonl_fsync(path, entries)
|
|
89
|
+
return path
|
|
90
|
+
|
|
91
|
+
def render_note(self, note: str) -> list[dict[str, Any]]:
|
|
92
|
+
"""A tandem-authored informational line, as both the model-facing
|
|
93
|
+
response_item and the UI-facing event_msg."""
|
|
94
|
+
ts = iso_now_ms()
|
|
95
|
+
return [
|
|
96
|
+
{
|
|
97
|
+
"timestamp": ts,
|
|
98
|
+
"type": "response_item",
|
|
99
|
+
"payload": {
|
|
100
|
+
"type": "message",
|
|
101
|
+
"role": "user",
|
|
102
|
+
"content": [{"type": "input_text", "text": note}],
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
"timestamp": ts,
|
|
107
|
+
"type": "event_msg",
|
|
108
|
+
"payload": {
|
|
109
|
+
"type": "user_message",
|
|
110
|
+
"message": note,
|
|
111
|
+
"images": [],
|
|
112
|
+
"local_images": [],
|
|
113
|
+
"audio": [],
|
|
114
|
+
"local_audio": [],
|
|
115
|
+
"text_elements": [],
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
# -- launching -----------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
def interactive_argv(self, session_id: str | None, fresh: bool) -> list[str]:
|
|
123
|
+
if fresh and session_id is None:
|
|
124
|
+
# codex mints its own id; tandem captures the new rollout file.
|
|
125
|
+
return [self.binary]
|
|
126
|
+
assert session_id
|
|
127
|
+
return [self.binary, "resume", session_id]
|
|
128
|
+
|
|
129
|
+
def oneoff_argv(self, session_id: str, prompt: str) -> list[str]:
|
|
130
|
+
return [self.binary, "exec", "--skip-git-repo-check", "resume", session_id, prompt]
|
|
131
|
+
|
|
132
|
+
def hook_argv_extra(self, sentinel: Path) -> list[str]:
|
|
133
|
+
# A -c notify=... override would silently replace a notify handler
|
|
134
|
+
# the user already configured (observed in the wild: Codex Computer
|
|
135
|
+
# Use registers one). In that case rely on fs-watching alone.
|
|
136
|
+
try:
|
|
137
|
+
import tomllib
|
|
138
|
+
|
|
139
|
+
with open(paths.codex_home() / "config.toml", "rb") as f:
|
|
140
|
+
if tomllib.load(f).get("notify"):
|
|
141
|
+
return []
|
|
142
|
+
except (OSError, ValueError):
|
|
143
|
+
pass
|
|
144
|
+
# notify appends a JSON payload argument; route through sh -c so it
|
|
145
|
+
# lands in $0 and is ignored.
|
|
146
|
+
return ["-c", f'notify=["/bin/sh","-c","touch \'{sentinel}\'"]']
|
|
147
|
+
|
|
148
|
+
# -- parsing -------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
def parse_entry(self, raw: dict[str, Any], ctx: SessionContext) -> list[NormalizedEvent]:
|
|
151
|
+
"""One rollout line -> normalized events.
|
|
152
|
+
|
|
153
|
+
Sources of truth per event class (to avoid the duplication built into
|
|
154
|
+
the rollout format):
|
|
155
|
+
- user prompts: event_msg/user_message (response_item role=user
|
|
156
|
+
also carries injected env context, so it's skipped)
|
|
157
|
+
- assistant text: response_item/message role=assistant
|
|
158
|
+
- tool activity: response_item function_call / custom_tool_call and
|
|
159
|
+
their *_output lines; event_msg/patch_apply_end
|
|
160
|
+
only enriches the pending call with structured
|
|
161
|
+
change data.
|
|
162
|
+
"""
|
|
163
|
+
ts = raw.get("timestamp")
|
|
164
|
+
etype = raw.get("type")
|
|
165
|
+
payload = raw.get("payload") or {}
|
|
166
|
+
ptype = payload.get("type")
|
|
167
|
+
|
|
168
|
+
def sysev(subtype: str, text: str = "") -> SystemEvent:
|
|
169
|
+
return SystemEvent(
|
|
170
|
+
source="codex", timestamp=ts, turn_index=ctx.turn_index,
|
|
171
|
+
subtype=subtype, text=text,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
if etype == "response_item":
|
|
175
|
+
if ptype == "message":
|
|
176
|
+
role = payload.get("role")
|
|
177
|
+
if role != "assistant":
|
|
178
|
+
return [sysev(f"context_message:{role}")]
|
|
179
|
+
text = "\n".join(
|
|
180
|
+
b.get("text", "")
|
|
181
|
+
for b in payload.get("content") or []
|
|
182
|
+
if isinstance(b, dict) and b.get("type") == "output_text"
|
|
183
|
+
)
|
|
184
|
+
phase = "final" if payload.get("phase") == "final_answer" else "commentary"
|
|
185
|
+
return [
|
|
186
|
+
AssistantMessage(
|
|
187
|
+
source="codex", timestamp=ts, turn_index=ctx.turn_index,
|
|
188
|
+
text=text, phase=phase,
|
|
189
|
+
)
|
|
190
|
+
]
|
|
191
|
+
if ptype == "reasoning":
|
|
192
|
+
return [Thinking(source="codex", timestamp=ts, turn_index=ctx.turn_index)]
|
|
193
|
+
if ptype in ("function_call", "custom_tool_call"):
|
|
194
|
+
return [
|
|
195
|
+
ToolCall(
|
|
196
|
+
source="codex", timestamp=ts, turn_index=ctx.turn_index,
|
|
197
|
+
call_id=payload.get("call_id", ""),
|
|
198
|
+
tool=payload.get("name", ""),
|
|
199
|
+
arguments=payload.get("arguments", payload.get("input", "")),
|
|
200
|
+
)
|
|
201
|
+
]
|
|
202
|
+
if ptype in ("function_call_output", "custom_tool_call_output"):
|
|
203
|
+
call_id = payload.get("call_id")
|
|
204
|
+
pending = ctx.pending_calls.get(call_id or "", {})
|
|
205
|
+
structured = pending.pop("_structured", None) if pending else None
|
|
206
|
+
output = payload.get("output", "")
|
|
207
|
+
if not isinstance(output, str):
|
|
208
|
+
output = str(output)
|
|
209
|
+
return [
|
|
210
|
+
ToolResult(
|
|
211
|
+
source="codex", timestamp=ts, turn_index=ctx.turn_index,
|
|
212
|
+
call_id=call_id, output=output, structured=structured,
|
|
213
|
+
)
|
|
214
|
+
]
|
|
215
|
+
return [sysev(f"response_item:{ptype}")]
|
|
216
|
+
|
|
217
|
+
if etype == "event_msg":
|
|
218
|
+
if ptype == "task_started":
|
|
219
|
+
ctx.turn_index += 1
|
|
220
|
+
return [sysev("turn_start")]
|
|
221
|
+
if ptype == "user_message":
|
|
222
|
+
return [
|
|
223
|
+
UserMessage(
|
|
224
|
+
source="user", timestamp=ts, turn_index=ctx.turn_index,
|
|
225
|
+
text=payload.get("message", ""),
|
|
226
|
+
)
|
|
227
|
+
]
|
|
228
|
+
if ptype == "patch_apply_end":
|
|
229
|
+
call_id = payload.get("call_id")
|
|
230
|
+
if call_id and call_id in ctx.pending_calls:
|
|
231
|
+
ctx.pending_calls[call_id]["_structured"] = {
|
|
232
|
+
"success": payload.get("success"),
|
|
233
|
+
"changes": payload.get("changes"),
|
|
234
|
+
}
|
|
235
|
+
return [sysev("patch_apply_end")]
|
|
236
|
+
if ptype == "task_complete":
|
|
237
|
+
return [sysev("turn_end", payload.get("last_agent_message") or "")]
|
|
238
|
+
# agent_message duplicates response_item/message; token_count etc.
|
|
239
|
+
return [sysev(f"event_msg:{ptype}")]
|
|
240
|
+
|
|
241
|
+
# session_meta, turn_context, world_state, unknown
|
|
242
|
+
return [sysev(f"codex:{etype}")]
|
|
243
|
+
|
|
244
|
+
# -- rendering (shadow append) -------------------------------------------
|
|
245
|
+
|
|
246
|
+
def render_events(
|
|
247
|
+
self, events: list[NormalizedEvent], ctx: SessionContext
|
|
248
|
+
) -> list[dict[str, Any]]:
|
|
249
|
+
"""Normalized events -> rollout lines. User prompts get both the
|
|
250
|
+
model-facing response_item and the UI-facing event_msg (codex's own
|
|
251
|
+
writer does the same); assistant text gets a response_item so it
|
|
252
|
+
lands in the model's context on resume.
|
|
253
|
+
|
|
254
|
+
Tool activity is rendered as native pairs (response_item only, no
|
|
255
|
+
event_msg): dict arguments -> function_call with JSON-stringified
|
|
256
|
+
arguments, str arguments -> custom_tool_call with input; the matching
|
|
257
|
+
result follows as function_call_output / custom_tool_call_output."""
|
|
258
|
+
out: list[dict[str, Any]] = []
|
|
259
|
+
custom_ids: set[str] = set() # calls rendered as custom_tool_call
|
|
260
|
+
for ev in events:
|
|
261
|
+
ts = ev.timestamp or iso_now_ms()
|
|
262
|
+
if ev.kind == "user_message":
|
|
263
|
+
out.append(
|
|
264
|
+
{
|
|
265
|
+
"timestamp": ts,
|
|
266
|
+
"type": "response_item",
|
|
267
|
+
"payload": {
|
|
268
|
+
"type": "message",
|
|
269
|
+
"role": "user",
|
|
270
|
+
"content": [{"type": "input_text", "text": ev.text}],
|
|
271
|
+
},
|
|
272
|
+
}
|
|
273
|
+
)
|
|
274
|
+
out.append(
|
|
275
|
+
{
|
|
276
|
+
"timestamp": ts,
|
|
277
|
+
"type": "event_msg",
|
|
278
|
+
"payload": {
|
|
279
|
+
"type": "user_message",
|
|
280
|
+
"message": ev.text,
|
|
281
|
+
"images": [],
|
|
282
|
+
"local_images": [],
|
|
283
|
+
"audio": [],
|
|
284
|
+
"local_audio": [],
|
|
285
|
+
"text_elements": [],
|
|
286
|
+
},
|
|
287
|
+
}
|
|
288
|
+
)
|
|
289
|
+
elif ev.kind == "assistant_message":
|
|
290
|
+
phase = "final_answer" if ev.phase == "final" else "commentary"
|
|
291
|
+
out.append(
|
|
292
|
+
{
|
|
293
|
+
"timestamp": ts,
|
|
294
|
+
"type": "response_item",
|
|
295
|
+
"payload": {
|
|
296
|
+
"type": "message",
|
|
297
|
+
"role": "assistant",
|
|
298
|
+
"content": [{"type": "output_text", "text": ev.text}],
|
|
299
|
+
"phase": phase,
|
|
300
|
+
},
|
|
301
|
+
}
|
|
302
|
+
)
|
|
303
|
+
out.append(
|
|
304
|
+
{
|
|
305
|
+
"timestamp": ts,
|
|
306
|
+
"type": "event_msg",
|
|
307
|
+
"payload": {
|
|
308
|
+
"type": "agent_message",
|
|
309
|
+
"message": ev.text,
|
|
310
|
+
"phase": phase,
|
|
311
|
+
"memory_citation": None,
|
|
312
|
+
},
|
|
313
|
+
}
|
|
314
|
+
)
|
|
315
|
+
elif ev.kind == "tool_call":
|
|
316
|
+
if isinstance(ev.arguments, str):
|
|
317
|
+
custom_ids.add(ev.call_id)
|
|
318
|
+
payload: dict[str, Any] = {
|
|
319
|
+
"type": "custom_tool_call", "name": ev.tool,
|
|
320
|
+
"input": ev.arguments, "call_id": ev.call_id,
|
|
321
|
+
}
|
|
322
|
+
else:
|
|
323
|
+
payload = {
|
|
324
|
+
"type": "function_call", "name": ev.tool,
|
|
325
|
+
"arguments": json.dumps(ev.arguments, ensure_ascii=False),
|
|
326
|
+
"call_id": ev.call_id,
|
|
327
|
+
}
|
|
328
|
+
out.append({"timestamp": ts, "type": "response_item", "payload": payload})
|
|
329
|
+
elif ev.kind == "tool_result":
|
|
330
|
+
ptype = ("custom_tool_call_output" if ev.call_id in custom_ids
|
|
331
|
+
else "function_call_output")
|
|
332
|
+
out.append({
|
|
333
|
+
"timestamp": ts, "type": "response_item",
|
|
334
|
+
"payload": {"type": ptype, "call_id": ev.call_id,
|
|
335
|
+
"output": ev.output},
|
|
336
|
+
})
|
|
337
|
+
return out
|
|
338
|
+
|
|
339
|
+
def render_placeholder(self, text: str, ctx: SessionContext) -> list[dict[str, Any]]:
|
|
340
|
+
return self.render_note(text)
|