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.
- anywhere_cli-0.1.0.dist-info/METADATA +110 -0
- anywhere_cli-0.1.0.dist-info/RECORD +36 -0
- anywhere_cli-0.1.0.dist-info/WHEEL +4 -0
- anywhere_cli-0.1.0.dist-info/entry_points.txt +3 -0
- connector/__init__.py +3 -0
- connector/adapter.py +39 -0
- connector/attachments.py +36 -0
- connector/capabilities.py +334 -0
- connector/claude/__init__.py +8 -0
- connector/claude/history_adapter.py +642 -0
- connector/claude/normalized.py +23 -0
- connector/claude/normalizers.py +97 -0
- connector/claude/path_utils.py +13 -0
- connector/claude/preferences.py +38 -0
- connector/claude/sdk_adapter.py +1377 -0
- connector/claude/timeline_identity.py +47 -0
- connector/claude/timeline_reducer.py +379 -0
- connector/claude/trust.py +69 -0
- connector/cli.py +149 -0
- connector/codex/__init__.py +3 -0
- connector/codex/adapter.py +951 -0
- connector/codex/history.py +199 -0
- connector/codex/reducer.py +1223 -0
- connector/codex/rpc.py +260 -0
- connector/launch.py +104 -0
- connector/local/__init__.py +6 -0
- connector/local/common.py +118 -0
- connector/local/file_ops.py +122 -0
- connector/local/ops.py +83 -0
- connector/local/shell.py +225 -0
- connector/local/terminal.py +389 -0
- connector/local_ops.py +5 -0
- connector/protocol.py +26 -0
- connector/runtime.py +1002 -0
- connector/sync_state.py +155 -0
- connector/time.py +7 -0
|
@@ -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"]
|