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,1150 @@
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 connector.logging 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
+ _token_usage_by_thread: dict[str, dict[str, Any]] = field(default_factory=dict)
96
+ _start_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False, repr=False)
97
+
98
+ def __post_init__(self) -> None:
99
+ if self.rpc is None:
100
+ self.rpc = JsonRpcStdioClient()
101
+ if self.reducer is None:
102
+ self.reducer = TimelineReducer()
103
+
104
+ def forget_sync_state(self) -> None:
105
+ """Drop the in-memory "I already told the backend about thread X"
106
+ markers so the next `sync_existing_sessions` re-ingests everything.
107
+
108
+ Called when the server-side runtime entry has been removed
109
+ (DELETE /runtime-capabilities/{runtime}). Without this, the
110
+ adapter would keep skipping threads it had already pushed in a
111
+ previous lifetime, even though the backend SQL no longer has them.
112
+ """
113
+ self._existing_thread_sync_markers.clear()
114
+ self._existing_thread_names.clear()
115
+
116
+ def forget_persisted_sync_state(self, connector_id: str) -> None:
117
+ self.forget_sync_state()
118
+ if self.sync_state_store is not None:
119
+ self.sync_state_store.delete_runtime("codex", connector_id)
120
+
121
+ async def start(self) -> None:
122
+ async with self._start_lock:
123
+ assert self.rpc is not None
124
+ await self.rpc.start(self.handle_notification)
125
+ if self._started:
126
+ return
127
+ await self._best_effort_bootstrap_reads()
128
+ self._started = True
129
+
130
+ async def create_session(self, params: dict[str, Any]) -> dict[str, Any]:
131
+ await self.start()
132
+ assert self.rpc is not None
133
+ assert self.reducer is not None
134
+ result = await self.rpc.request(
135
+ "thread/start",
136
+ {
137
+ "cwd": params.get("cwd"),
138
+ "model": params.get("model"),
139
+ "approvalPolicy": params.get("approvalPolicy"),
140
+ "sandbox": _sandbox_mode(params.get("sandbox")),
141
+ "ephemeral": params.get("ephemeral", False),
142
+ },
143
+ )
144
+ thread_id = _thread_id_from_result(result)
145
+ if thread_id is None:
146
+ raise RuntimeError(f"Codex thread/start did not return a thread id: {json.dumps(result, ensure_ascii=False)}")
147
+ self._loaded_thread_ids.add(thread_id)
148
+ session_id = params.get("sessionId")
149
+ connector_id = params.get("connectorId")
150
+ if not isinstance(session_id, str) and isinstance(connector_id, str):
151
+ session_id = stable_session_id(connector_id, thread_id)
152
+ if isinstance(session_id, str):
153
+ self.reducer.bind_session(session_id, thread_id)
154
+ return {
155
+ "sessionId": session_id,
156
+ "externalSessionId": thread_id,
157
+ "thread": result.get("thread") or result,
158
+ "backendNotifications": [
159
+ {
160
+ "method": "session.updated",
161
+ "params": {
162
+ "sessionId": session_id,
163
+ "runtime": "codex",
164
+ "externalSessionId": thread_id,
165
+ "status": "idle",
166
+ "cwd": params.get("cwd"),
167
+ },
168
+ }
169
+ ]
170
+ if isinstance(session_id, str)
171
+ else [],
172
+ }
173
+
174
+ async def sync_session(self, params: dict[str, Any]) -> dict[str, Any]:
175
+ await self.start()
176
+ assert self.rpc is not None
177
+ assert self.reducer is not None
178
+ session_id = _required_string(params, "sessionId")
179
+ thread_id = _required_string(params, "externalSessionId")
180
+ self.reducer.bind_session(session_id, thread_id)
181
+ started = time.perf_counter()
182
+ logger.info("codex session sync started session_id={} thread_id={}", session_id, thread_id)
183
+ await self._ensure_thread_loaded(thread_id, force=True)
184
+ reduced, thread = await self._reduce_current_timeline(session_id, thread_id)
185
+ elapsed_ms = (time.perf_counter() - started) * 1000
186
+ logger.info(
187
+ "codex session sync completed session_id={} thread_id={} timeline_items={} approvals={} elapsed_ms={:.1f}",
188
+ session_id,
189
+ thread_id,
190
+ len(reduced.timeline_items),
191
+ len(reduced.approvals),
192
+ elapsed_ms,
193
+ )
194
+ return {
195
+ "thread": thread,
196
+ "backendNotifications": _backend_notifications_from_reduction(reduced, timeline_method="timeline.sync"),
197
+ }
198
+
199
+ async def sync_existing_sessions(
200
+ self,
201
+ connector_id: str,
202
+ *,
203
+ limit: int = 100,
204
+ force: bool = False,
205
+ notification_sink: Callable[[list[dict[str, Any]]], Awaitable[None]] | None = None,
206
+ ) -> dict[str, Any]:
207
+ await self.start()
208
+ assert self.rpc is not None
209
+ assert self.reducer is not None
210
+
211
+ list_result = await asyncio.wait_for(
212
+ self.rpc.request("thread/list", {"limit": limit, "sortKey": "updated_at"}),
213
+ timeout=EXISTING_SYNC_SCAN_TIMEOUT_SECONDS,
214
+ )
215
+ thread_refs = _thread_refs_from_list_result(list_result)
216
+ notifications: list[dict[str, Any]] = []
217
+ synced_threads: list[str] = []
218
+ skipped_threads: list[str] = []
219
+ notification_count = 0
220
+ started = time.perf_counter()
221
+ logger.info(
222
+ "codex existing thread sync started connector_id={} threads={} force={}",
223
+ connector_id,
224
+ len(thread_refs),
225
+ force,
226
+ )
227
+ for thread_ref in thread_refs:
228
+ thread_id = _thread_id_from_result(thread_ref)
229
+ if not thread_id:
230
+ continue
231
+ local_state = _local_thread_state(thread_ref)
232
+ if local_state in {"archived", "deleted", "unresumable"}:
233
+ logger.info(
234
+ "codex skipping local {} thread thread_id={}",
235
+ local_state,
236
+ thread_id,
237
+ )
238
+ skipped_threads.append(thread_id)
239
+ continue
240
+ sync_marker = _thread_sync_marker(thread_ref)
241
+ current_name = _optional_string(thread_ref.get("name"))
242
+ persisted_state = (
243
+ self.sync_state_store.get("codex", connector_id, thread_id)
244
+ if self.sync_state_store is not None
245
+ else None
246
+ )
247
+ previous_marker = self._existing_thread_sync_markers.get(thread_id)
248
+ if previous_marker is None and persisted_state is not None:
249
+ previous_marker = _optional_string((persisted_state.fingerprint or {}).get("marker"))
250
+ if previous_marker is not None:
251
+ self._existing_thread_sync_markers[thread_id] = previous_marker
252
+ previous_name = _optional_string((persisted_state.metadata or {}).get("name"))
253
+ if previous_name is not None:
254
+ self._existing_thread_names[thread_id] = previous_name
255
+ if not force and sync_marker is not None and previous_marker == sync_marker:
256
+ # Codex may rename a thread without bumping updatedAt — diff
257
+ # the name independently and push a title-only update.
258
+ if self._existing_thread_names.get(thread_id) != current_name:
259
+ session_id = stable_session_id(connector_id, thread_id)
260
+ rename_notification = {
261
+ "method": "session.updated",
262
+ "params": {
263
+ "sessionId": session_id,
264
+ "title": current_name,
265
+ "sourceObservedAt": utc_now(),
266
+ },
267
+ }
268
+ notification_count += 1
269
+ if notification_sink is not None:
270
+ await notification_sink([rename_notification])
271
+ else:
272
+ notifications.append(rename_notification)
273
+ self._existing_thread_names[thread_id] = current_name
274
+ self._persist_sync_state(connector_id, thread_id, sync_marker, current_name)
275
+ skipped_threads.append(thread_id)
276
+ continue
277
+ session_id = stable_session_id(connector_id, thread_id)
278
+ self.reducer.bind_session(session_id, thread_id)
279
+ try:
280
+ reduced, _thread = await asyncio.wait_for(
281
+ self._sync_changed_existing_thread(
282
+ session_id,
283
+ thread_id,
284
+ thread_ref=thread_ref,
285
+ ),
286
+ timeout=EXISTING_SYNC_CHANGED_THREAD_TIMEOUT_SECONDS,
287
+ )
288
+ except TimeoutError:
289
+ logger.warning(
290
+ "codex existing thread sync timed out thread_id={} timeout_s={}",
291
+ thread_id,
292
+ EXISTING_SYNC_CHANGED_THREAD_TIMEOUT_SECONDS,
293
+ )
294
+ continue
295
+ except Exception as exc:
296
+ reason = _unresumable_thread_failure_reason(str(exc))
297
+ if reason is not None:
298
+ logger.info(
299
+ "codex skipping {} thread thread_id={} error={}",
300
+ reason,
301
+ thread_id,
302
+ exc,
303
+ )
304
+ skipped_threads.append(thread_id)
305
+ if sync_marker is not None:
306
+ self._existing_thread_sync_markers[thread_id] = sync_marker
307
+ continue
308
+ logger.warning("codex existing thread sync failed thread_id={} error={}", thread_id, exc)
309
+ continue
310
+ if _is_imported_external_thread(reduced.timeline_items):
311
+ logger.info(
312
+ "codex skipping imported external thread thread_id={} items={}",
313
+ thread_id,
314
+ len(reduced.timeline_items),
315
+ )
316
+ skipped_threads.append(thread_id)
317
+ if sync_marker is not None:
318
+ self._existing_thread_sync_markers[thread_id] = sync_marker
319
+ self._persist_sync_state(connector_id, thread_id, sync_marker, current_name)
320
+ continue
321
+ if reduced.session_update is not None:
322
+ reduced.session_update["runtime"] = "codex"
323
+ last_activity_at = _codex_time(thread_ref.get("updatedAt") or thread_ref.get("updated_at"))
324
+ if last_activity_at is not None:
325
+ reduced.session_update["lastActivityAt"] = last_activity_at
326
+ thread_notifications = _backend_notifications_from_reduction(reduced, timeline_method="timeline.sync")
327
+ notification_count += len(thread_notifications)
328
+ if notification_sink is not None:
329
+ await notification_sink(thread_notifications)
330
+ else:
331
+ notifications.extend(thread_notifications)
332
+ if sync_marker is not None:
333
+ self._existing_thread_sync_markers[thread_id] = sync_marker
334
+ self._existing_thread_names[thread_id] = current_name
335
+ self._persist_sync_state(connector_id, thread_id, sync_marker, current_name)
336
+ synced_threads.append(thread_id)
337
+
338
+ elapsed_ms = (time.perf_counter() - started) * 1000
339
+ logger.info(
340
+ "codex existing thread sync completed connector_id={} synced_threads={} skipped_threads={} notifications={} elapsed_ms={:.1f}",
341
+ connector_id,
342
+ len(synced_threads),
343
+ len(skipped_threads),
344
+ notification_count,
345
+ elapsed_ms,
346
+ )
347
+ return {
348
+ "threads": synced_threads,
349
+ "skippedThreads": skipped_threads,
350
+ "backendNotifications": notifications,
351
+ }
352
+
353
+ def _persist_sync_state(
354
+ self,
355
+ connector_id: str,
356
+ thread_id: str,
357
+ sync_marker: str | None,
358
+ current_name: str | None,
359
+ ) -> None:
360
+ if self.sync_state_store is None or sync_marker is None:
361
+ return
362
+ self.sync_state_store.set(
363
+ "codex",
364
+ connector_id,
365
+ thread_id,
366
+ fingerprint={"marker": sync_marker},
367
+ metadata={"name": current_name},
368
+ )
369
+
370
+ async def _sync_changed_existing_thread(
371
+ self,
372
+ session_id: str,
373
+ thread_id: str,
374
+ *,
375
+ thread_ref: dict[str, Any],
376
+ ) -> tuple[ReductionResult, dict[str, Any] | None]:
377
+ await self._ensure_thread_loaded(thread_id)
378
+ return await self._reduce_current_timeline(
379
+ session_id,
380
+ thread_id,
381
+ thread_ref=thread_ref,
382
+ )
383
+
384
+ async def start_turn(self, params: dict[str, Any]) -> dict[str, Any]:
385
+ await self.start()
386
+ assert self.rpc is not None
387
+ assert self.reducer is not None
388
+ session_id = _required_string(params, "sessionId")
389
+ thread_id = _optional_string(params.get("externalSessionId")) or self.reducer.thread_for_session(session_id)
390
+ if thread_id is None:
391
+ raise ValueError("externalSessionId is required before starting a Codex turn")
392
+ content = _required_string(params, "content")
393
+ self.reducer.bind_session(session_id, thread_id)
394
+ backend_notifications: list[dict[str, Any]] = []
395
+ try:
396
+ await self._ensure_thread_loaded(thread_id)
397
+ except RuntimeError as exc:
398
+ if _unresumable_thread_failure_reason(str(exc)) != "deleted":
399
+ raise
400
+ thread_id, backend_notifications = await self._replace_missing_thread_for_turn(
401
+ params,
402
+ session_id=session_id,
403
+ old_thread_id=thread_id,
404
+ error=exc,
405
+ )
406
+
407
+ attachments = params.get("attachments") or []
408
+ cwd = _optional_string(params.get("cwd"))
409
+ text_content, extra_inputs = await self._materialize_attachments(
410
+ content, attachments, cwd, session_id
411
+ )
412
+
413
+ input_items: list[dict[str, Any]] = [
414
+ {"type": "text", "text": text_content, "text_elements": []},
415
+ *extra_inputs,
416
+ ]
417
+ client_message_id = _optional_string(params.get("clientMessageId"))
418
+ timeline_attachments = _timeline_attachments(params)
419
+ if client_message_id:
420
+ self.reducer.register_client_message(
421
+ session_id=session_id,
422
+ thread_id=thread_id,
423
+ client_message_id=client_message_id,
424
+ text=text_content,
425
+ attachments=timeline_attachments,
426
+ )
427
+ turn_params = {
428
+ "threadId": thread_id,
429
+ "input": input_items,
430
+ "approvalPolicy": params.get("approvalPolicy"),
431
+ "sandboxPolicy": params.get("sandboxPolicy"),
432
+ "model": params.get("model"),
433
+ "effort": params.get("effort"),
434
+ "approvalsReviewer": params.get("approvalsReviewer"),
435
+ }
436
+ try:
437
+ result = await self.rpc.request("turn/start", turn_params)
438
+ except RuntimeError as exc:
439
+ recovered = await self._recover_missing_thread_for_turn_start(
440
+ params,
441
+ session_id=session_id,
442
+ thread_id=thread_id,
443
+ error=exc,
444
+ )
445
+ if recovered is None:
446
+ raise
447
+ thread_id, extra_notifications = recovered
448
+ backend_notifications.extend(extra_notifications)
449
+ self.reducer.bind_session(session_id, thread_id)
450
+ turn_params["threadId"] = thread_id
451
+ if client_message_id:
452
+ self.reducer.register_client_message(
453
+ session_id=session_id,
454
+ thread_id=thread_id,
455
+ client_message_id=client_message_id,
456
+ text=text_content,
457
+ attachments=timeline_attachments,
458
+ )
459
+ result = await self.rpc.request("turn/start", turn_params)
460
+ turn_id = _turn_id_from_result(result)
461
+ if client_message_id and turn_id:
462
+ self.reducer.register_client_message(
463
+ session_id=session_id,
464
+ thread_id=thread_id,
465
+ turn_id=turn_id,
466
+ client_message_id=client_message_id,
467
+ text=text_content,
468
+ attachments=timeline_attachments,
469
+ )
470
+ logger.info(
471
+ "codex turn started session_id={} thread_id={} turn_id={} input_chars={} attachments={}",
472
+ session_id,
473
+ thread_id,
474
+ turn_id,
475
+ len(text_content),
476
+ len(attachments),
477
+ )
478
+ return {
479
+ "turnId": turn_id,
480
+ "turn": result.get("turn") or result,
481
+ "externalSessionId": thread_id,
482
+ "backendNotifications": backend_notifications,
483
+ }
484
+
485
+ async def execute_command(self, params: dict[str, Any]) -> dict[str, Any]:
486
+ await self.start()
487
+ assert self.rpc is not None
488
+ assert self.reducer is not None
489
+ session_id = _required_string(params, "sessionId")
490
+ thread_id = _required_string(params, "externalSessionId")
491
+ command = _required_string(params, "command")
492
+ client_command_id = _required_string(params, "clientCommandId")
493
+ raw_args = _optional_string(params.get("rawArgs")) or ""
494
+ options = params.get("options") if isinstance(params.get("options"), dict) else {}
495
+ if command not in {"compact", "review", "status"}:
496
+ raise ValueError("command_unavailable")
497
+ self.reducer.bind_session(session_id, thread_id)
498
+ started_items = self.reducer.command_items(
499
+ session_id=session_id,
500
+ thread_id=thread_id,
501
+ client_command_id=client_command_id,
502
+ command=command,
503
+ raw_args=raw_args,
504
+ phase="started",
505
+ )
506
+ try:
507
+ if command == "compact":
508
+ await self.rpc.request("thread/compact/start", {"threadId": thread_id})
509
+ data: dict[str, Any] = {"message": "Compaction started."}
510
+ elif command == "review":
511
+ target = options.get("target")
512
+ if not isinstance(target, dict):
513
+ target = {"type": "uncommittedChanges"}
514
+ if target.get("type") not in {"uncommittedChanges", "baseBranch", "commit", "custom"}:
515
+ raise ValueError("invalid review target")
516
+ result = await self.rpc.request(
517
+ "review/start",
518
+ {"threadId": thread_id, "delivery": "inline", "target": target},
519
+ )
520
+ data = {
521
+ "message": "Code review started.",
522
+ "turnId": _turn_id_from_result(result),
523
+ "target": target,
524
+ }
525
+ else:
526
+ thread = await self.rpc.request(
527
+ "thread/read", {"threadId": thread_id, "includeTurns": False}
528
+ )
529
+ account = await self._optional_rpc_read("account/read", {})
530
+ limits = await self._optional_rpc_read("account/rateLimits/read", {})
531
+ data = {
532
+ "session": params.get("sessionContext"),
533
+ "thread": thread.get("thread") if isinstance(thread, dict) else thread,
534
+ "tokenUsage": self._token_usage_by_thread.get(thread_id),
535
+ "account": _status_account(account),
536
+ "rateLimits": limits,
537
+ }
538
+ completed_items = self.reducer.command_items(
539
+ session_id=session_id,
540
+ thread_id=thread_id,
541
+ client_command_id=client_command_id,
542
+ command=command,
543
+ raw_args=raw_args,
544
+ phase="completed" if command == "status" else "accepted",
545
+ data=data,
546
+ )
547
+ except Exception as exc:
548
+ failed_items = self.reducer.command_items(
549
+ session_id=session_id,
550
+ thread_id=thread_id,
551
+ client_command_id=client_command_id,
552
+ command=command,
553
+ raw_args=raw_args,
554
+ phase="failed",
555
+ error=str(exc),
556
+ )
557
+ return {
558
+ "command": command,
559
+ "status": "failed",
560
+ "error": str(exc),
561
+ "backendNotifications": _item_notifications(session_id, [started_items[0], failed_items[1]]),
562
+ }
563
+ return {
564
+ "command": command,
565
+ "status": "accepted" if command != "status" else "completed",
566
+ "data": data,
567
+ "backendNotifications": _item_notifications(session_id, completed_items),
568
+ }
569
+
570
+ async def _optional_rpc_read(self, method: str, params: dict[str, Any]) -> dict[str, Any] | None:
571
+ assert self.rpc is not None
572
+ try:
573
+ result = await self.rpc.request(method, params)
574
+ return result if isinstance(result, dict) else None
575
+ except Exception as exc:
576
+ logger.debug("optional codex command read failed method={} error={}", method, exc)
577
+ return None
578
+
579
+ async def _materialize_attachments(
580
+ self,
581
+ content: str,
582
+ attachments: list[Any],
583
+ cwd: str | None,
584
+ session_id: str,
585
+ ) -> tuple[str, list[dict[str, Any]]]:
586
+ """Download each attachment to the connector user attachment dir and translate
587
+ into codex `UserInput` items.
588
+
589
+ Codex's `turn/start` `input` array supports text / image / localImage /
590
+ skill / mention — there is no generic file input. So:
591
+
592
+ * image/* attachments → `localImage` input item
593
+ * everything else → mention appended to the leading text item so
594
+ the model can inspect the materialized local path later.
595
+ """
596
+ if not attachments:
597
+ return content, []
598
+ if self.attachment_downloader is None:
599
+ logger.warning("dropping {} attachments — no downloader is wired", len(attachments))
600
+ return content, []
601
+
602
+ text = content
603
+ items: list[dict[str, Any]] = []
604
+ for att in attachments:
605
+ file_id = _attachment_file_id(att)
606
+ if file_id is None:
607
+ continue
608
+ try:
609
+ data, original_name, media_type = await self.attachment_downloader(
610
+ session_id, file_id
611
+ )
612
+ except Exception as exc:
613
+ logger.exception("attachment download failed file_id={}", file_id)
614
+ text += f"\n\n[Failed to load attachment {file_id}: {exc}]"
615
+ continue
616
+ target = attachment_target(session_id, file_id, original_name)
617
+ target.parent.mkdir(parents=True, exist_ok=True)
618
+ target.write_bytes(data)
619
+ try:
620
+ target.chmod(0o600)
621
+ except OSError:
622
+ pass
623
+
624
+ if media_type.startswith("image/"):
625
+ items.append({"type": "localImage", "path": str(target)})
626
+ else:
627
+ # Path-mention fallback: tell the model the file is sitting at
628
+ # this absolute path and let it call fs.readText if curious.
629
+ text += (
630
+ f"\n\n[Attached file: {original_name} ({media_type or 'unknown type'},"
631
+ f" {len(data)} bytes) at {target}]"
632
+ )
633
+ return text, items
634
+
635
+ async def interrupt_turn(self, params: dict[str, Any]) -> dict[str, Any]:
636
+ await self.start()
637
+ assert self.rpc is not None
638
+ assert self.reducer is not None
639
+ session_id = _optional_string(params.get("sessionId"))
640
+ thread_id = _optional_string(params.get("externalSessionId"))
641
+ if thread_id is None and session_id is not None:
642
+ thread_id = self.reducer.thread_for_session(session_id)
643
+ if thread_id is None:
644
+ raise ValueError("externalSessionId is required before interrupting a Codex turn")
645
+ turn_id = _required_string(params, "turnId")
646
+ try:
647
+ result = await self.rpc.request("turn/interrupt", {"threadId": thread_id, "turnId": turn_id})
648
+ except RuntimeError as exc:
649
+ reason = _soft_interrupt_failure_reason(str(exc))
650
+ if reason is None:
651
+ raise
652
+ logger.info(
653
+ "codex interrupt treated as already finished thread_id={} turn_id={} reason={}",
654
+ thread_id,
655
+ turn_id,
656
+ reason,
657
+ )
658
+ return {"interrupted": False, "reason": reason}
659
+ return {"interrupted": True, **result}
660
+
661
+ async def resolve_approval(self, params: dict[str, Any]) -> dict[str, Any]:
662
+ await self.start()
663
+ assert self.rpc is not None
664
+ request_id = params.get("requestId")
665
+ if request_id is None:
666
+ raise ValueError("requestId is required to resolve a Codex approval")
667
+ decision = _approval_decision(params.get("status"))
668
+ await self.rpc.respond(request_id, {"decision": decision})
669
+ logger.info(
670
+ "codex approval resolved request_id={} approval_id={} status={} decision={}",
671
+ request_id,
672
+ params.get("approvalId"),
673
+ params.get("status"),
674
+ decision,
675
+ )
676
+ return {"resolved": True}
677
+
678
+ async def handle_notification(self, message: dict[str, Any]) -> None:
679
+ if message.get("method") == "thread/tokenUsage/updated":
680
+ params = message.get("params") if isinstance(message.get("params"), dict) else {}
681
+ thread_id = _optional_string(params.get("threadId"))
682
+ token_usage = params.get("tokenUsage")
683
+ if thread_id and isinstance(token_usage, dict):
684
+ self._token_usage_by_thread[thread_id] = token_usage
685
+ assert self.reducer is not None
686
+ reduced = self.reducer.reduce_notification(message)
687
+ self._schedule_history_sync_after_turn_completion(message)
688
+ if message.get("method") == "turn/completed":
689
+ session_id = _session_id_from_reduction(reduced)
690
+ thread_id = _thread_id_from_turn_message(message)
691
+ logger.info(
692
+ "codex turn completed session_id={} thread_id={} timeline_items={} approvals={}",
693
+ session_id,
694
+ thread_id,
695
+ len(reduced.timeline_items),
696
+ len(reduced.approvals),
697
+ )
698
+ elif message.get("method") == "item/completed":
699
+ completed_item = _completed_item_from_message(message)
700
+ if completed_item is not None and completed_item.get("type") in {"agentMessage", "userMessage"}:
701
+ session_id = _session_id_from_reduction(reduced)
702
+ thread_id = _thread_id_from_turn_message(message)
703
+ logger.info(
704
+ "codex message completed session_id={} thread_id={} item_id={} item_type={}",
705
+ session_id,
706
+ thread_id,
707
+ completed_item.get("id"),
708
+ completed_item.get("type"),
709
+ )
710
+ for notification in _backend_notifications_from_reduction(reduced, timeline_method="timeline.itemUpsert"):
711
+ if self.notification_sink is not None:
712
+ await self.notification_sink(notification["method"], notification["params"])
713
+
714
+ def reduce_notification_for_test(self, message: dict[str, Any]) -> ReductionResult:
715
+ assert self.reducer is not None
716
+ return self.reducer.reduce_notification(message)
717
+
718
+ async def _resume_thread(self, thread_id: str) -> None:
719
+ assert self.rpc is not None
720
+ await self.rpc.request("thread/resume", {"threadId": thread_id})
721
+
722
+ async def _ensure_thread_loaded(self, thread_id: str, *, force: bool = False) -> None:
723
+ if not force and thread_id in self._loaded_thread_ids:
724
+ return
725
+ await self._resume_thread(thread_id)
726
+ self._loaded_thread_ids.add(thread_id)
727
+
728
+ async def _create_replacement_thread(self, params: dict[str, Any]) -> dict[str, Any]:
729
+ return await self.create_session(
730
+ {
731
+ "sessionId": _required_string(params, "sessionId"),
732
+ "cwd": params.get("cwd"),
733
+ "model": params.get("model"),
734
+ "approvalPolicy": params.get("approvalPolicy"),
735
+ "sandbox": params.get("sandboxPolicy"),
736
+ "ephemeral": params.get("ephemeral", False),
737
+ }
738
+ )
739
+
740
+ async def _recover_missing_thread_for_turn_start(
741
+ self,
742
+ params: dict[str, Any],
743
+ *,
744
+ session_id: str,
745
+ thread_id: str,
746
+ error: RuntimeError,
747
+ ) -> tuple[str, list[dict[str, Any]]] | None:
748
+ if _unresumable_thread_failure_reason(str(error)) != "deleted":
749
+ return None
750
+ logger.warning(
751
+ "codex turn/start target thread missing; forcing resume before retry session_id={} thread_id={} error={}",
752
+ session_id,
753
+ thread_id,
754
+ error,
755
+ )
756
+ self._loaded_thread_ids.discard(thread_id)
757
+ try:
758
+ await self._ensure_thread_loaded(thread_id, force=True)
759
+ except RuntimeError as resume_error:
760
+ if _unresumable_thread_failure_reason(str(resume_error)) != "deleted":
761
+ raise
762
+ return await self._replace_missing_thread_for_turn(
763
+ params,
764
+ session_id=session_id,
765
+ old_thread_id=thread_id,
766
+ error=resume_error,
767
+ )
768
+ return thread_id, []
769
+
770
+ async def _replace_missing_thread_for_turn(
771
+ self,
772
+ params: dict[str, Any],
773
+ *,
774
+ session_id: str,
775
+ old_thread_id: str,
776
+ error: RuntimeError,
777
+ ) -> tuple[str, list[dict[str, Any]]]:
778
+ logger.warning(
779
+ "codex thread rollout missing; creating replacement thread session_id={} old_thread_id={} error={}",
780
+ session_id,
781
+ old_thread_id,
782
+ error,
783
+ )
784
+ replacement = await self._create_replacement_thread(params)
785
+ thread_id = replacement["externalSessionId"]
786
+ self.reducer.bind_session(session_id, thread_id)
787
+ backend_notifications = replacement["backendNotifications"]
788
+ for notification in backend_notifications:
789
+ if notification.get("method") == "session.updated":
790
+ notification.get("params", {}).pop("status", None)
791
+ for notification in backend_notifications:
792
+ if self.notification_sink is not None:
793
+ await self.notification_sink(notification["method"], notification["params"])
794
+ return thread_id, backend_notifications
795
+
796
+ async def _best_effort_bootstrap_reads(self) -> None:
797
+ assert self.rpc is not None
798
+ for method, params in (
799
+ ("account/read", None),
800
+ ("model/list", None),
801
+ ("thread/loaded/list", None),
802
+ ):
803
+ try:
804
+ await self.rpc.request(method, params)
805
+ except Exception as exc: # pragma: no cover - defensive against version drift
806
+ logger.debug("codex bootstrap read failed method={} error={}", method, exc)
807
+
808
+ async def _reduce_current_timeline(
809
+ self,
810
+ session_id: str,
811
+ thread_id: str,
812
+ *,
813
+ thread_ref: dict[str, Any] | None = None,
814
+ ) -> tuple[ReductionResult, dict[str, Any]]:
815
+ assert self.rpc is not None
816
+ assert self.reducer is not None
817
+ snapshot_result = await self.rpc.request("thread/read", {"threadId": thread_id, "includeTurns": True})
818
+ thread = snapshot_result.get("thread") if isinstance(snapshot_result.get("thread"), dict) else snapshot_result
819
+ if not isinstance(thread, dict):
820
+ thread = {}
821
+ return self.reducer.reduce_thread_snapshot(
822
+ session_id,
823
+ thread,
824
+ fallback_thread_id=thread_id,
825
+ ), thread
826
+
827
+ def _schedule_history_sync_after_turn_completion(self, message: dict[str, Any]) -> None:
828
+ if message.get("method") != "turn/completed":
829
+ return
830
+ params = message.get("params") if isinstance(message.get("params"), dict) else {}
831
+ thread_id = _optional_string(params.get("threadId")) or _nested_string(params, "thread", "id")
832
+ if thread_id is None:
833
+ return
834
+ session_id = _optional_string(params.get("platformSessionId"))
835
+ if session_id is None and self.reducer is not None:
836
+ session_id = self.reducer.session_for_thread(thread_id)
837
+ if session_id is None:
838
+ return
839
+ old_task = self._history_sync_tasks.get(thread_id)
840
+ if old_task is not None and not old_task.done():
841
+ old_task.cancel()
842
+ self._history_sync_tasks[thread_id] = asyncio.create_task(self._delayed_push_thread_snapshot(session_id, thread_id))
843
+
844
+ async def _delayed_push_thread_snapshot(self, session_id: str, thread_id: str) -> None:
845
+ try:
846
+ await asyncio.sleep(0.5)
847
+ reduced, _thread = await self._reduce_current_timeline(session_id, thread_id)
848
+ if not reduced.timeline_items:
849
+ return
850
+ notification_count = 0
851
+ for notification in _backend_notifications_from_reduction(reduced, timeline_method="timeline.sync"):
852
+ notification_count += 1
853
+ if self.notification_sink is not None:
854
+ await self.notification_sink(notification["method"], notification["params"])
855
+ logger.info(
856
+ "codex turn snapshot synced session_id={} thread_id={} timeline_items={} notifications={}",
857
+ session_id,
858
+ thread_id,
859
+ len(reduced.timeline_items),
860
+ notification_count,
861
+ )
862
+ except asyncio.CancelledError:
863
+ raise
864
+ except Exception:
865
+ logger.exception("codex delayed thread snapshot sync failed thread_id={}", thread_id)
866
+
867
+
868
+ def _backend_notifications_from_reduction(
869
+ reduced: ReductionResult,
870
+ *,
871
+ timeline_method: str = "timeline.sync",
872
+ ) -> list[dict[str, Any]]:
873
+ notifications: list[dict[str, Any]] = []
874
+ if reduced.session_update:
875
+ notifications.append({"method": "session.updated", "params": reduced.session_update})
876
+ if reduced.timeline_items:
877
+ session_id = reduced.timeline_items[0]["sessionId"]
878
+ if timeline_method == "timeline.itemUpsert":
879
+ for item in reduced.timeline_items:
880
+ notifications.append({"method": timeline_method, "params": {"sessionId": session_id, "item": item}})
881
+ else:
882
+ notifications.append({"method": timeline_method, "params": {"sessionId": session_id, "items": reduced.timeline_items}})
883
+ for approval in reduced.approvals:
884
+ notifications.append({"method": "approval.requested", "params": approval})
885
+ return notifications
886
+
887
+
888
+ def _session_id_from_reduction(reduced: ReductionResult) -> str | None:
889
+ if reduced.timeline_items:
890
+ value = reduced.timeline_items[0].get("sessionId")
891
+ return value if isinstance(value, str) else None
892
+ if reduced.session_update:
893
+ value = reduced.session_update.get("sessionId")
894
+ return value if isinstance(value, str) else None
895
+ if reduced.approvals:
896
+ value = reduced.approvals[0].get("sessionId")
897
+ return value if isinstance(value, str) else None
898
+ return None
899
+
900
+
901
+ def _thread_id_from_turn_message(message: dict[str, Any]) -> str | None:
902
+ params = message.get("params") if isinstance(message.get("params"), dict) else {}
903
+ return _optional_string(params.get("threadId")) or _nested_string(params, "thread", "id")
904
+
905
+
906
+ def _completed_item_from_message(message: dict[str, Any]) -> dict[str, Any] | None:
907
+ params = message.get("params") if isinstance(message.get("params"), dict) else {}
908
+ item = params.get("item")
909
+ return item if isinstance(item, dict) else None
910
+
911
+
912
+ def stable_session_id(connector_id: str, thread_id: str) -> str:
913
+ digest = hashlib.sha256(f"{connector_id}:codex:{thread_id}".encode("utf-8")).hexdigest()[:24]
914
+ return f"sess_codex_{digest}"
915
+
916
+
917
+ # Token only emitted by Claude Code when its transcript is serialised into a
918
+ # Codex thread; never appears in native Codex output.
919
+ _EXTERNAL_AGENT_TOOL_CALL_MARKER = "[external_agent_tool_call:"
920
+
921
+
922
+ def _is_imported_external_thread(timeline_items: list[dict[str, Any]]) -> bool:
923
+ for item in timeline_items:
924
+ if not isinstance(item, dict):
925
+ continue
926
+ if item.get("type") != "message":
927
+ continue
928
+ if item.get("role") != "assistant":
929
+ continue
930
+ content = item.get("content")
931
+ if not isinstance(content, dict):
932
+ continue
933
+ text = content.get("text")
934
+ if isinstance(text, str) and _EXTERNAL_AGENT_TOOL_CALL_MARKER in text:
935
+ return True
936
+ return False
937
+
938
+
939
+ def _thread_sync_marker(thread_ref: dict[str, Any]) -> str | None:
940
+ updated_at = thread_ref.get("updatedAt") or thread_ref.get("updated_at")
941
+ if updated_at is not None:
942
+ return f"updated:{_codex_time(updated_at) or str(updated_at)}"
943
+ try:
944
+ encoded = json.dumps(thread_ref, ensure_ascii=False, sort_keys=True, default=str)
945
+ except TypeError:
946
+ return None
947
+ return f"ref:{hashlib.sha256(encoded.encode('utf-8')).hexdigest()}"
948
+
949
+
950
+ def _thread_refs_from_list_result(result: dict[str, Any]) -> list[dict[str, Any]]:
951
+ for key in ("threads", "data", "items"):
952
+ value = result.get(key)
953
+ if isinstance(value, list):
954
+ return [item for item in value if isinstance(item, dict)]
955
+ nested = result.get("thread")
956
+ if isinstance(nested, dict):
957
+ return [nested]
958
+ if _thread_id_from_result(result):
959
+ return [result]
960
+ logger.debug("codex thread/list returned no recognizable thread list: {}", json.dumps(result, ensure_ascii=False))
961
+ return []
962
+
963
+
964
+ def _local_thread_state(thread_ref: dict[str, Any]) -> str:
965
+ """Best-effort local thread state from Codex list metadata.
966
+
967
+ Codex app-server is versioned independently, so keep this deliberately
968
+ tolerant: if any common archived/deleted flag is present we treat the
969
+ thread as not resumable and never publish it to the backend.
970
+ """
971
+ for key in ("localState", "local_state", "lifecycleState", "lifecycle_state"):
972
+ value = thread_ref.get(key)
973
+ if isinstance(value, str):
974
+ normalized = value.lower()
975
+ if normalized in {"active", "archived", "deleted", "unresumable", "unknown"}:
976
+ return normalized
977
+ status = thread_ref.get("status")
978
+ if isinstance(status, dict):
979
+ status = status.get("type") or status.get("state")
980
+ if isinstance(status, str):
981
+ normalized_status = status.lower()
982
+ if normalized_status in {"archived", "deleted", "unresumable"}:
983
+ return normalized_status
984
+ for key in ("archived", "isArchived", "is_archived"):
985
+ if thread_ref.get(key) is True:
986
+ return "archived"
987
+ for key in ("deleted", "isDeleted", "is_deleted"):
988
+ if thread_ref.get(key) is True:
989
+ return "deleted"
990
+ for key in ("archivedAt", "archived_at"):
991
+ if thread_ref.get(key):
992
+ return "archived"
993
+ for key in ("deletedAt", "deleted_at", "removedAt", "removed_at"):
994
+ if thread_ref.get(key):
995
+ return "deleted"
996
+ if thread_ref.get("resumeSupported") is False or thread_ref.get("resumable") is False:
997
+ return "unresumable"
998
+ return "active"
999
+
1000
+
1001
+ def _required_string(params: dict[str, Any], key: str) -> str:
1002
+ value = params.get(key)
1003
+ if not isinstance(value, str) or not value:
1004
+ raise ValueError(f"{key} is required")
1005
+ return value
1006
+
1007
+
1008
+ def _optional_string(value: Any) -> str | None:
1009
+ return value if isinstance(value, str) and value else None
1010
+
1011
+
1012
+ def _sandbox_mode(value: Any) -> str | None:
1013
+ if value is None:
1014
+ return None
1015
+ if isinstance(value, str):
1016
+ if value in {"read-only", "workspace-write", "danger-full-access"}:
1017
+ return value
1018
+ return {
1019
+ "readOnly": "read-only",
1020
+ "workspaceWrite": "workspace-write",
1021
+ "dangerFullAccess": "danger-full-access",
1022
+ }.get(value)
1023
+ if isinstance(value, dict):
1024
+ sandbox_type = value.get("type")
1025
+ if isinstance(sandbox_type, str):
1026
+ return {
1027
+ "readOnly": "read-only",
1028
+ "workspaceWrite": "workspace-write",
1029
+ "dangerFullAccess": "danger-full-access",
1030
+ "read-only": "read-only",
1031
+ "workspace-write": "workspace-write",
1032
+ "danger-full-access": "danger-full-access",
1033
+ }.get(sandbox_type)
1034
+ return None
1035
+
1036
+
1037
+ def _codex_time(value: Any) -> str | None:
1038
+ if isinstance(value, int | float):
1039
+ seconds = float(value)
1040
+ if seconds > 10_000_000_000:
1041
+ seconds = seconds / 1000
1042
+ return datetime.fromtimestamp(seconds, UTC).isoformat().replace("+00:00", "Z")
1043
+ return _optional_string(value)
1044
+
1045
+
1046
+ def _nested_string(data: dict[str, Any], key: str, nested_key: str) -> str | None:
1047
+ nested = data.get(key)
1048
+ if isinstance(nested, dict):
1049
+ return _optional_string(nested.get(nested_key))
1050
+ return None
1051
+
1052
+
1053
+ def _approval_decision(status: Any) -> str:
1054
+ if status == "approved_for_session":
1055
+ return "acceptForSession"
1056
+ if status == "approved":
1057
+ return "accept"
1058
+ if status == "cancelled":
1059
+ return "cancel"
1060
+ return "decline"
1061
+
1062
+
1063
+ def _soft_interrupt_failure_reason(error_text: str) -> str | None:
1064
+ message = error_text
1065
+ try:
1066
+ parsed = json.loads(error_text)
1067
+ if isinstance(parsed, dict):
1068
+ raw = parsed.get("message")
1069
+ if isinstance(raw, str):
1070
+ message = raw
1071
+ except json.JSONDecodeError:
1072
+ pass
1073
+ normalized = message.lower()
1074
+ if "thread not found" in normalized:
1075
+ return "thread_not_found"
1076
+ if "turn not found" in normalized:
1077
+ return "turn_not_found"
1078
+ return None
1079
+
1080
+
1081
+ def _unresumable_thread_failure_reason(error_text: str) -> str | None:
1082
+ message = error_text
1083
+ try:
1084
+ parsed = json.loads(error_text)
1085
+ if isinstance(parsed, dict):
1086
+ raw = parsed.get("message")
1087
+ if isinstance(raw, str):
1088
+ message = raw
1089
+ except json.JSONDecodeError:
1090
+ pass
1091
+ normalized = message.lower()
1092
+ if (
1093
+ "thread not found" in normalized
1094
+ or "session not found" in normalized
1095
+ or "no rollout found" in normalized
1096
+ or "id not found" in normalized
1097
+ ):
1098
+ return "deleted"
1099
+ if "archived" in normalized:
1100
+ return "archived"
1101
+ if "cannot resume" in normalized or "not resumable" in normalized or "unresumable" in normalized:
1102
+ return "unresumable"
1103
+ if "failed to load configuration" in normalized and "model provider" in normalized:
1104
+ return "missing_model_provider"
1105
+ if "model provider" in normalized and "not found" in normalized:
1106
+ return "missing_model_provider"
1107
+ return None
1108
+
1109
+
1110
+ def _attachment_file_id(att: Any) -> str | None:
1111
+ if isinstance(att, dict):
1112
+ candidate = att.get("fileId")
1113
+ if isinstance(candidate, str) and candidate:
1114
+ return candidate
1115
+ return None
1116
+
1117
+
1118
+ def _attachment_name_from(att: Any) -> str | None:
1119
+ if isinstance(att, dict):
1120
+ candidate = att.get("name")
1121
+ if isinstance(candidate, str) and candidate:
1122
+ return candidate
1123
+ return None
1124
+
1125
+
1126
+ def _item_notifications(session_id: str, items: list[dict[str, Any]]) -> list[dict[str, Any]]:
1127
+ return [
1128
+ {
1129
+ "method": "timeline.itemUpsert",
1130
+ "params": {"sessionId": session_id, "item": item},
1131
+ }
1132
+ for item in items
1133
+ ]
1134
+
1135
+
1136
+ def _status_account(value: dict[str, Any] | None) -> dict[str, Any] | None:
1137
+ if not value:
1138
+ return None
1139
+ account = value.get("account") if isinstance(value.get("account"), dict) else {}
1140
+ sanitized = {
1141
+ key: account[key]
1142
+ for key in ("type", "planType")
1143
+ if key in account
1144
+ }
1145
+ if "requiresOpenaiAuth" in value:
1146
+ sanitized["requiresOpenaiAuth"] = value["requiresOpenaiAuth"]
1147
+ return sanitized or None
1148
+
1149
+
1150
+ __all__ = ["CODEX_APPROVAL_METHODS", "CodexAdapter", "JsonRpcStdioClient", "TimelineReducer"]