anywhere-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.
@@ -0,0 +1,951 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from collections.abc import Awaitable, Callable
5
+ from dataclasses import dataclass, field
6
+ from datetime import UTC, datetime
7
+ import hashlib
8
+ import json
9
+ import time
10
+ from typing import Any
11
+
12
+ from loguru import logger
13
+
14
+ from connector.attachments import attachment_target
15
+ from connector.codex.reducer import CODEX_APPROVAL_METHODS, ReductionResult, TimelineReducer
16
+ from connector.codex.rpc import JsonRpcStdioClient
17
+ from connector.sync_state import SyncStateStore
18
+ from connector.time import utc_now
19
+
20
+
21
+ AttachmentDownloader = Callable[[str, str], Awaitable[tuple[bytes, str, str]]]
22
+ """(session_id, file_id) -> (data, original_name, media_type)"""
23
+
24
+ EXISTING_SYNC_SCAN_TIMEOUT_SECONDS = 1200.0
25
+ EXISTING_SYNC_CHANGED_THREAD_TIMEOUT_SECONDS = 1200.0
26
+
27
+
28
+ def _thread_id_from_result(value: dict[str, Any]) -> str | None:
29
+ thread = value.get("thread") if isinstance(value.get("thread"), dict) else value
30
+ if not isinstance(thread, dict):
31
+ return None
32
+ for key in ("id", "thread_id", "threadId"):
33
+ if isinstance(thread.get(key), str):
34
+ return thread[key]
35
+ nested = thread.get("thread")
36
+ if isinstance(nested, dict) and isinstance(nested.get("id"), str):
37
+ return nested["id"]
38
+ return None
39
+
40
+
41
+ def _timeline_attachments(params: dict[str, Any]) -> list[dict[str, Any]]:
42
+ raw = params.get("timelineAttachments")
43
+ if not isinstance(raw, list):
44
+ raw = params.get("attachments")
45
+ if not isinstance(raw, list):
46
+ return []
47
+ out: list[dict[str, Any]] = []
48
+ for entry in raw:
49
+ if not isinstance(entry, dict):
50
+ continue
51
+ file_id = entry.get("fileId") or entry.get("id")
52
+ if not isinstance(file_id, str) or not file_id:
53
+ continue
54
+ item: dict[str, Any] = {"fileId": file_id}
55
+ for key in ("name", "mediaType", "size", "sha256"):
56
+ value = entry.get(key)
57
+ if value is not None:
58
+ item[key] = value
59
+ out.append(item)
60
+ return out
61
+
62
+
63
+ def _turn_id_from_result(value: dict[str, Any]) -> str | None:
64
+ turn = value.get("turn") if isinstance(value.get("turn"), dict) else value
65
+ if not isinstance(turn, dict):
66
+ return None
67
+ for key in ("id", "turn_id", "turnId"):
68
+ if isinstance(turn.get(key), str):
69
+ return turn[key]
70
+ nested = turn.get("turn")
71
+ if isinstance(nested, dict) and isinstance(nested.get("id"), str):
72
+ return nested["id"]
73
+ return None
74
+
75
+
76
+ @dataclass(slots=True)
77
+ class CodexAdapter:
78
+ """Adapter around Codex app-server.
79
+
80
+ The adapter does not talk to the backend directly. It returns normalized
81
+ notification payloads so the connector runtime can forward them over its
82
+ backend WebSocket.
83
+ """
84
+
85
+ rpc: JsonRpcStdioClient | None = None
86
+ reducer: TimelineReducer | None = None
87
+ notification_sink: Callable[[str, dict[str, Any]], Awaitable[None]] | None = None
88
+ attachment_downloader: AttachmentDownloader | None = None
89
+ sync_state_store: SyncStateStore | None = None
90
+ _started: bool = False
91
+ _loaded_thread_ids: set[str] = field(default_factory=set)
92
+ _history_sync_tasks: dict[str, asyncio.Task[None]] = field(default_factory=dict)
93
+ _existing_thread_sync_markers: dict[str, str] = field(default_factory=dict)
94
+ _existing_thread_names: dict[str, str | None] = field(default_factory=dict)
95
+
96
+ def __post_init__(self) -> None:
97
+ if self.rpc is None:
98
+ self.rpc = JsonRpcStdioClient()
99
+ if self.reducer is None:
100
+ self.reducer = TimelineReducer()
101
+
102
+ def forget_sync_state(self) -> None:
103
+ """Drop the in-memory "I already told the backend about thread X"
104
+ markers so the next `sync_existing_sessions` re-ingests everything.
105
+
106
+ Called when the server-side runtime entry has been removed
107
+ (DELETE /runtime-capabilities/{runtime}). Without this, the
108
+ adapter would keep skipping threads it had already pushed in a
109
+ previous lifetime, even though the backend SQL no longer has them.
110
+ """
111
+ self._existing_thread_sync_markers.clear()
112
+ self._existing_thread_names.clear()
113
+
114
+ def forget_persisted_sync_state(self, connector_id: str) -> None:
115
+ self.forget_sync_state()
116
+ if self.sync_state_store is not None:
117
+ self.sync_state_store.delete_runtime("codex", connector_id)
118
+
119
+ async def start(self) -> None:
120
+ assert self.rpc is not None
121
+ await self.rpc.start(self.handle_notification)
122
+ if self._started:
123
+ return
124
+ await self._best_effort_bootstrap_reads()
125
+ self._started = True
126
+
127
+ async def create_session(self, params: dict[str, Any]) -> dict[str, Any]:
128
+ await self.start()
129
+ assert self.rpc is not None
130
+ assert self.reducer is not None
131
+ result = await self.rpc.request(
132
+ "thread/start",
133
+ {
134
+ "cwd": params.get("cwd"),
135
+ "model": params.get("model"),
136
+ "approvalPolicy": params.get("approvalPolicy"),
137
+ "sandbox": _sandbox_mode(params.get("sandbox")),
138
+ "ephemeral": params.get("ephemeral", False),
139
+ },
140
+ )
141
+ thread_id = _thread_id_from_result(result)
142
+ if thread_id is None:
143
+ raise RuntimeError(f"Codex thread/start did not return a thread id: {json.dumps(result, ensure_ascii=False)}")
144
+ self._loaded_thread_ids.add(thread_id)
145
+ session_id = params.get("sessionId")
146
+ connector_id = params.get("connectorId")
147
+ if not isinstance(session_id, str) and isinstance(connector_id, str):
148
+ session_id = stable_session_id(connector_id, thread_id)
149
+ if isinstance(session_id, str):
150
+ self.reducer.bind_session(session_id, thread_id)
151
+ return {
152
+ "sessionId": session_id,
153
+ "externalSessionId": thread_id,
154
+ "thread": result.get("thread") or result,
155
+ "backendNotifications": [
156
+ {
157
+ "method": "session.updated",
158
+ "params": {
159
+ "sessionId": session_id,
160
+ "runtime": "codex",
161
+ "externalSessionId": thread_id,
162
+ "status": "idle",
163
+ "cwd": params.get("cwd"),
164
+ },
165
+ }
166
+ ]
167
+ if isinstance(session_id, str)
168
+ else [],
169
+ }
170
+
171
+ async def sync_session(self, params: dict[str, Any]) -> dict[str, Any]:
172
+ await self.start()
173
+ assert self.rpc is not None
174
+ assert self.reducer is not None
175
+ session_id = _required_string(params, "sessionId")
176
+ thread_id = _required_string(params, "externalSessionId")
177
+ self.reducer.bind_session(session_id, thread_id)
178
+ started = time.perf_counter()
179
+ logger.info("codex session sync started session_id={} thread_id={}", session_id, thread_id)
180
+ await self._ensure_thread_loaded(thread_id, force=True)
181
+ reduced, thread = await self._reduce_current_timeline(session_id, thread_id)
182
+ elapsed_ms = (time.perf_counter() - started) * 1000
183
+ logger.info(
184
+ "codex session sync completed session_id={} thread_id={} timeline_items={} approvals={} elapsed_ms={:.1f}",
185
+ session_id,
186
+ thread_id,
187
+ len(reduced.timeline_items),
188
+ len(reduced.approvals),
189
+ elapsed_ms,
190
+ )
191
+ return {
192
+ "thread": thread,
193
+ "backendNotifications": _backend_notifications_from_reduction(reduced, timeline_method="timeline.sync"),
194
+ }
195
+
196
+ async def sync_existing_sessions(
197
+ self,
198
+ connector_id: str,
199
+ *,
200
+ limit: int = 100,
201
+ force: bool = False,
202
+ notification_sink: Callable[[list[dict[str, Any]]], Awaitable[None]] | None = None,
203
+ ) -> dict[str, Any]:
204
+ await self.start()
205
+ assert self.rpc is not None
206
+ assert self.reducer is not None
207
+
208
+ list_result = await asyncio.wait_for(
209
+ self.rpc.request("thread/list", {"limit": limit, "sortKey": "updated_at"}),
210
+ timeout=EXISTING_SYNC_SCAN_TIMEOUT_SECONDS,
211
+ )
212
+ thread_refs = _thread_refs_from_list_result(list_result)
213
+ notifications: list[dict[str, Any]] = []
214
+ synced_threads: list[str] = []
215
+ skipped_threads: list[str] = []
216
+ notification_count = 0
217
+ started = time.perf_counter()
218
+ logger.info(
219
+ "codex existing thread sync started connector_id={} threads={} force={}",
220
+ connector_id,
221
+ len(thread_refs),
222
+ force,
223
+ )
224
+ for thread_ref in thread_refs:
225
+ thread_id = _thread_id_from_result(thread_ref)
226
+ if not thread_id:
227
+ continue
228
+ local_state = _local_thread_state(thread_ref)
229
+ if local_state in {"archived", "deleted", "unresumable"}:
230
+ logger.info(
231
+ "codex skipping local {} thread thread_id={}",
232
+ local_state,
233
+ thread_id,
234
+ )
235
+ skipped_threads.append(thread_id)
236
+ continue
237
+ sync_marker = _thread_sync_marker(thread_ref)
238
+ current_name = _optional_string(thread_ref.get("name"))
239
+ persisted_state = (
240
+ self.sync_state_store.get("codex", connector_id, thread_id)
241
+ if self.sync_state_store is not None
242
+ else None
243
+ )
244
+ previous_marker = self._existing_thread_sync_markers.get(thread_id)
245
+ if previous_marker is None and persisted_state is not None:
246
+ previous_marker = _optional_string((persisted_state.fingerprint or {}).get("marker"))
247
+ if previous_marker is not None:
248
+ self._existing_thread_sync_markers[thread_id] = previous_marker
249
+ previous_name = _optional_string((persisted_state.metadata or {}).get("name"))
250
+ if previous_name is not None:
251
+ self._existing_thread_names[thread_id] = previous_name
252
+ if not force and sync_marker is not None and previous_marker == sync_marker:
253
+ # Codex may rename a thread without bumping updatedAt — diff
254
+ # the name independently and push a title-only update.
255
+ if self._existing_thread_names.get(thread_id) != current_name:
256
+ session_id = stable_session_id(connector_id, thread_id)
257
+ rename_notification = {
258
+ "method": "session.updated",
259
+ "params": {
260
+ "sessionId": session_id,
261
+ "title": current_name,
262
+ "sourceObservedAt": utc_now(),
263
+ },
264
+ }
265
+ notification_count += 1
266
+ if notification_sink is not None:
267
+ await notification_sink([rename_notification])
268
+ else:
269
+ notifications.append(rename_notification)
270
+ self._existing_thread_names[thread_id] = current_name
271
+ self._persist_sync_state(connector_id, thread_id, sync_marker, current_name)
272
+ skipped_threads.append(thread_id)
273
+ continue
274
+ session_id = stable_session_id(connector_id, thread_id)
275
+ self.reducer.bind_session(session_id, thread_id)
276
+ try:
277
+ reduced, _thread = await asyncio.wait_for(
278
+ self._sync_changed_existing_thread(
279
+ session_id,
280
+ thread_id,
281
+ thread_ref=thread_ref,
282
+ ),
283
+ timeout=EXISTING_SYNC_CHANGED_THREAD_TIMEOUT_SECONDS,
284
+ )
285
+ except TimeoutError:
286
+ logger.warning(
287
+ "codex existing thread sync timed out thread_id={} timeout_s={}",
288
+ thread_id,
289
+ EXISTING_SYNC_CHANGED_THREAD_TIMEOUT_SECONDS,
290
+ )
291
+ continue
292
+ except Exception as exc:
293
+ reason = _unresumable_thread_failure_reason(str(exc))
294
+ if reason is not None:
295
+ logger.info(
296
+ "codex skipping {} thread thread_id={} error={}",
297
+ reason,
298
+ thread_id,
299
+ exc,
300
+ )
301
+ skipped_threads.append(thread_id)
302
+ if sync_marker is not None:
303
+ self._existing_thread_sync_markers[thread_id] = sync_marker
304
+ continue
305
+ logger.warning("codex existing thread sync failed thread_id={} error={}", thread_id, exc)
306
+ continue
307
+ if _is_imported_external_thread(reduced.timeline_items):
308
+ logger.info(
309
+ "codex skipping imported external thread thread_id={} items={}",
310
+ thread_id,
311
+ len(reduced.timeline_items),
312
+ )
313
+ skipped_threads.append(thread_id)
314
+ if sync_marker is not None:
315
+ self._existing_thread_sync_markers[thread_id] = sync_marker
316
+ self._persist_sync_state(connector_id, thread_id, sync_marker, current_name)
317
+ continue
318
+ if reduced.session_update is not None:
319
+ reduced.session_update["runtime"] = "codex"
320
+ last_activity_at = _codex_time(thread_ref.get("updatedAt") or thread_ref.get("updated_at"))
321
+ if last_activity_at is not None:
322
+ reduced.session_update["lastActivityAt"] = last_activity_at
323
+ thread_notifications = _backend_notifications_from_reduction(reduced, timeline_method="timeline.sync")
324
+ notification_count += len(thread_notifications)
325
+ if notification_sink is not None:
326
+ await notification_sink(thread_notifications)
327
+ else:
328
+ notifications.extend(thread_notifications)
329
+ if sync_marker is not None:
330
+ self._existing_thread_sync_markers[thread_id] = sync_marker
331
+ self._existing_thread_names[thread_id] = current_name
332
+ self._persist_sync_state(connector_id, thread_id, sync_marker, current_name)
333
+ synced_threads.append(thread_id)
334
+
335
+ elapsed_ms = (time.perf_counter() - started) * 1000
336
+ logger.info(
337
+ "codex existing thread sync completed connector_id={} synced_threads={} skipped_threads={} notifications={} elapsed_ms={:.1f}",
338
+ connector_id,
339
+ len(synced_threads),
340
+ len(skipped_threads),
341
+ notification_count,
342
+ elapsed_ms,
343
+ )
344
+ return {
345
+ "threads": synced_threads,
346
+ "skippedThreads": skipped_threads,
347
+ "backendNotifications": notifications,
348
+ }
349
+
350
+ def _persist_sync_state(
351
+ self,
352
+ connector_id: str,
353
+ thread_id: str,
354
+ sync_marker: str | None,
355
+ current_name: str | None,
356
+ ) -> None:
357
+ if self.sync_state_store is None or sync_marker is None:
358
+ return
359
+ self.sync_state_store.set(
360
+ "codex",
361
+ connector_id,
362
+ thread_id,
363
+ fingerprint={"marker": sync_marker},
364
+ metadata={"name": current_name},
365
+ )
366
+
367
+ async def _sync_changed_existing_thread(
368
+ self,
369
+ session_id: str,
370
+ thread_id: str,
371
+ *,
372
+ thread_ref: dict[str, Any],
373
+ ) -> tuple[ReductionResult, dict[str, Any] | None]:
374
+ await self._ensure_thread_loaded(thread_id)
375
+ return await self._reduce_current_timeline(
376
+ session_id,
377
+ thread_id,
378
+ thread_ref=thread_ref,
379
+ )
380
+
381
+ async def start_turn(self, params: dict[str, Any]) -> dict[str, Any]:
382
+ await self.start()
383
+ assert self.rpc is not None
384
+ assert self.reducer is not None
385
+ session_id = _required_string(params, "sessionId")
386
+ thread_id = _optional_string(params.get("externalSessionId")) or self.reducer.thread_for_session(session_id)
387
+ if thread_id is None:
388
+ raise ValueError("externalSessionId is required before starting a Codex turn")
389
+ content = _required_string(params, "content")
390
+ self.reducer.bind_session(session_id, thread_id)
391
+ backend_notifications: list[dict[str, Any]] = []
392
+ try:
393
+ await self._ensure_thread_loaded(thread_id)
394
+ except RuntimeError as exc:
395
+ if _unresumable_thread_failure_reason(str(exc)) != "deleted":
396
+ raise
397
+ logger.warning(
398
+ "codex thread rollout missing; creating replacement thread session_id={} old_thread_id={} error={}",
399
+ session_id,
400
+ thread_id,
401
+ exc,
402
+ )
403
+ replacement = await self._create_replacement_thread(params)
404
+ thread_id = replacement["externalSessionId"]
405
+ self.reducer.bind_session(session_id, thread_id)
406
+ backend_notifications = replacement["backendNotifications"]
407
+ for notification in backend_notifications:
408
+ if notification.get("method") == "session.updated":
409
+ notification.get("params", {}).pop("status", None)
410
+ for notification in backend_notifications:
411
+ if self.notification_sink is not None:
412
+ await self.notification_sink(notification["method"], notification["params"])
413
+
414
+ attachments = params.get("attachments") or []
415
+ cwd = _optional_string(params.get("cwd"))
416
+ text_content, extra_inputs = await self._materialize_attachments(
417
+ content, attachments, cwd, session_id
418
+ )
419
+
420
+ input_items: list[dict[str, Any]] = [
421
+ {"type": "text", "text": text_content, "text_elements": []},
422
+ *extra_inputs,
423
+ ]
424
+ client_message_id = _optional_string(params.get("clientMessageId"))
425
+ timeline_attachments = _timeline_attachments(params)
426
+ if client_message_id:
427
+ self.reducer.register_client_message(
428
+ session_id=session_id,
429
+ thread_id=thread_id,
430
+ client_message_id=client_message_id,
431
+ text=text_content,
432
+ attachments=timeline_attachments,
433
+ )
434
+ result = await self.rpc.request(
435
+ "turn/start",
436
+ {
437
+ "threadId": thread_id,
438
+ "input": input_items,
439
+ "approvalPolicy": params.get("approvalPolicy"),
440
+ "sandboxPolicy": params.get("sandboxPolicy"),
441
+ "model": params.get("model"),
442
+ "effort": params.get("effort"),
443
+ "approvalsReviewer": params.get("approvalsReviewer"),
444
+ },
445
+ )
446
+ turn_id = _turn_id_from_result(result)
447
+ if client_message_id and turn_id:
448
+ self.reducer.register_client_message(
449
+ session_id=session_id,
450
+ thread_id=thread_id,
451
+ turn_id=turn_id,
452
+ client_message_id=client_message_id,
453
+ text=text_content,
454
+ attachments=timeline_attachments,
455
+ )
456
+ logger.info(
457
+ "codex turn started session_id={} thread_id={} turn_id={} input_chars={} attachments={}",
458
+ session_id,
459
+ thread_id,
460
+ turn_id,
461
+ len(text_content),
462
+ len(attachments),
463
+ )
464
+ return {
465
+ "turnId": turn_id,
466
+ "turn": result.get("turn") or result,
467
+ "externalSessionId": thread_id,
468
+ "backendNotifications": backend_notifications,
469
+ }
470
+
471
+ async def _materialize_attachments(
472
+ self,
473
+ content: str,
474
+ attachments: list[Any],
475
+ cwd: str | None,
476
+ session_id: str,
477
+ ) -> tuple[str, list[dict[str, Any]]]:
478
+ """Download each attachment to the connector user attachment dir and translate
479
+ into codex `UserInput` items.
480
+
481
+ Codex's `turn/start` `input` array supports text / image / localImage /
482
+ skill / mention — there is no generic file input. So:
483
+
484
+ * image/* attachments → `localImage` input item
485
+ * everything else → mention appended to the leading text item so
486
+ the model can inspect the materialized local path later.
487
+ """
488
+ if not attachments:
489
+ return content, []
490
+ if self.attachment_downloader is None:
491
+ logger.warning("dropping {} attachments — no downloader is wired", len(attachments))
492
+ return content, []
493
+
494
+ text = content
495
+ items: list[dict[str, Any]] = []
496
+ for att in attachments:
497
+ file_id = _attachment_file_id(att)
498
+ if file_id is None:
499
+ continue
500
+ try:
501
+ data, original_name, media_type = await self.attachment_downloader(
502
+ session_id, file_id
503
+ )
504
+ except Exception as exc:
505
+ logger.exception("attachment download failed file_id={}", file_id)
506
+ text += f"\n\n[Failed to load attachment {file_id}: {exc}]"
507
+ continue
508
+ target = attachment_target(session_id, file_id, original_name)
509
+ target.parent.mkdir(parents=True, exist_ok=True)
510
+ target.write_bytes(data)
511
+ try:
512
+ target.chmod(0o600)
513
+ except OSError:
514
+ pass
515
+
516
+ if media_type.startswith("image/"):
517
+ items.append({"type": "localImage", "path": str(target)})
518
+ else:
519
+ # Path-mention fallback: tell the model the file is sitting at
520
+ # this absolute path and let it call fs.readText if curious.
521
+ text += (
522
+ f"\n\n[Attached file: {original_name} ({media_type or 'unknown type'},"
523
+ f" {len(data)} bytes) at {target}]"
524
+ )
525
+ return text, items
526
+
527
+ async def interrupt_turn(self, params: dict[str, Any]) -> dict[str, Any]:
528
+ await self.start()
529
+ assert self.rpc is not None
530
+ assert self.reducer is not None
531
+ session_id = _optional_string(params.get("sessionId"))
532
+ thread_id = _optional_string(params.get("externalSessionId"))
533
+ if thread_id is None and session_id is not None:
534
+ thread_id = self.reducer.thread_for_session(session_id)
535
+ if thread_id is None:
536
+ raise ValueError("externalSessionId is required before interrupting a Codex turn")
537
+ turn_id = _required_string(params, "turnId")
538
+ try:
539
+ result = await self.rpc.request("turn/interrupt", {"threadId": thread_id, "turnId": turn_id})
540
+ except RuntimeError as exc:
541
+ reason = _soft_interrupt_failure_reason(str(exc))
542
+ if reason is None:
543
+ raise
544
+ logger.info(
545
+ "codex interrupt treated as already finished thread_id={} turn_id={} reason={}",
546
+ thread_id,
547
+ turn_id,
548
+ reason,
549
+ )
550
+ return {"interrupted": False, "reason": reason}
551
+ return {"interrupted": True, **result}
552
+
553
+ async def resolve_approval(self, params: dict[str, Any]) -> dict[str, Any]:
554
+ await self.start()
555
+ assert self.rpc is not None
556
+ request_id = params.get("requestId")
557
+ if request_id is None:
558
+ raise ValueError("requestId is required to resolve a Codex approval")
559
+ decision = _approval_decision(params.get("status"))
560
+ await self.rpc.respond(request_id, {"decision": decision})
561
+ logger.info(
562
+ "codex approval resolved request_id={} approval_id={} status={} decision={}",
563
+ request_id,
564
+ params.get("approvalId"),
565
+ params.get("status"),
566
+ decision,
567
+ )
568
+ return {"resolved": True}
569
+
570
+ async def handle_notification(self, message: dict[str, Any]) -> None:
571
+ assert self.reducer is not None
572
+ reduced = self.reducer.reduce_notification(message)
573
+ self._schedule_history_sync_after_turn_completion(message)
574
+ if message.get("method") == "turn/completed":
575
+ session_id = _session_id_from_reduction(reduced)
576
+ thread_id = _thread_id_from_turn_message(message)
577
+ logger.info(
578
+ "codex turn completed session_id={} thread_id={} timeline_items={} approvals={}",
579
+ session_id,
580
+ thread_id,
581
+ len(reduced.timeline_items),
582
+ len(reduced.approvals),
583
+ )
584
+ elif message.get("method") == "item/completed":
585
+ completed_item = _completed_item_from_message(message)
586
+ if completed_item is not None and completed_item.get("type") in {"agentMessage", "userMessage"}:
587
+ session_id = _session_id_from_reduction(reduced)
588
+ thread_id = _thread_id_from_turn_message(message)
589
+ logger.info(
590
+ "codex message completed session_id={} thread_id={} item_id={} item_type={}",
591
+ session_id,
592
+ thread_id,
593
+ completed_item.get("id"),
594
+ completed_item.get("type"),
595
+ )
596
+ for notification in _backend_notifications_from_reduction(reduced, timeline_method="timeline.itemUpsert"):
597
+ if self.notification_sink is not None:
598
+ await self.notification_sink(notification["method"], notification["params"])
599
+
600
+ def reduce_notification_for_test(self, message: dict[str, Any]) -> ReductionResult:
601
+ assert self.reducer is not None
602
+ return self.reducer.reduce_notification(message)
603
+
604
+ async def _resume_thread(self, thread_id: str) -> None:
605
+ assert self.rpc is not None
606
+ await self.rpc.request("thread/resume", {"threadId": thread_id})
607
+
608
+ async def _ensure_thread_loaded(self, thread_id: str, *, force: bool = False) -> None:
609
+ if not force and thread_id in self._loaded_thread_ids:
610
+ return
611
+ await self._resume_thread(thread_id)
612
+ self._loaded_thread_ids.add(thread_id)
613
+
614
+ async def _create_replacement_thread(self, params: dict[str, Any]) -> dict[str, Any]:
615
+ return await self.create_session(
616
+ {
617
+ "sessionId": _required_string(params, "sessionId"),
618
+ "cwd": params.get("cwd"),
619
+ "model": params.get("model"),
620
+ "approvalPolicy": params.get("approvalPolicy"),
621
+ "sandbox": params.get("sandboxPolicy"),
622
+ "ephemeral": params.get("ephemeral", False),
623
+ }
624
+ )
625
+
626
+ async def _best_effort_bootstrap_reads(self) -> None:
627
+ assert self.rpc is not None
628
+ for method, params in (
629
+ ("account/read", None),
630
+ ("model/list", None),
631
+ ("thread/loaded/list", None),
632
+ ):
633
+ try:
634
+ await self.rpc.request(method, params)
635
+ except Exception as exc: # pragma: no cover - defensive against version drift
636
+ logger.debug("codex bootstrap read failed method={} error={}", method, exc)
637
+
638
+ async def _reduce_current_timeline(
639
+ self,
640
+ session_id: str,
641
+ thread_id: str,
642
+ *,
643
+ thread_ref: dict[str, Any] | None = None,
644
+ ) -> tuple[ReductionResult, dict[str, Any]]:
645
+ assert self.rpc is not None
646
+ assert self.reducer is not None
647
+ snapshot_result = await self.rpc.request("thread/read", {"threadId": thread_id, "includeTurns": True})
648
+ thread = snapshot_result.get("thread") if isinstance(snapshot_result.get("thread"), dict) else snapshot_result
649
+ if not isinstance(thread, dict):
650
+ thread = {}
651
+ return self.reducer.reduce_thread_snapshot(
652
+ session_id,
653
+ thread,
654
+ fallback_thread_id=thread_id,
655
+ ), thread
656
+
657
+ def _schedule_history_sync_after_turn_completion(self, message: dict[str, Any]) -> None:
658
+ if message.get("method") != "turn/completed":
659
+ return
660
+ params = message.get("params") if isinstance(message.get("params"), dict) else {}
661
+ thread_id = _optional_string(params.get("threadId")) or _nested_string(params, "thread", "id")
662
+ if thread_id is None:
663
+ return
664
+ session_id = _optional_string(params.get("platformSessionId"))
665
+ if session_id is None and self.reducer is not None:
666
+ session_id = self.reducer.session_for_thread(thread_id)
667
+ if session_id is None:
668
+ return
669
+ old_task = self._history_sync_tasks.get(thread_id)
670
+ if old_task is not None and not old_task.done():
671
+ old_task.cancel()
672
+ self._history_sync_tasks[thread_id] = asyncio.create_task(self._delayed_push_thread_snapshot(session_id, thread_id))
673
+
674
+ async def _delayed_push_thread_snapshot(self, session_id: str, thread_id: str) -> None:
675
+ try:
676
+ await asyncio.sleep(0.5)
677
+ reduced, _thread = await self._reduce_current_timeline(session_id, thread_id)
678
+ if not reduced.timeline_items:
679
+ return
680
+ notification_count = 0
681
+ for notification in _backend_notifications_from_reduction(reduced, timeline_method="timeline.sync"):
682
+ notification_count += 1
683
+ if self.notification_sink is not None:
684
+ await self.notification_sink(notification["method"], notification["params"])
685
+ logger.info(
686
+ "codex turn snapshot synced session_id={} thread_id={} timeline_items={} notifications={}",
687
+ session_id,
688
+ thread_id,
689
+ len(reduced.timeline_items),
690
+ notification_count,
691
+ )
692
+ except asyncio.CancelledError:
693
+ raise
694
+ except Exception:
695
+ logger.exception("codex delayed thread snapshot sync failed thread_id={}", thread_id)
696
+
697
+
698
+ def _backend_notifications_from_reduction(
699
+ reduced: ReductionResult,
700
+ *,
701
+ timeline_method: str = "timeline.sync",
702
+ ) -> list[dict[str, Any]]:
703
+ notifications: list[dict[str, Any]] = []
704
+ if reduced.session_update:
705
+ notifications.append({"method": "session.updated", "params": reduced.session_update})
706
+ if reduced.timeline_items:
707
+ session_id = reduced.timeline_items[0]["sessionId"]
708
+ if timeline_method == "timeline.itemUpsert":
709
+ for item in reduced.timeline_items:
710
+ notifications.append({"method": timeline_method, "params": {"sessionId": session_id, "item": item}})
711
+ else:
712
+ notifications.append({"method": timeline_method, "params": {"sessionId": session_id, "items": reduced.timeline_items}})
713
+ for approval in reduced.approvals:
714
+ notifications.append({"method": "approval.requested", "params": approval})
715
+ return notifications
716
+
717
+
718
+ def _session_id_from_reduction(reduced: ReductionResult) -> str | None:
719
+ if reduced.timeline_items:
720
+ value = reduced.timeline_items[0].get("sessionId")
721
+ return value if isinstance(value, str) else None
722
+ if reduced.session_update:
723
+ value = reduced.session_update.get("sessionId")
724
+ return value if isinstance(value, str) else None
725
+ if reduced.approvals:
726
+ value = reduced.approvals[0].get("sessionId")
727
+ return value if isinstance(value, str) else None
728
+ return None
729
+
730
+
731
+ def _thread_id_from_turn_message(message: dict[str, Any]) -> str | None:
732
+ params = message.get("params") if isinstance(message.get("params"), dict) else {}
733
+ return _optional_string(params.get("threadId")) or _nested_string(params, "thread", "id")
734
+
735
+
736
+ def _completed_item_from_message(message: dict[str, Any]) -> dict[str, Any] | None:
737
+ params = message.get("params") if isinstance(message.get("params"), dict) else {}
738
+ item = params.get("item")
739
+ return item if isinstance(item, dict) else None
740
+
741
+
742
+ def stable_session_id(connector_id: str, thread_id: str) -> str:
743
+ digest = hashlib.sha256(f"{connector_id}:codex:{thread_id}".encode("utf-8")).hexdigest()[:24]
744
+ return f"sess_codex_{digest}"
745
+
746
+
747
+ # Token only emitted by Claude Code when its transcript is serialised into a
748
+ # Codex thread; never appears in native Codex output.
749
+ _EXTERNAL_AGENT_TOOL_CALL_MARKER = "[external_agent_tool_call:"
750
+
751
+
752
+ def _is_imported_external_thread(timeline_items: list[dict[str, Any]]) -> bool:
753
+ for item in timeline_items:
754
+ if not isinstance(item, dict):
755
+ continue
756
+ if item.get("type") != "message":
757
+ continue
758
+ if item.get("role") != "assistant":
759
+ continue
760
+ content = item.get("content")
761
+ if not isinstance(content, dict):
762
+ continue
763
+ text = content.get("text")
764
+ if isinstance(text, str) and _EXTERNAL_AGENT_TOOL_CALL_MARKER in text:
765
+ return True
766
+ return False
767
+
768
+
769
+ def _thread_sync_marker(thread_ref: dict[str, Any]) -> str | None:
770
+ updated_at = thread_ref.get("updatedAt") or thread_ref.get("updated_at")
771
+ if updated_at is not None:
772
+ return f"updated:{_codex_time(updated_at) or str(updated_at)}"
773
+ try:
774
+ encoded = json.dumps(thread_ref, ensure_ascii=False, sort_keys=True, default=str)
775
+ except TypeError:
776
+ return None
777
+ return f"ref:{hashlib.sha256(encoded.encode('utf-8')).hexdigest()}"
778
+
779
+
780
+ def _thread_refs_from_list_result(result: dict[str, Any]) -> list[dict[str, Any]]:
781
+ for key in ("threads", "data", "items"):
782
+ value = result.get(key)
783
+ if isinstance(value, list):
784
+ return [item for item in value if isinstance(item, dict)]
785
+ nested = result.get("thread")
786
+ if isinstance(nested, dict):
787
+ return [nested]
788
+ if _thread_id_from_result(result):
789
+ return [result]
790
+ logger.debug("codex thread/list returned no recognizable thread list: {}", json.dumps(result, ensure_ascii=False))
791
+ return []
792
+
793
+
794
+ def _local_thread_state(thread_ref: dict[str, Any]) -> str:
795
+ """Best-effort local thread state from Codex list metadata.
796
+
797
+ Codex app-server is versioned independently, so keep this deliberately
798
+ tolerant: if any common archived/deleted flag is present we treat the
799
+ thread as not resumable and never publish it to the backend.
800
+ """
801
+ for key in ("localState", "local_state", "lifecycleState", "lifecycle_state"):
802
+ value = thread_ref.get(key)
803
+ if isinstance(value, str):
804
+ normalized = value.lower()
805
+ if normalized in {"active", "archived", "deleted", "unresumable", "unknown"}:
806
+ return normalized
807
+ status = thread_ref.get("status")
808
+ if isinstance(status, dict):
809
+ status = status.get("type") or status.get("state")
810
+ if isinstance(status, str):
811
+ normalized_status = status.lower()
812
+ if normalized_status in {"archived", "deleted", "unresumable"}:
813
+ return normalized_status
814
+ for key in ("archived", "isArchived", "is_archived"):
815
+ if thread_ref.get(key) is True:
816
+ return "archived"
817
+ for key in ("deleted", "isDeleted", "is_deleted"):
818
+ if thread_ref.get(key) is True:
819
+ return "deleted"
820
+ for key in ("archivedAt", "archived_at"):
821
+ if thread_ref.get(key):
822
+ return "archived"
823
+ for key in ("deletedAt", "deleted_at", "removedAt", "removed_at"):
824
+ if thread_ref.get(key):
825
+ return "deleted"
826
+ if thread_ref.get("resumeSupported") is False or thread_ref.get("resumable") is False:
827
+ return "unresumable"
828
+ return "active"
829
+
830
+
831
+ def _required_string(params: dict[str, Any], key: str) -> str:
832
+ value = params.get(key)
833
+ if not isinstance(value, str) or not value:
834
+ raise ValueError(f"{key} is required")
835
+ return value
836
+
837
+
838
+ def _optional_string(value: Any) -> str | None:
839
+ return value if isinstance(value, str) and value else None
840
+
841
+
842
+ def _sandbox_mode(value: Any) -> str | None:
843
+ if value is None:
844
+ return None
845
+ if isinstance(value, str):
846
+ if value in {"read-only", "workspace-write", "danger-full-access"}:
847
+ return value
848
+ return {
849
+ "readOnly": "read-only",
850
+ "workspaceWrite": "workspace-write",
851
+ "dangerFullAccess": "danger-full-access",
852
+ }.get(value)
853
+ if isinstance(value, dict):
854
+ sandbox_type = value.get("type")
855
+ if isinstance(sandbox_type, str):
856
+ return {
857
+ "readOnly": "read-only",
858
+ "workspaceWrite": "workspace-write",
859
+ "dangerFullAccess": "danger-full-access",
860
+ "read-only": "read-only",
861
+ "workspace-write": "workspace-write",
862
+ "danger-full-access": "danger-full-access",
863
+ }.get(sandbox_type)
864
+ return None
865
+
866
+
867
+ def _codex_time(value: Any) -> str | None:
868
+ if isinstance(value, int | float):
869
+ seconds = float(value)
870
+ if seconds > 10_000_000_000:
871
+ seconds = seconds / 1000
872
+ return datetime.fromtimestamp(seconds, UTC).isoformat().replace("+00:00", "Z")
873
+ return _optional_string(value)
874
+
875
+
876
+ def _nested_string(data: dict[str, Any], key: str, nested_key: str) -> str | None:
877
+ nested = data.get(key)
878
+ if isinstance(nested, dict):
879
+ return _optional_string(nested.get(nested_key))
880
+ return None
881
+
882
+
883
+ def _approval_decision(status: Any) -> str:
884
+ if status == "approved_for_session":
885
+ return "acceptForSession"
886
+ if status == "approved":
887
+ return "accept"
888
+ if status == "cancelled":
889
+ return "cancel"
890
+ return "decline"
891
+
892
+
893
+ def _soft_interrupt_failure_reason(error_text: str) -> str | None:
894
+ message = error_text
895
+ try:
896
+ parsed = json.loads(error_text)
897
+ if isinstance(parsed, dict):
898
+ raw = parsed.get("message")
899
+ if isinstance(raw, str):
900
+ message = raw
901
+ except json.JSONDecodeError:
902
+ pass
903
+ normalized = message.lower()
904
+ if "thread not found" in normalized:
905
+ return "thread_not_found"
906
+ if "turn not found" in normalized:
907
+ return "turn_not_found"
908
+ return None
909
+
910
+
911
+ def _unresumable_thread_failure_reason(error_text: str) -> str | None:
912
+ message = error_text
913
+ try:
914
+ parsed = json.loads(error_text)
915
+ if isinstance(parsed, dict):
916
+ raw = parsed.get("message")
917
+ if isinstance(raw, str):
918
+ message = raw
919
+ except json.JSONDecodeError:
920
+ pass
921
+ normalized = message.lower()
922
+ if (
923
+ "thread not found" in normalized
924
+ or "session not found" in normalized
925
+ or "no rollout found" in normalized
926
+ ):
927
+ return "deleted"
928
+ if "archived" in normalized:
929
+ return "archived"
930
+ if "cannot resume" in normalized or "not resumable" in normalized or "unresumable" in normalized:
931
+ return "unresumable"
932
+ return None
933
+
934
+
935
+ def _attachment_file_id(att: Any) -> str | None:
936
+ if isinstance(att, dict):
937
+ candidate = att.get("fileId")
938
+ if isinstance(candidate, str) and candidate:
939
+ return candidate
940
+ return None
941
+
942
+
943
+ def _attachment_name_from(att: Any) -> str | None:
944
+ if isinstance(att, dict):
945
+ candidate = att.get("name")
946
+ if isinstance(candidate, str) and candidate:
947
+ return candidate
948
+ return None
949
+
950
+
951
+ __all__ = ["CODEX_APPROVAL_METHODS", "CodexAdapter", "JsonRpcStdioClient", "TimelineReducer"]