agentlink-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.
Files changed (55) hide show
  1. agentlink_cli-0.1.0.dist-info/METADATA +136 -0
  2. agentlink_cli-0.1.0.dist-info/RECORD +55 -0
  3. agentlink_cli-0.1.0.dist-info/WHEEL +4 -0
  4. agentlink_cli-0.1.0.dist-info/entry_points.txt +3 -0
  5. connector/__init__.py +3 -0
  6. connector/acp/__init__.py +6 -0
  7. connector/acp/adapter.py +1221 -0
  8. connector/acp/config_options.py +175 -0
  9. connector/acp/discovery.py +385 -0
  10. connector/acp/manifest.py +110 -0
  11. connector/acp/manifests/__init__.py +1 -0
  12. connector/acp/manifests/codebuddy.json +37 -0
  13. connector/acp/manifests/cursor.json +39 -0
  14. connector/acp/manifests/gemini.json +33 -0
  15. connector/acp/manifests/grok_build.json +31 -0
  16. connector/acp/reducer.py +615 -0
  17. connector/acp/rpc.py +308 -0
  18. connector/adapter.py +39 -0
  19. connector/attachments.py +36 -0
  20. connector/capabilities.py +603 -0
  21. connector/claude/__init__.py +8 -0
  22. connector/claude/history_adapter.py +642 -0
  23. connector/claude/normalized.py +23 -0
  24. connector/claude/normalizers.py +97 -0
  25. connector/claude/path_utils.py +13 -0
  26. connector/claude/preferences.py +38 -0
  27. connector/claude/sdk_adapter.py +1376 -0
  28. connector/claude/timeline_identity.py +47 -0
  29. connector/claude/timeline_reducer.py +379 -0
  30. connector/claude/trust.py +69 -0
  31. connector/cli.py +280 -0
  32. connector/codex/__init__.py +3 -0
  33. connector/codex/adapter.py +1150 -0
  34. connector/codex/history.py +199 -0
  35. connector/codex/reducer.py +1309 -0
  36. connector/codex/rpc.py +261 -0
  37. connector/control.py +298 -0
  38. connector/json_rpc.py +143 -0
  39. connector/launch.py +310 -0
  40. connector/local/__init__.py +6 -0
  41. connector/local/common.py +118 -0
  42. connector/local/file_ops.py +144 -0
  43. connector/local/ops.py +92 -0
  44. connector/local/shell.py +225 -0
  45. connector/local/terminal.py +658 -0
  46. connector/local_ops.py +5 -0
  47. connector/local_runtime.py +139 -0
  48. connector/logging.py +50 -0
  49. connector/perf.py +89 -0
  50. connector/protocol.py +26 -0
  51. connector/registry.py +49 -0
  52. connector/runtime.py +1309 -0
  53. connector/sync_state.py +155 -0
  54. connector/time.py +7 -0
  55. connector/version.py +13 -0
@@ -0,0 +1,1309 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+ from connector.time import utc_now
9
+
10
+
11
+ CODEX_APPROVAL_METHODS = {
12
+ "item/commandExecution/requestApproval",
13
+ "item/fileChange/requestApproval",
14
+ "item/permissions/requestApproval",
15
+ }
16
+
17
+ OUTPUT_PREVIEW_CHARS = 4000
18
+
19
+
20
+ @dataclass(slots=True)
21
+ class ReductionResult:
22
+ session_update: dict[str, Any] | None = None
23
+ timeline_items: list[dict[str, Any]] = field(default_factory=list)
24
+ approvals: list[dict[str, Any]] = field(default_factory=list)
25
+
26
+
27
+ class TimelineReducer:
28
+ def __init__(self) -> None:
29
+ self._session_by_thread: dict[str, str] = {}
30
+ self._thread_by_session: dict[str, str] = {}
31
+ self._items: dict[str, dict[str, Any]] = {}
32
+ self._order_by_item: dict[str, int] = {}
33
+ self._tool_kind_by_call: dict[str, str] = {}
34
+ self._client_message_by_turn: dict[tuple[str, str | None, str], dict[str, Any]] = {}
35
+ self._pending_client_messages: dict[tuple[str, str | None], list[dict[str, Any]]] = {}
36
+ self._reasoning_index_by_turn: dict[tuple[str, str], int] = {}
37
+ self._next_order = 1
38
+
39
+ def bind_session(self, session_id: str, thread_id: str) -> None:
40
+ self._session_by_thread[thread_id] = session_id
41
+ self._thread_by_session[session_id] = thread_id
42
+
43
+ def thread_for_session(self, session_id: str) -> str | None:
44
+ return self._thread_by_session.get(session_id)
45
+
46
+ def session_for_thread(self, thread_id: str) -> str | None:
47
+ return self._session_by_thread.get(thread_id)
48
+
49
+ def _session_update(
50
+ self,
51
+ *,
52
+ session_id: str,
53
+ thread_id: str | None,
54
+ status: str | None = None,
55
+ **values: Any,
56
+ ) -> dict[str, Any]:
57
+ update = {
58
+ "sessionId": session_id,
59
+ "runtime": "codex",
60
+ "sourceObservedAt": utc_now(),
61
+ **values,
62
+ }
63
+ if status is not None:
64
+ update["status"] = status
65
+ if thread_id:
66
+ update["externalSessionId"] = thread_id
67
+ return update
68
+
69
+ def register_client_message(
70
+ self,
71
+ *,
72
+ session_id: str,
73
+ thread_id: str | None,
74
+ client_message_id: str,
75
+ text: str | None = None,
76
+ turn_id: str | None = None,
77
+ attachments: list[dict[str, Any]] | None = None,
78
+ ) -> None:
79
+ message = {"clientMessageId": client_message_id, "text": text, "attachments": attachments or []}
80
+ if turn_id:
81
+ self._client_message_by_turn[(session_id, thread_id, turn_id)] = message
82
+ pending_key = (session_id, thread_id)
83
+ pending = self._pending_client_messages.get(pending_key)
84
+ if pending is not None:
85
+ self._pending_client_messages[pending_key] = [
86
+ item for item in pending if item.get("clientMessageId") != client_message_id
87
+ ]
88
+ return
89
+ self._pending_client_messages.setdefault((session_id, thread_id), []).append(
90
+ message
91
+ )
92
+
93
+ def command_items(
94
+ self,
95
+ *,
96
+ session_id: str,
97
+ thread_id: str,
98
+ client_command_id: str,
99
+ command: str,
100
+ raw_args: str,
101
+ phase: str,
102
+ data: dict[str, Any] | None = None,
103
+ error: str | None = None,
104
+ ) -> list[dict[str, Any]]:
105
+ """Create stable timeline rows for a client-side slash command."""
106
+ text = f"/{command}{(' ' + raw_args) if raw_args else ''}"
107
+ user = self._upsert_item(
108
+ session_id=session_id,
109
+ turn_id=None,
110
+ item_id=client_command_id,
111
+ derived_key=None,
112
+ item_type="message",
113
+ status="done",
114
+ role="user",
115
+ content={"text": text, "format": "markdown"},
116
+ source_session_id=thread_id,
117
+ source_item_type="commandInvocation",
118
+ event="command/execute",
119
+ source_extra={"clientMessageId": client_command_id},
120
+ )
121
+ result = self._upsert_item(
122
+ session_id=session_id,
123
+ turn_id=None,
124
+ item_id=None,
125
+ derived_key=f"command-{client_command_id}",
126
+ item_type="system",
127
+ status="failed" if error else ("running" if phase == "started" else "done"),
128
+ role="system",
129
+ content={
130
+ "kind": "codex_command",
131
+ "command": command,
132
+ "phase": phase,
133
+ "data": data or {},
134
+ **({"message": error} if error else {}),
135
+ },
136
+ source_session_id=thread_id,
137
+ source_item_type="commandResult",
138
+ event="command/execute",
139
+ )
140
+ return [user, result]
141
+
142
+ def reduce_thread_snapshot(
143
+ self,
144
+ session_id: str,
145
+ thread: dict[str, Any],
146
+ *,
147
+ fallback_thread_id: str | None = None,
148
+ ) -> ReductionResult:
149
+ thread_id = fallback_thread_id or _thread_id(thread)
150
+ if thread_id:
151
+ self.bind_session(session_id, thread_id)
152
+
153
+ items: list[dict[str, Any]] = []
154
+ for turn in _list_value(thread.get("turns")):
155
+ turn_id = _string_value(turn.get("id")) or _string_value(turn.get("turnId"))
156
+ status = _turn_status(turn)
157
+ is_complete = status in {"completed", "failed", "cancelled", "interrupted"}
158
+ turn_items = [
159
+ item for item in _list_value(turn.get("items"))
160
+ if not _is_bootstrap_user_message(item) and not _is_external_import_marker(item)
161
+ ]
162
+ message_counts = _message_type_counts(turn_items)
163
+ message_indices: dict[str, int] = {}
164
+ _reasoning_index_by_turn: dict[str | None, int] = {}
165
+ if turn_id:
166
+ items.append(
167
+ self._upsert_turn_start(
168
+ session_id,
169
+ thread_id,
170
+ turn_id,
171
+ turn,
172
+ status=_turn_result_to_status(_turn_result(turn)) if is_complete else "running",
173
+ event="turn/completed" if is_complete else "turn/started",
174
+ )
175
+ )
176
+ for index, item in enumerate(turn_items):
177
+ item = dict(item)
178
+ item.setdefault("_snapshotIndex", index)
179
+ codex_type = _string_value(item.get("type"))
180
+ if codex_type == "reasoning":
181
+ idx = _reasoning_index_by_turn.get(turn_id, 0)
182
+ item["_reasoningTurnIndex"] = idx
183
+ _reasoning_index_by_turn[turn_id] = idx + 1
184
+ if codex_type in {"userMessage", "agentMessage"}:
185
+ message_index = message_indices.get(codex_type, 0)
186
+ message_indices[codex_type] = message_index + 1
187
+ if message_counts.get(codex_type, 0) > 1:
188
+ item["_messageKey"] = f"message-{codex_type}-{message_index}"
189
+ reduced = self._upsert_completed_item(session_id, thread_id, turn_id, item)
190
+ if reduced is not None:
191
+ items.append(reduced)
192
+ if turn_id and is_complete:
193
+ items.append(self._upsert_turn_end(session_id, thread_id, turn_id, turn))
194
+
195
+ session_update = {
196
+ "sessionId": session_id,
197
+ "runtime": "codex",
198
+ "status": _session_status_from_thread(thread),
199
+ "externalSessionId": thread_id,
200
+ "title": _string_value(thread.get("name")) or _string_value(thread.get("title")),
201
+ "cwd": _string_value(thread.get("cwd")),
202
+ "lastSyncedAt": utc_now(),
203
+ "sourceObservedAt": utc_now(),
204
+ }
205
+ return ReductionResult(session_update=session_update, timeline_items=items)
206
+
207
+ def reduce_history_items(
208
+ self,
209
+ session_id: str,
210
+ thread_id: str,
211
+ items: list[dict[str, Any]],
212
+ ) -> ReductionResult:
213
+ self.bind_session(session_id, thread_id)
214
+ records: list[tuple[str | None, dict[str, Any]]] = []
215
+ completed_turns: dict[str, str] = {}
216
+ message_counts: dict[str, int] = {}
217
+ for item_record in items:
218
+ raw_item = item_record.get("item") if isinstance(item_record.get("item"), dict) else item_record
219
+ if not isinstance(raw_item, dict):
220
+ continue
221
+ if _is_bootstrap_user_message(raw_item) or _is_external_import_marker(raw_item):
222
+ continue
223
+ turn_id = _string_value(item_record.get("turnId")) or _string_value(item_record.get("turn_id"))
224
+ item = dict(raw_item)
225
+ codex_type = _string_value(item.get("type"))
226
+ if codex_type in {"userMessage", "agentMessage"}:
227
+ message_counts[f"{turn_id}:{codex_type}"] = message_counts.get(f"{turn_id}:{codex_type}", 0) + 1
228
+ if codex_type == "turnEnd" and turn_id is not None:
229
+ completed_turns[turn_id] = _turn_result_to_status(_turn_result(item))
230
+ records.append((turn_id, item))
231
+
232
+ reduced_items: list[dict[str, Any]] = []
233
+ message_indices: dict[str, int] = {}
234
+ for turn_id, item in records:
235
+ codex_type = _string_value(item.get("type"))
236
+ if codex_type in {"userMessage", "agentMessage"} and _string_value(item.get("_derivedKey")) is None:
237
+ message_index = message_indices.get(f"{turn_id}:{codex_type}", 0)
238
+ message_indices[f"{turn_id}:{codex_type}"] = message_index + 1
239
+ if message_counts.get(f"{turn_id}:{codex_type}", 0) > 1:
240
+ item["_messageKey"] = f"message-{codex_type}-{message_index}"
241
+ if codex_type == "turnStart" and turn_id in completed_turns:
242
+ item["_historyTurnStartStatus"] = completed_turns[turn_id]
243
+ reduced = self._upsert_completed_item(
244
+ session_id,
245
+ thread_id,
246
+ turn_id,
247
+ item,
248
+ event="history/response_item",
249
+ )
250
+ if reduced is not None:
251
+ reduced_items.append(reduced)
252
+ return ReductionResult(timeline_items=reduced_items)
253
+
254
+ def reduce_notification(self, message: dict[str, Any]) -> ReductionResult:
255
+ method = _string_value(message.get("method"))
256
+ params = message.get("params") if isinstance(message.get("params"), dict) else {}
257
+ thread_id = _extract_thread_id(params)
258
+ turn_id = _extract_turn_id(params)
259
+ session_id = _string_value(params.get("platformSessionId"))
260
+ if session_id is None and thread_id is not None:
261
+ session_id = self._session_by_thread.get(thread_id)
262
+ if session_id is None:
263
+ return ReductionResult()
264
+ if thread_id:
265
+ self.bind_session(session_id, thread_id)
266
+
267
+ if method == "thread/name/updated":
268
+ return ReductionResult(
269
+ session_update=self._session_update(
270
+ session_id=session_id,
271
+ thread_id=thread_id,
272
+ title=_string_value(params.get("threadName")),
273
+ ),
274
+ )
275
+
276
+ if method == "turn/started":
277
+ return ReductionResult(
278
+ session_update=self._session_update(
279
+ session_id=session_id,
280
+ thread_id=thread_id,
281
+ status="running",
282
+ ),
283
+ timeline_items=[self._upsert_turn_start(session_id, thread_id, turn_id, params.get("turn") or params)],
284
+ )
285
+
286
+ if method == "turn/completed":
287
+ turn = params.get("turn") if isinstance(params.get("turn"), dict) else params
288
+ return ReductionResult(
289
+ session_update=self._session_update(
290
+ session_id=session_id,
291
+ thread_id=thread_id,
292
+ status=_session_status_from_turn(turn),
293
+ ),
294
+ timeline_items=self._complete_turn(session_id, thread_id, turn_id, turn),
295
+ )
296
+
297
+ if method == "turn/diff/updated":
298
+ item = self._upsert_item(
299
+ session_id=session_id,
300
+ turn_id=turn_id,
301
+ item_id=None,
302
+ derived_key="turn-diff",
303
+ item_type="artifact",
304
+ status="running",
305
+ role=None,
306
+ content={
307
+ "kind": "diff",
308
+ "unifiedDiff": _string_value(params.get("diff")) or _string_value(params.get("patch")) or "",
309
+ },
310
+ source_session_id=thread_id,
311
+ source_item_type=None,
312
+ event=method,
313
+ )
314
+ return ReductionResult(timeline_items=[item])
315
+
316
+ if method == "turn/plan/updated":
317
+ plan = params.get("plan") if isinstance(params.get("plan"), dict) else params
318
+ item = self._upsert_item(
319
+ session_id=session_id,
320
+ turn_id=turn_id,
321
+ item_id=None,
322
+ derived_key="turn-plan",
323
+ item_type="system",
324
+ status="running",
325
+ role="system",
326
+ content=_plan_content(plan),
327
+ source_session_id=thread_id,
328
+ source_item_type=None,
329
+ event=method,
330
+ )
331
+ return ReductionResult(timeline_items=[item])
332
+
333
+ if method in CODEX_APPROVAL_METHODS:
334
+ approval = self._approval_from_request(method, message, params, session_id, thread_id, turn_id)
335
+ timeline_item = self._approval_target_item(method, params, approval)
336
+ return ReductionResult(
337
+ session_update=self._session_update(
338
+ session_id=session_id,
339
+ thread_id=thread_id,
340
+ status="waiting_approval",
341
+ ),
342
+ timeline_items=[timeline_item] if timeline_item else [],
343
+ approvals=[approval],
344
+ )
345
+
346
+ if method == "item/agentMessage/delta":
347
+ item_id = _string_value(params.get("itemId")) or _nested_string(params, "item", "id")
348
+ item = self._append_text_item(
349
+ session_id=session_id,
350
+ thread_id=thread_id,
351
+ turn_id=turn_id,
352
+ item_id=item_id,
353
+ delta=_string_value(params.get("delta")) or _string_value(params.get("text")) or "",
354
+ )
355
+ return ReductionResult(timeline_items=[item])
356
+
357
+ if method == "item/commandExecution/outputDelta":
358
+ item_id = _string_value(params.get("itemId")) or _nested_string(params, "item", "id")
359
+ item = self._append_command_output(
360
+ session_id=session_id,
361
+ thread_id=thread_id,
362
+ turn_id=turn_id,
363
+ item_id=item_id,
364
+ delta=_string_value(params.get("delta")) or _string_value(params.get("text")) or "",
365
+ )
366
+ return ReductionResult(timeline_items=[item])
367
+
368
+ if method == "item/fileChange/patchUpdated":
369
+ item_id = _string_value(params.get("itemId")) or _nested_string(params, "item", "id")
370
+ item = self._upsert_item(
371
+ session_id=session_id,
372
+ turn_id=turn_id,
373
+ item_id=item_id,
374
+ derived_key=None,
375
+ item_type="tool",
376
+ status="running",
377
+ role="tool",
378
+ content={
379
+ "kind": "file_change",
380
+ "changes": [
381
+ {
382
+ "path": _string_value(params.get("path")) or "",
383
+ "action": _string_value(params.get("action")) or "unknown",
384
+ "diff": _string_value(params.get("patch")) or _string_value(params.get("diff")),
385
+ }
386
+ ],
387
+ },
388
+ source_session_id=thread_id,
389
+ source_item_type="fileChange",
390
+ event=method,
391
+ )
392
+ return ReductionResult(timeline_items=[item])
393
+
394
+ if method == "item/completed":
395
+ item = params.get("item") if isinstance(params.get("item"), dict) else params
396
+ item = dict(item)
397
+ item["_eventItemId"] = _string_value(params.get("itemId"))
398
+ timeline_item = self._upsert_completed_item(session_id, thread_id, turn_id, item, event=method)
399
+ return ReductionResult(timeline_items=[timeline_item] if timeline_item else [])
400
+
401
+ if method == "error":
402
+ item = self._upsert_item(
403
+ session_id=session_id,
404
+ turn_id=turn_id,
405
+ item_id=None,
406
+ derived_key=f"error-{_short_hash(message)}",
407
+ item_type="system",
408
+ status="failed",
409
+ role="system",
410
+ content={
411
+ "kind": "error",
412
+ "code": _string_value(params.get("code")) or "codex_error",
413
+ "message": _string_value(params.get("message")) or json.dumps(params, ensure_ascii=False),
414
+ "details": params,
415
+ "recoverable": True,
416
+ },
417
+ source_session_id=thread_id,
418
+ source_item_type=None,
419
+ event=method,
420
+ )
421
+ return ReductionResult(
422
+ session_update=self._session_update(
423
+ session_id=session_id,
424
+ thread_id=thread_id,
425
+ status="error",
426
+ ),
427
+ timeline_items=[item],
428
+ )
429
+
430
+ return ReductionResult()
431
+
432
+ def _upsert_completed_item(
433
+ self,
434
+ session_id: str,
435
+ thread_id: str | None,
436
+ turn_id: str | None,
437
+ item: dict[str, Any],
438
+ *,
439
+ event: str | None = None,
440
+ ) -> dict[str, Any] | None:
441
+ codex_type = _string_value(item.get("type")) or "unknown"
442
+ item_id = _string_value(item.get("id")) or _string_value(item.get("itemId")) or _string_value(item.get("call_id")) or _short_hash(item)
443
+ derived_key = _stable_item_key(item)
444
+ source_item_id = _string_value(item.get("_eventItemId")) or item_id
445
+ status = _timeline_status(item.get("status")) or "done"
446
+ role: str | None = None
447
+ timeline_type = "system"
448
+ content: dict[str, Any]
449
+ source_extra: dict[str, Any] | None = None
450
+
451
+ if codex_type == "userMessage":
452
+ timeline_type = "message"
453
+ role = "user"
454
+ content = {"text": _message_text(item), "format": "markdown"}
455
+ client_message = self._client_message_for_user_message(
456
+ session_id, thread_id, turn_id, content["text"]
457
+ )
458
+ client_message_id = client_message.get("clientMessageId") if client_message else None
459
+ if client_message_id:
460
+ source_extra = {"clientMessageId": client_message_id}
461
+ attachments = client_message.get("attachments") if client_message else None
462
+ if isinstance(attachments, list) and attachments:
463
+ content["attachments"] = attachments
464
+ elif codex_type == "agentMessage":
465
+ timeline_type = "message"
466
+ role = "assistant"
467
+ content = {"text": _message_text(item), "format": "markdown"}
468
+ elif codex_type == "reasoning":
469
+ if derived_key is None and turn_id is not None:
470
+ key = (session_id, turn_id)
471
+ idx = self._reasoning_index_by_turn.get(key, 0)
472
+ self._reasoning_index_by_turn[key] = idx + 1
473
+ derived_key = f"reasoning-{idx}"
474
+ role = "system"
475
+ content = _reasoning_content(item)
476
+ elif codex_type == "plan":
477
+ role = "system"
478
+ content = _plan_content(item)
479
+ elif codex_type == "turnStart":
480
+ return self._upsert_turn_start(
481
+ session_id,
482
+ thread_id,
483
+ turn_id,
484
+ item,
485
+ status=_timeline_status(item.get("_historyTurnStartStatus")) or "running",
486
+ event=event or "history/turn_started",
487
+ )
488
+ elif codex_type == "turnEnd":
489
+ return self._upsert_turn_end(session_id, thread_id, turn_id, item)
490
+ elif codex_type == "commandExecution":
491
+ timeline_type = "tool"
492
+ role = "tool"
493
+ content = _command_content(item)
494
+ elif codex_type == "function_call":
495
+ timeline_type = "tool"
496
+ role = "tool"
497
+ content = _function_call_content(item)
498
+ self._tool_kind_by_call[source_item_id] = str(content.get("kind") or "command")
499
+ elif codex_type == "fileChange":
500
+ timeline_type = "tool"
501
+ role = "tool"
502
+ content = _file_change_content(item)
503
+ elif codex_type == "custom_tool_call":
504
+ timeline_type = "tool"
505
+ role = "tool"
506
+ content = _custom_tool_call_content(item)
507
+ self._tool_kind_by_call[source_item_id] = str(content.get("kind") or "tool")
508
+ elif codex_type in {"function_call_output", "custom_tool_call_output"}:
509
+ timeline_type = "tool"
510
+ role = "tool"
511
+ content = self._tool_output_content(session_id, thread_id, turn_id, source_item_id, item)
512
+ elif codex_type == "mcpToolCall":
513
+ timeline_type = "tool"
514
+ role = "tool"
515
+ content = {
516
+ "kind": "mcp",
517
+ "server": _string_value(item.get("server")) or "",
518
+ "tool": _string_value(item.get("tool")) or _string_value(item.get("name")) or "",
519
+ "arguments": item.get("arguments"),
520
+ "result": item.get("result"),
521
+ "error": item.get("error"),
522
+ }
523
+ elif codex_type == "webSearch":
524
+ timeline_type = "tool"
525
+ role = "tool"
526
+ content = {"kind": "web_search", "query": _string_value(item.get("query")), "action": item.get("action")}
527
+ elif codex_type == "imageView":
528
+ timeline_type = "artifact"
529
+ content = {
530
+ "kind": "image",
531
+ "path": _string_value(item.get("path")) or "",
532
+ "url": _string_value(item.get("url")),
533
+ "mediaType": _string_value(item.get("mediaType")),
534
+ }
535
+ elif codex_type == "contextCompaction":
536
+ role = "system"
537
+ content = {
538
+ "kind": "codex_compaction",
539
+ "message": "Conversation context compacted.",
540
+ "details": item,
541
+ }
542
+ elif codex_type == "enteredReviewMode":
543
+ role = "system"
544
+ content = {
545
+ "kind": "codex_review",
546
+ "phase": "started",
547
+ "message": _string_value(item.get("review")) or "Code review started.",
548
+ }
549
+ elif codex_type == "exitedReviewMode":
550
+ role = "system"
551
+ content = {
552
+ "kind": "codex_review",
553
+ "phase": "completed",
554
+ "text": _string_value(item.get("review")) or "",
555
+ }
556
+ else:
557
+ role = "system"
558
+ content = {"kind": "status", "code": f"codex.{codex_type}", "message": codex_type, "details": item}
559
+
560
+ return self._upsert_item(
561
+ session_id=session_id,
562
+ turn_id=turn_id,
563
+ item_id=None if derived_key else source_item_id,
564
+ derived_key=derived_key,
565
+ item_type=timeline_type,
566
+ status=status,
567
+ role=role,
568
+ content=content,
569
+ source_session_id=thread_id,
570
+ source_item_type=codex_type,
571
+ source_item_id=source_item_id,
572
+ event=event,
573
+ source_extra=source_extra,
574
+ )
575
+
576
+ def _client_message_for_user_message(
577
+ self,
578
+ session_id: str,
579
+ thread_id: str | None,
580
+ turn_id: str | None,
581
+ text: str,
582
+ ) -> dict[str, Any] | None:
583
+ if turn_id is not None:
584
+ mapped = self._client_message_by_turn.get((session_id, thread_id, turn_id))
585
+ if mapped:
586
+ return mapped
587
+ pending_key = (session_id, thread_id)
588
+ pending = self._pending_client_messages.get(pending_key)
589
+ if not pending:
590
+ return None
591
+ for index, candidate in enumerate(pending):
592
+ expected = candidate.get("text")
593
+ if expected is None or _client_message_text_matches(text, expected):
594
+ client_message_id = candidate.get("clientMessageId")
595
+ del pending[index]
596
+ if turn_id is not None and client_message_id:
597
+ self._client_message_by_turn[(session_id, thread_id, turn_id)] = candidate
598
+ return candidate
599
+ return None
600
+
601
+ def _upsert_turn_start(
602
+ self,
603
+ session_id: str,
604
+ thread_id: str | None,
605
+ turn_id: str | None,
606
+ turn: dict[str, Any],
607
+ *,
608
+ status: str = "running",
609
+ event: str = "turn/started",
610
+ ) -> dict[str, Any]:
611
+ return self._upsert_item(
612
+ session_id=session_id,
613
+ turn_id=turn_id,
614
+ item_id=None,
615
+ derived_key="turn-start",
616
+ item_type="turn.start",
617
+ status=status,
618
+ role=None,
619
+ content={
620
+ "title": _string_value(turn.get("title")),
621
+ "inputSummary": _turn_input_summary(turn),
622
+ },
623
+ source_session_id=thread_id,
624
+ source_item_type=None,
625
+ event=event,
626
+ )
627
+
628
+ def _complete_turn(
629
+ self,
630
+ session_id: str,
631
+ thread_id: str | None,
632
+ turn_id: str | None,
633
+ turn: dict[str, Any],
634
+ ) -> list[dict[str, Any]]:
635
+ result = _turn_result(turn)
636
+ start = self._upsert_turn_start(
637
+ session_id=session_id,
638
+ thread_id=thread_id,
639
+ turn_id=turn_id,
640
+ turn=turn,
641
+ status=_turn_result_to_status(result),
642
+ event="turn/completed",
643
+ )
644
+ end = self._upsert_turn_end(session_id, thread_id, turn_id, turn)
645
+ return [start, end]
646
+
647
+ def _upsert_turn_end(
648
+ self,
649
+ session_id: str,
650
+ thread_id: str | None,
651
+ turn_id: str | None,
652
+ turn: dict[str, Any],
653
+ ) -> dict[str, Any]:
654
+ result = _turn_result(turn)
655
+ return self._upsert_item(
656
+ session_id=session_id,
657
+ turn_id=turn_id,
658
+ item_id=None,
659
+ derived_key="turn-end",
660
+ item_type="turn.end",
661
+ status=_turn_result_to_status(result),
662
+ role=None,
663
+ content={
664
+ "result": result,
665
+ "error": _error_content(turn.get("error")),
666
+ "usage": turn.get("usage"),
667
+ },
668
+ source_session_id=thread_id,
669
+ source_item_type=None,
670
+ event="turn/completed",
671
+ completed_at=_turn_completed_at(turn),
672
+ )
673
+
674
+ def _append_text_item(
675
+ self,
676
+ *,
677
+ session_id: str,
678
+ thread_id: str | None,
679
+ turn_id: str | None,
680
+ item_id: str | None,
681
+ delta: str,
682
+ ) -> dict[str, Any]:
683
+ timeline_id = _timeline_id(session_id, thread_id, turn_id, item_id, None)
684
+ existing = self._items.get(timeline_id)
685
+ text = ""
686
+ if existing:
687
+ text = str(existing.get("content", {}).get("text") or "")
688
+ return self._upsert_item(
689
+ session_id=session_id,
690
+ turn_id=turn_id,
691
+ item_id=item_id,
692
+ derived_key=None,
693
+ item_type="message",
694
+ status="running",
695
+ role="assistant",
696
+ content={"text": text + delta, "format": "markdown"},
697
+ source_session_id=thread_id,
698
+ source_item_type="agentMessage",
699
+ source_item_id=item_id,
700
+ event="item/agentMessage/delta",
701
+ )
702
+
703
+ def _append_command_output(
704
+ self,
705
+ *,
706
+ session_id: str,
707
+ thread_id: str | None,
708
+ turn_id: str | None,
709
+ item_id: str | None,
710
+ delta: str,
711
+ ) -> dict[str, Any]:
712
+ timeline_id = _timeline_id(session_id, thread_id, turn_id, item_id, None)
713
+ existing = self._items.get(timeline_id)
714
+ content = dict(existing.get("content", {})) if existing else {"kind": "command", "command": ""}
715
+ output = str(content.get("outputText") or "") + delta
716
+ output_preview = _preview_text(output)
717
+ content["outputText"] = output_preview
718
+ content["outputPreview"] = output_preview
719
+ content["outputTruncated"] = len(output) > OUTPUT_PREVIEW_CHARS
720
+ content["outputLength"] = len(output)
721
+ return self._upsert_item(
722
+ session_id=session_id,
723
+ turn_id=turn_id,
724
+ item_id=item_id,
725
+ derived_key=None,
726
+ item_type="tool",
727
+ status="running",
728
+ role="tool",
729
+ content=content,
730
+ source_session_id=thread_id,
731
+ source_item_type="commandExecution",
732
+ event="item/commandExecution/outputDelta",
733
+ )
734
+
735
+ def _tool_output_content(
736
+ self,
737
+ session_id: str,
738
+ thread_id: str | None,
739
+ turn_id: str | None,
740
+ item_id: str,
741
+ item: dict[str, Any],
742
+ ) -> dict[str, Any]:
743
+ timeline_id = _timeline_id(session_id, thread_id, turn_id, item_id, None)
744
+ existing = self._items.get(timeline_id)
745
+ content = dict(existing.get("content", {})) if existing else {"kind": self._tool_kind_by_call.get(item_id, "tool")}
746
+ content["result"] = _tool_output_value(item)
747
+ output = _tool_output_text(item)
748
+ output_preview = _preview_text(output)
749
+ content["outputText"] = output_preview
750
+ content["outputPreview"] = output_preview
751
+ content["outputTruncated"] = len(output) > OUTPUT_PREVIEW_CHARS
752
+ content["outputLength"] = len(output)
753
+ return content
754
+
755
+ def _approval_from_request(
756
+ self,
757
+ method: str,
758
+ message: dict[str, Any],
759
+ params: dict[str, Any],
760
+ session_id: str,
761
+ thread_id: str | None,
762
+ turn_id: str | None,
763
+ ) -> dict[str, Any]:
764
+ item_id = _string_value(params.get("itemId")) or _nested_string(params, "item", "id")
765
+ approval_id = f"appr_{_short_hash([session_id, thread_id, turn_id, item_id, method, message.get('id')])}"
766
+ if "commandExecution" in method:
767
+ kind = "command"
768
+ title = "Codex wants to run a command"
769
+ elif "fileChange" in method:
770
+ kind = "file_change"
771
+ title = "Codex wants to change files"
772
+ elif "permissions" in method:
773
+ kind = "permission"
774
+ title = "Codex requests permission"
775
+ else:
776
+ kind = "unknown"
777
+ title = "Codex requests approval"
778
+
779
+ return {
780
+ "id": approval_id,
781
+ "sessionId": session_id,
782
+ "turnId": turn_id,
783
+ "status": "pending",
784
+ "kind": kind,
785
+ "targetItemId": _timeline_id(session_id, thread_id, turn_id, item_id, None) if item_id else None,
786
+ "title": title,
787
+ "description": _approval_description(params),
788
+ "payload": params,
789
+ "choices": ["approve", "approve_for_session", "reject", "cancel"],
790
+ "source": {
791
+ "runtime": "codex",
792
+ "requestId": message.get("id"),
793
+ "sessionId": thread_id,
794
+ "turnId": turn_id,
795
+ "itemId": item_id,
796
+ "method": method,
797
+ },
798
+ }
799
+
800
+ def _approval_target_item(
801
+ self,
802
+ method: str,
803
+ params: dict[str, Any],
804
+ approval: dict[str, Any],
805
+ ) -> dict[str, Any] | None:
806
+ target_item_id = approval.get("targetItemId")
807
+ if not isinstance(target_item_id, str):
808
+ return None
809
+ existing = self._items.get(target_item_id)
810
+ content = dict(existing.get("content", {})) if existing else {}
811
+ if not content:
812
+ if approval["kind"] == "command":
813
+ content = {
814
+ "kind": "command",
815
+ "command": params.get("command") or params.get("cmd") or "",
816
+ "cwd": _string_value(params.get("cwd")),
817
+ }
818
+ else:
819
+ content = {
820
+ "kind": "file_change" if approval["kind"] == "file_change" else "unknown",
821
+ "changes": [],
822
+ }
823
+ content["approval"] = {"id": approval["id"], "status": "pending"}
824
+ item_id = _string_value(params.get("itemId")) or _nested_string(params, "item", "id")
825
+ return self._upsert_item(
826
+ session_id=approval["sessionId"],
827
+ turn_id=approval.get("turnId"),
828
+ item_id=item_id,
829
+ derived_key=None,
830
+ item_type="tool",
831
+ status="waiting_approval",
832
+ role="tool",
833
+ content=content,
834
+ source_session_id=approval["source"].get("sessionId"),
835
+ source_item_type="commandExecution" if "commandExecution" in method else "fileChange",
836
+ event=method,
837
+ )
838
+
839
+ def _upsert_item(
840
+ self,
841
+ *,
842
+ session_id: str,
843
+ turn_id: str | None,
844
+ item_id: str | None,
845
+ derived_key: str | None,
846
+ item_type: str,
847
+ status: str,
848
+ role: str | None,
849
+ content: dict[str, Any],
850
+ source_session_id: str | None,
851
+ source_item_type: str | None,
852
+ event: str | None,
853
+ source_item_id: str | None = None,
854
+ completed_at: str | None = None,
855
+ source_extra: dict[str, Any] | None = None,
856
+ ) -> dict[str, Any]:
857
+ timeline_id = _timeline_id(session_id, source_session_id, turn_id, item_id, derived_key)
858
+ order_seq = self._order_by_item.setdefault(timeline_id, self._allocate_order_seq())
859
+ existing = self._items.get(timeline_id)
860
+ revision = int(existing.get("revision", 0)) + 1 if existing else 1
861
+ now = utc_now()
862
+ source = {
863
+ "runtime": "codex",
864
+ "sessionId": source_session_id,
865
+ "turnId": turn_id,
866
+ "itemId": source_item_id or item_id,
867
+ "itemType": source_item_type,
868
+ "event": event,
869
+ "derivedKey": derived_key,
870
+ }
871
+ if source_extra:
872
+ source.update(source_extra)
873
+ source = {key: value for key, value in source.items() if value is not None}
874
+ content_hash = _content_hash(item_type, status, role, content, source)
875
+ if existing and existing.get("contentHash") == content_hash:
876
+ return existing
877
+ snapshot = {
878
+ "id": timeline_id,
879
+ "sessionId": session_id,
880
+ "turnId": turn_id,
881
+ "type": item_type,
882
+ "status": status,
883
+ "role": role,
884
+ "content": content,
885
+ "source": source,
886
+ "orderSeq": order_seq,
887
+ "revision": revision,
888
+ "contentHash": content_hash,
889
+ "createdAt": existing.get("createdAt") if existing else now,
890
+ "updatedAt": now,
891
+ "completedAt": completed_at,
892
+ }
893
+ if role is None:
894
+ snapshot.pop("role")
895
+ if turn_id is None:
896
+ snapshot.pop("turnId")
897
+ if completed_at is None:
898
+ snapshot.pop("completedAt")
899
+ self._items[timeline_id] = snapshot
900
+ return snapshot
901
+
902
+ def _allocate_order_seq(self) -> int:
903
+ value = self._next_order
904
+ self._next_order += 1
905
+ return value
906
+
907
+
908
+ def _timeline_id(
909
+ session_id: str,
910
+ source_session_id: str | None,
911
+ turn_id: str | None,
912
+ item_id: str | None,
913
+ derived_key: str | None,
914
+ ) -> str:
915
+ identity = [session_id, "codex", source_session_id, turn_id, item_id or derived_key]
916
+ return f"tl_{_short_hash(identity)}"
917
+
918
+
919
+ def _content_hash(*values: Any) -> str:
920
+ return f"sha256:{_short_hash(values, length=64)}"
921
+
922
+
923
+ def _client_message_text_matches(actual: str, expected: str) -> bool:
924
+ if actual == expected:
925
+ return True
926
+ return actual.startswith(expected) and actual[len(expected) :].startswith("\n\n[")
927
+
928
+
929
+ def _short_hash(value: Any, *, length: int = 20) -> str:
930
+ encoded = json.dumps(value, ensure_ascii=False, sort_keys=True, default=str).encode("utf-8")
931
+ return hashlib.sha256(encoded).hexdigest()[:length]
932
+
933
+
934
+ def _extract_thread_id(params: dict[str, Any]) -> str | None:
935
+ return _string_value(params.get("threadId")) or _nested_string(params, "thread", "id")
936
+
937
+
938
+ def _extract_turn_id(params: dict[str, Any]) -> str | None:
939
+ return _string_value(params.get("turnId")) or _nested_string(params, "turn", "id")
940
+
941
+
942
+ def _thread_id(thread: dict[str, Any]) -> str | None:
943
+ return _string_value(thread.get("id")) or _string_value(thread.get("threadId")) or _nested_string(thread, "thread", "id")
944
+
945
+
946
+ def _string_value(value: Any) -> str | None:
947
+ return value if isinstance(value, str) else None
948
+
949
+
950
+ def _nested_string(data: dict[str, Any], key: str, nested_key: str) -> str | None:
951
+ nested = data.get(key)
952
+ if not isinstance(nested, dict):
953
+ return None
954
+ return _string_value(nested.get(nested_key))
955
+
956
+
957
+ def _list_value(value: Any) -> list[dict[str, Any]]:
958
+ return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
959
+
960
+
961
+ def _message_type_counts(items: list[dict[str, Any]]) -> dict[str, int]:
962
+ counts: dict[str, int] = {}
963
+ for item in items:
964
+ codex_type = _string_value(item.get("type"))
965
+ if codex_type in {"userMessage", "agentMessage"}:
966
+ counts[codex_type] = counts.get(codex_type, 0) + 1
967
+ return counts
968
+
969
+
970
+ def _message_text(item: dict[str, Any]) -> str:
971
+ if isinstance(item.get("text"), str):
972
+ return item["text"]
973
+ parts = item.get("parts")
974
+ if isinstance(parts, list):
975
+ return "".join(str(part.get("text") or "") for part in parts if isinstance(part, dict))
976
+ content = item.get("content")
977
+ if isinstance(content, list):
978
+ return "".join(str(part.get("text") or "") for part in content if isinstance(part, dict))
979
+ return ""
980
+
981
+
982
+ def _is_bootstrap_user_message(item: dict[str, Any]) -> bool:
983
+ if _string_value(item.get("type")) != "userMessage":
984
+ return False
985
+ text = _message_text(item).lstrip()
986
+ return (
987
+ text.startswith("# AGENTS.md instructions for ")
988
+ and "<INSTRUCTIONS>" in text
989
+ and "<environment_context>" in text
990
+ )
991
+
992
+
993
+ def _is_external_import_marker(item: dict[str, Any]) -> bool:
994
+ if _string_value(item.get("type")) not in {"userMessage", "agentMessage"}:
995
+ return False
996
+ return _message_text(item).strip() == "<EXTERNAL SESSION IMPORTED>"
997
+
998
+
999
+ def _stable_item_key(item: dict[str, Any]) -> str | None:
1000
+ derived_key = _string_value(item.get("_derivedKey"))
1001
+ if derived_key:
1002
+ return derived_key
1003
+ message_key = _string_value(item.get("_messageKey"))
1004
+ if message_key:
1005
+ return message_key
1006
+ codex_type = _string_value(item.get("type")) or "unknown"
1007
+ if codex_type in {"userMessage", "agentMessage"}:
1008
+ item_id = _string_value(item.get("id")) or _string_value(item.get("_eventItemId"))
1009
+ if item_id and not item_id.startswith("item-"):
1010
+ return None
1011
+ return _message_item_key(codex_type)
1012
+ if codex_type == "reasoning":
1013
+ idx = item.get("_reasoningTurnIndex")
1014
+ if isinstance(idx, int):
1015
+ return f"reasoning-{idx}"
1016
+ return None
1017
+ item_id = _string_value(item.get("id"))
1018
+ if not item_id or not item_id.startswith("item-"):
1019
+ return None
1020
+ index = item.get("_snapshotIndex")
1021
+ if isinstance(index, int):
1022
+ return f"snapshot-{codex_type}-{index}"
1023
+ return f"snapshot-{codex_type}-{item_id}"
1024
+
1025
+
1026
+ def _message_item_key(codex_type: str) -> str:
1027
+ return f"message-{codex_type}"
1028
+
1029
+
1030
+ def _reasoning_content(item: dict[str, Any]) -> dict[str, Any]:
1031
+ summaries = item.get("summaries")
1032
+ if not isinstance(summaries, list):
1033
+ summaries = item.get("summary")
1034
+ if isinstance(summaries, list):
1035
+ normalized = [
1036
+ {"index": index, "text": str(summary.get("text") or "") if isinstance(summary, dict) else str(summary)}
1037
+ for index, summary in enumerate(summaries)
1038
+ ]
1039
+ else:
1040
+ normalized = []
1041
+ return {"kind": "reasoning", "summaries": normalized, "rawText": _string_value(item.get("text"))}
1042
+
1043
+
1044
+ def _plan_content(plan: dict[str, Any]) -> dict[str, Any]:
1045
+ steps = plan.get("steps")
1046
+ normalized_steps = []
1047
+ if isinstance(steps, list):
1048
+ for step in steps:
1049
+ if isinstance(step, dict):
1050
+ normalized_steps.append(
1051
+ {
1052
+ "text": str(step.get("text") or step.get("description") or ""),
1053
+ "status": _plan_step_status(step.get("status")),
1054
+ }
1055
+ )
1056
+ else:
1057
+ normalized_steps.append({"text": str(step), "status": "pending"})
1058
+ return {
1059
+ "kind": "plan",
1060
+ "explanation": _string_value(plan.get("explanation")),
1061
+ "steps": normalized_steps,
1062
+ "text": _string_value(plan.get("text")),
1063
+ }
1064
+
1065
+
1066
+ def _plan_step_status(value: Any) -> str:
1067
+ if value in {"pending", "running", "done"}:
1068
+ return str(value)
1069
+ if value == "completed":
1070
+ return "done"
1071
+ if value == "in_progress":
1072
+ return "running"
1073
+ return "pending"
1074
+
1075
+
1076
+ def _command_content(item: dict[str, Any]) -> dict[str, Any]:
1077
+ output = (
1078
+ _string_value(item.get("outputText"))
1079
+ or _string_value(item.get("output"))
1080
+ or _string_value(item.get("aggregatedOutput"))
1081
+ or ""
1082
+ )
1083
+ output_preview = _preview_text(output)
1084
+ return {
1085
+ "kind": "command",
1086
+ "command": item.get("command") or item.get("cmd") or "",
1087
+ "cwd": _string_value(item.get("cwd")),
1088
+ "outputText": output_preview,
1089
+ "outputPreview": output_preview,
1090
+ "outputTruncated": len(output) > OUTPUT_PREVIEW_CHARS,
1091
+ "outputLength": len(output),
1092
+ "exitCode": item.get("exitCode"),
1093
+ "durationMs": item.get("durationMs"),
1094
+ "processId": item.get("processId"),
1095
+ "actions": item.get("commandActions"),
1096
+ }
1097
+
1098
+
1099
+ def _function_call_content(item: dict[str, Any]) -> dict[str, Any]:
1100
+ name = _string_value(item.get("name")) or "function"
1101
+ arguments = _parse_jsonish(item.get("arguments"))
1102
+ if name == "exec_command":
1103
+ command = arguments.get("cmd") if isinstance(arguments, dict) else None
1104
+ return {
1105
+ "kind": "command",
1106
+ "command": command or "",
1107
+ "cwd": arguments.get("workdir") if isinstance(arguments, dict) else None,
1108
+ "arguments": arguments,
1109
+ "function": name,
1110
+ }
1111
+ if name in {"web", "web.run"} or name.startswith("web."):
1112
+ return {"kind": "web_search", "query": _query_from_arguments(arguments), "action": arguments, "function": name}
1113
+ return {"kind": "mcp", "server": "function", "tool": name, "arguments": arguments, "result": None, "error": None}
1114
+
1115
+
1116
+ def _custom_tool_call_content(item: dict[str, Any]) -> dict[str, Any]:
1117
+ name = _string_value(item.get("name")) or "custom_tool"
1118
+ call_input = item.get("input")
1119
+ if name == "apply_patch":
1120
+ return {"kind": "file_change", "tool": name, "changes": _changes_from_patch(_string_value(call_input) or "")}
1121
+ return {"kind": "mcp", "server": "custom", "tool": name, "arguments": call_input, "result": None, "error": None}
1122
+
1123
+
1124
+ def _tool_output_value(item: dict[str, Any]) -> Any:
1125
+ output = item.get("output")
1126
+ if isinstance(output, str):
1127
+ parsed = _parse_jsonish(output)
1128
+ return parsed
1129
+ return output
1130
+
1131
+
1132
+ def _tool_output_text(item: dict[str, Any]) -> str:
1133
+ output = _tool_output_value(item)
1134
+ if isinstance(output, dict):
1135
+ for key in ("output", "text", "message"):
1136
+ if isinstance(output.get(key), str):
1137
+ return output[key]
1138
+ return json.dumps(output, ensure_ascii=False, indent=2)
1139
+ if output is None:
1140
+ return ""
1141
+ return str(output)
1142
+
1143
+
1144
+ def _preview_text(value: str) -> str:
1145
+ return value[-OUTPUT_PREVIEW_CHARS:]
1146
+
1147
+
1148
+ def _file_change_content(item: dict[str, Any]) -> dict[str, Any]:
1149
+ changes = item.get("changes")
1150
+ if not isinstance(changes, list):
1151
+ changes = [
1152
+ {
1153
+ "path": _string_value(item.get("path")) or "",
1154
+ "action": _string_value(item.get("action")) or "unknown",
1155
+ "diff": _string_value(item.get("diff")) or _string_value(item.get("patch")),
1156
+ }
1157
+ ]
1158
+ return {"kind": "file_change", "changes": changes}
1159
+
1160
+
1161
+ def _parse_jsonish(value: Any) -> Any:
1162
+ if not isinstance(value, str):
1163
+ return value
1164
+ try:
1165
+ return json.loads(value)
1166
+ except json.JSONDecodeError:
1167
+ return value
1168
+
1169
+
1170
+ def _query_from_arguments(arguments: Any) -> str | None:
1171
+ if isinstance(arguments, dict):
1172
+ query = arguments.get("query") or arguments.get("q")
1173
+ if isinstance(query, str):
1174
+ return query
1175
+ search_query = arguments.get("search_query")
1176
+ if isinstance(search_query, list) and search_query and isinstance(search_query[0], dict):
1177
+ q = search_query[0].get("q")
1178
+ return q if isinstance(q, str) else None
1179
+ return None
1180
+
1181
+
1182
+ def _changes_from_patch(patch: str) -> list[dict[str, Any]]:
1183
+ changes: list[dict[str, Any]] = []
1184
+ current: dict[str, Any] | None = None
1185
+ diff_lines: list[str] = []
1186
+ for line in patch.splitlines():
1187
+ if line.startswith("*** Add File: ") or line.startswith("*** Update File: ") or line.startswith("*** Delete File: "):
1188
+ if current is not None:
1189
+ current["diff"] = "\n".join(diff_lines)
1190
+ changes.append(current)
1191
+ action, path = _patch_header(line)
1192
+ current = {"path": path, "action": action}
1193
+ diff_lines = []
1194
+ elif current is not None:
1195
+ diff_lines.append(line)
1196
+ if current is not None:
1197
+ current["diff"] = "\n".join(diff_lines)
1198
+ changes.append(current)
1199
+ return changes or [{"path": "", "action": "patch", "diff": patch}]
1200
+
1201
+
1202
+ def _patch_header(line: str) -> tuple[str, str]:
1203
+ if line.startswith("*** Add File: "):
1204
+ return "add", line.removeprefix("*** Add File: ").strip()
1205
+ if line.startswith("*** Delete File: "):
1206
+ return "delete", line.removeprefix("*** Delete File: ").strip()
1207
+ return "update", line.removeprefix("*** Update File: ").strip()
1208
+
1209
+
1210
+ def _timeline_status(value: Any) -> str | None:
1211
+ if value in {"pending", "running", "waiting_approval", "done", "failed", "cancelled", "interrupted"}:
1212
+ return str(value)
1213
+ if value in {"completed", "succeeded"}:
1214
+ return "done"
1215
+ if value in {"inProgress", "in_progress"}:
1216
+ return "running"
1217
+ return None
1218
+
1219
+
1220
+ def _turn_status(turn: dict[str, Any]) -> str:
1221
+ status = turn.get("status")
1222
+ if isinstance(status, dict):
1223
+ return str(status.get("type") or "")
1224
+ return str(status or "")
1225
+
1226
+
1227
+ def _turn_result(turn: dict[str, Any]) -> str:
1228
+ status = _turn_status(turn)
1229
+ if status in {"completed", "failed", "interrupted", "cancelled"}:
1230
+ return status
1231
+ return "completed"
1232
+
1233
+
1234
+ def _turn_completed_at(turn: dict[str, Any]) -> str | None:
1235
+ for key in (
1236
+ "completedAt",
1237
+ "completed_at",
1238
+ "endedAt",
1239
+ "ended_at",
1240
+ "finishedAt",
1241
+ "finished_at",
1242
+ "updatedAt",
1243
+ "updated_at",
1244
+ ):
1245
+ value = turn.get(key)
1246
+ if isinstance(value, str) and value:
1247
+ return value
1248
+ return None
1249
+
1250
+
1251
+ def _turn_result_to_status(result: str) -> str:
1252
+ if result == "completed":
1253
+ return "done"
1254
+ if result in {"failed", "interrupted", "cancelled"}:
1255
+ return result
1256
+ return "done"
1257
+
1258
+
1259
+ def _session_status_from_turn(turn: dict[str, Any]) -> str:
1260
+ result = _turn_result(turn)
1261
+ if result == "completed":
1262
+ return "idle"
1263
+ if result in {"interrupted", "cancelled"}:
1264
+ return "idle"
1265
+ return "error"
1266
+
1267
+
1268
+ def _session_status_from_thread(thread: dict[str, Any]) -> str:
1269
+ status = thread.get("status")
1270
+ status_type = status.get("type") if isinstance(status, dict) else status
1271
+ if status_type in {"running", "inProgress"}:
1272
+ return "running"
1273
+ if status_type == "waiting_approval":
1274
+ return "waiting_approval"
1275
+ if status_type == "error":
1276
+ return "error"
1277
+ return "idle"
1278
+
1279
+
1280
+ def _turn_input_summary(turn: dict[str, Any]) -> str | None:
1281
+ input_value = turn.get("input")
1282
+ if isinstance(input_value, str):
1283
+ return input_value[:200]
1284
+ if isinstance(input_value, list):
1285
+ text = "".join(str(item.get("text") or "") for item in input_value if isinstance(item, dict))
1286
+ return text[:200] if text else None
1287
+ return None
1288
+
1289
+
1290
+ def _error_content(value: Any) -> dict[str, Any] | None:
1291
+ if value is None:
1292
+ return None
1293
+ if isinstance(value, dict):
1294
+ return {
1295
+ "code": _string_value(value.get("code")),
1296
+ "message": _string_value(value.get("message")) or json.dumps(value, ensure_ascii=False),
1297
+ "details": value,
1298
+ }
1299
+ return {"message": str(value)}
1300
+
1301
+
1302
+ def _approval_description(params: dict[str, Any]) -> str | None:
1303
+ parts = [
1304
+ _string_value(params.get("command")),
1305
+ _string_value(params.get("reason")),
1306
+ _string_value(params.get("cwd")),
1307
+ _string_value(params.get("grantRoot")),
1308
+ ]
1309
+ return "\n".join(part for part in parts if part) or None