jupyterlab-codex-sidebar 0.1.3 → 0.1.5
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.
- package/.claude/settings.local.json +9 -0
- package/.github/workflows/unit-tests.yml +27 -0
- package/AGENTS.md +42 -0
- package/README.md +67 -9
- package/docs/images/codex-sidebar-screenshot.png +0 -0
- package/jupyterlab_codex/handlers.py +938 -297
- package/jupyterlab_codex/labextension/package.json +13 -3
- package/jupyterlab_codex/labextension/static/525.224526d045c727069de6.js +2 -0
- package/jupyterlab_codex/labextension/static/855.d20f6158cd81bb4c9056.js +1 -0
- package/jupyterlab_codex/labextension/static/{remoteEntry.b2fdc03a1c4582e79156.js → remoteEntry.c1e865f207776f7f24ff.js} +1 -1
- package/jupyterlab_codex/protocol.py +297 -0
- package/jupyterlab_codex/runner.py +137 -31
- package/jupyterlab_codex/sessions.py +582 -97
- package/lib/codexChat.d.ts +13 -0
- package/lib/codexChat.js +2410 -0
- package/lib/codexChat.js.map +1 -0
- package/lib/codexChatAttachmentDedup.d.ts +10 -0
- package/lib/codexChatAttachmentDedup.js +35 -0
- package/lib/codexChatAttachmentDedup.js.map +1 -0
- package/lib/codexChatAttachmentLimit.d.ts +8 -0
- package/lib/codexChatAttachmentLimit.js +61 -0
- package/lib/codexChatAttachmentLimit.js.map +1 -0
- package/lib/codexChatDocumentUtils.d.ts +68 -0
- package/lib/codexChatDocumentUtils.js +480 -0
- package/lib/codexChatDocumentUtils.js.map +1 -0
- package/lib/codexChatFormatting.d.ts +11 -0
- package/lib/codexChatFormatting.js +83 -0
- package/lib/codexChatFormatting.js.map +1 -0
- package/lib/codexChatNotice.d.ts +3 -0
- package/lib/codexChatNotice.js +74 -0
- package/lib/codexChatNotice.js.map +1 -0
- package/lib/codexChatPersistence.d.ts +35 -0
- package/lib/codexChatPersistence.js +158 -0
- package/lib/codexChatPersistence.js.map +1 -0
- package/lib/codexChatPrimitives.d.ts +41 -0
- package/lib/codexChatPrimitives.js +152 -0
- package/lib/codexChatPrimitives.js.map +1 -0
- package/lib/codexChatRender.d.ts +24 -0
- package/lib/codexChatRender.js +293 -0
- package/lib/codexChatRender.js.map +1 -0
- package/lib/codexChatSessionFactory.d.ts +15 -0
- package/lib/codexChatSessionFactory.js +45 -0
- package/lib/codexChatSessionFactory.js.map +1 -0
- package/lib/codexChatSessionKey.d.ts +3 -0
- package/lib/codexChatSessionKey.js +14 -0
- package/lib/codexChatSessionKey.js.map +1 -0
- package/lib/codexChatStorage.d.ts +4 -0
- package/lib/codexChatStorage.js +37 -0
- package/lib/codexChatStorage.js.map +1 -0
- package/lib/codexSessionResolver.d.ts +12 -0
- package/lib/codexSessionResolver.js +38 -0
- package/lib/codexSessionResolver.js.map +1 -0
- package/lib/handlers/activitySummarizer.d.ts +15 -0
- package/lib/handlers/activitySummarizer.js +327 -0
- package/lib/handlers/activitySummarizer.js.map +1 -0
- package/lib/handlers/codexMessageTypes.d.ts +30 -0
- package/lib/handlers/codexMessageTypes.js +2 -0
- package/lib/handlers/codexMessageTypes.js.map +1 -0
- package/lib/handlers/codexMessageUtils.d.ts +46 -0
- package/lib/handlers/codexMessageUtils.js +144 -0
- package/lib/handlers/codexMessageUtils.js.map +1 -0
- package/lib/handlers/handleCodexSocketMessage.d.ts +107 -0
- package/lib/handlers/handleCodexSocketMessage.js +78 -0
- package/lib/handlers/handleCodexSocketMessage.js.map +1 -0
- package/lib/handlers/sessionSyncHandler.d.ts +34 -0
- package/lib/handlers/sessionSyncHandler.js +181 -0
- package/lib/handlers/sessionSyncHandler.js.map +1 -0
- package/lib/hooks/useCodexSocket.d.ts +15 -0
- package/lib/hooks/useCodexSocket.js +84 -0
- package/lib/hooks/useCodexSocket.js.map +1 -0
- package/lib/index.js +1 -1
- package/lib/index.js.map +1 -1
- package/lib/panel.d.ts +1 -11
- package/lib/panel.js +1 -2768
- package/lib/panel.js.map +1 -1
- package/lib/protocol.d.ts +235 -0
- package/lib/protocol.js +278 -0
- package/lib/protocol.js.map +1 -0
- package/package.json +13 -3
- package/playwright.config.cjs +24 -0
- package/playwright.unit.config.cjs +19 -0
- package/pyproject.toml +1 -1
- package/release.sh +243 -0
- package/scripts/run_playwright_e2e.sh +96 -0
- package/scripts/run_playwright_freeze_repro.sh +58 -0
- package/scripts/run_playwright_queue_repro.sh +60 -0
- package/scripts/run_playwright_repro.sh +55 -0
- package/src/codexChat.tsx +3755 -0
- package/src/codexChatAttachmentDedup.ts +47 -0
- package/src/codexChatAttachmentLimit.ts +82 -0
- package/src/codexChatDocumentUtils.ts +612 -0
- package/src/codexChatFormatting.ts +94 -0
- package/src/codexChatNotice.ts +95 -0
- package/src/codexChatPersistence.ts +191 -0
- package/src/codexChatPrimitives.tsx +422 -0
- package/src/codexChatRender.tsx +376 -0
- package/src/codexChatSessionFactory.ts +79 -0
- package/src/codexChatSessionKey.ts +16 -0
- package/src/codexChatStorage.ts +36 -0
- package/src/codexSessionResolver.ts +56 -0
- package/src/handlers/activitySummarizer.ts +369 -0
- package/src/handlers/codexMessageTypes.ts +34 -0
- package/src/handlers/codexMessageUtils.ts +217 -0
- package/src/handlers/handleCodexSocketMessage.ts +204 -0
- package/src/handlers/sessionSyncHandler.ts +308 -0
- package/src/hooks/useCodexSocket.ts +109 -0
- package/src/index.ts +1 -1
- package/src/panel.tsx +1 -4131
- package/src/protocol.ts +582 -0
- package/style/index.css +424 -11
- package/tests/e2e/fixtures/notebooks/tab1.ipynb +322 -0
- package/tests/e2e/fixtures/notebooks/tab1.py +272 -0
- package/tests/e2e/fixtures/notebooks/tab2.ipynb +252 -0
- package/tests/e2e/fixtures/notebooks/tab2.py +231 -0
- package/tests/e2e/fixtures/notebooks/tab3.ipynb +403 -0
- package/tests/e2e/fixtures/notebooks/tab3.py +331 -0
- package/tests/e2e/fixtures/notebooks/tab4.py +339 -0
- package/tests/e2e/freeze-notebook-tabs-repro.spec.js +295 -0
- package/tests/e2e/mock-codex-cli-flood.py +127 -0
- package/tests/e2e/mock-codex-cli.py +95 -0
- package/tests/e2e/queue-multitab-repro.spec.js +189 -0
- package/tests/test_handlers.py +116 -0
- package/tests/test_protocol.py +169 -0
- package/tests/test_session_store_limits.py +50 -0
- package/tests/unit/codexChatAttachmentDedup.spec.ts +56 -0
- package/tests/unit/codexChatAttachmentLimit.spec.ts +42 -0
- package/tests/unit/codexChatLimit.spec.ts +18 -0
- package/tests/unit/codexChatNotice.spec.ts +45 -0
- package/tests/unit/codexChatPersistence.spec.ts +199 -0
- package/tests/unit/codexChatSessionFactory.spec.ts +94 -0
- package/tests/unit/codexChatSessionKey.spec.ts +18 -0
- package/tests/unit/codexMessageUtils.spec.ts +89 -0
- package/tests/unit/codexSessionResolver.spec.ts +92 -0
- package/tests/unit/handleCodexSocketMessage.spec.ts +476 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/webpack.config.js +6 -0
- package/jupyterlab_codex/labextension/static/504.335f3447c84ba3d74517.js +0 -2
- package/jupyterlab_codex/labextension/static/972.d43137b7438a053eeb72.js +0 -1
- /package/jupyterlab_codex/labextension/static/{504.335f3447c84ba3d74517.js.LICENSE.txt → 525.224526d045c727069de6.js.LICENSE.txt} +0 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import base64
|
|
3
|
+
import hashlib
|
|
3
4
|
import json
|
|
4
5
|
import os
|
|
5
6
|
import shutil
|
|
@@ -11,11 +12,23 @@ from datetime import datetime, timezone
|
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
from typing import Any, Dict
|
|
13
14
|
|
|
14
|
-
from tornado.websocket import WebSocketHandler
|
|
15
|
+
from tornado.websocket import WebSocketClosedError, WebSocketHandler
|
|
15
16
|
|
|
16
17
|
from .cli_defaults import load_cli_defaults_for_ui
|
|
17
18
|
from .runner import CodexRunner
|
|
18
19
|
from .sessions import SessionStore
|
|
20
|
+
from .protocol import (
|
|
21
|
+
ProtocolParseError,
|
|
22
|
+
build_cli_defaults_payload,
|
|
23
|
+
build_delete_all_payload,
|
|
24
|
+
build_done_payload,
|
|
25
|
+
build_error_payload,
|
|
26
|
+
build_event_payload,
|
|
27
|
+
build_output_payload,
|
|
28
|
+
build_rate_limits_payload,
|
|
29
|
+
build_status_payload,
|
|
30
|
+
parse_client_message,
|
|
31
|
+
)
|
|
19
32
|
|
|
20
33
|
|
|
21
34
|
_MAX_IMAGE_ATTACHMENTS = 4
|
|
@@ -36,6 +49,21 @@ _NOISY_STDERR_PATTERNS = (
|
|
|
36
49
|
),
|
|
37
50
|
)
|
|
38
51
|
|
|
52
|
+
_AUTH_REQUIRED_HINT = (
|
|
53
|
+
"Authentication required: open a terminal and run `codex` (or `codex login`) to sign in, then retry."
|
|
54
|
+
)
|
|
55
|
+
_RESUME_FALLBACK_HINT = (
|
|
56
|
+
"Resume was unavailable for this turn. This turn was handled in fallback mode."
|
|
57
|
+
)
|
|
58
|
+
_PY_CELL_MARKER_RE = re.compile(r"^\s*#\s*%%(?:\s|$|\[)")
|
|
59
|
+
_PY_JUPYTEXT_HEADER_HINTS = (
|
|
60
|
+
"jupytext:",
|
|
61
|
+
"formats:",
|
|
62
|
+
"format_name:",
|
|
63
|
+
"text_representation:",
|
|
64
|
+
)
|
|
65
|
+
_SAFE_SESSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
66
|
+
|
|
39
67
|
|
|
40
68
|
def _strip_noisy_stderr_lines(text: str) -> str:
|
|
41
69
|
if not text:
|
|
@@ -48,6 +76,15 @@ def _strip_noisy_stderr_lines(text: str) -> str:
|
|
|
48
76
|
return "".join(kept)
|
|
49
77
|
|
|
50
78
|
|
|
79
|
+
def _is_missing_auth_stderr(text: str) -> bool:
|
|
80
|
+
lower = (text or "").lower()
|
|
81
|
+
if not lower:
|
|
82
|
+
return False
|
|
83
|
+
if "missing bearer or basic authentication" in lower:
|
|
84
|
+
return True
|
|
85
|
+
return "401 unauthorized" in lower and "api.openai.com" in lower
|
|
86
|
+
|
|
87
|
+
|
|
51
88
|
def _coerce_command_path(value: Any) -> str:
|
|
52
89
|
if not isinstance(value, str):
|
|
53
90
|
return ""
|
|
@@ -60,6 +97,15 @@ def _coerce_session_context_key(value: Any) -> str:
|
|
|
60
97
|
return value.strip()
|
|
61
98
|
|
|
62
99
|
|
|
100
|
+
def _coerce_session_id(value: Any) -> str:
|
|
101
|
+
if not isinstance(value, str):
|
|
102
|
+
return ""
|
|
103
|
+
session_id = value.strip()
|
|
104
|
+
if not session_id or not _SAFE_SESSION_ID_RE.fullmatch(session_id):
|
|
105
|
+
return ""
|
|
106
|
+
return session_id
|
|
107
|
+
|
|
108
|
+
|
|
63
109
|
def _coerce_bool_flag(value: Any) -> bool:
|
|
64
110
|
if isinstance(value, bool):
|
|
65
111
|
return value
|
|
@@ -68,6 +114,34 @@ def _coerce_bool_flag(value: Any) -> bool:
|
|
|
68
114
|
return False
|
|
69
115
|
|
|
70
116
|
|
|
117
|
+
def _coerce_ui_preview(value: Any) -> Dict[str, str] | None:
|
|
118
|
+
if not isinstance(value, dict):
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
location_raw = value.get("locationLabel")
|
|
122
|
+
preview_raw = value.get("previewText")
|
|
123
|
+
if not isinstance(location_raw, str) or not isinstance(preview_raw, str):
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
location = re.sub(r"\s+", " ", location_raw).strip()
|
|
127
|
+
preview_text = preview_raw.replace("\r\n", "\n").replace("\r", "\n").strip()
|
|
128
|
+
if not location or not preview_text:
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
"locationLabel": location[:80],
|
|
133
|
+
"previewText": preview_text[:500],
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _coerce_ui_selection_preview(value: Any) -> Dict[str, str] | None:
|
|
138
|
+
return _coerce_ui_preview(value)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _coerce_ui_cell_output_preview(value: Any) -> Dict[str, str] | None:
|
|
142
|
+
return _coerce_ui_preview(value)
|
|
143
|
+
|
|
144
|
+
|
|
71
145
|
def _build_command_not_found_hint(requested_path: str) -> dict[str, str]:
|
|
72
146
|
requested_label = requested_path or "codex"
|
|
73
147
|
detected = shutil.which("codex")
|
|
@@ -86,6 +160,10 @@ def _build_command_not_found_hint(requested_path: str) -> dict[str, str]:
|
|
|
86
160
|
}
|
|
87
161
|
|
|
88
162
|
|
|
163
|
+
class _ResumeFallbackRequested(Exception):
|
|
164
|
+
"""Raised when resume did not continue the requested thread."""
|
|
165
|
+
|
|
166
|
+
|
|
89
167
|
class CodexWSHandler(WebSocketHandler):
|
|
90
168
|
def initialize(self, server_app):
|
|
91
169
|
self._server_app = server_app
|
|
@@ -93,11 +171,35 @@ class CodexWSHandler(WebSocketHandler):
|
|
|
93
171
|
self._store = SessionStore()
|
|
94
172
|
self._active_runs: Dict[str, Dict[str, Any]] = {}
|
|
95
173
|
|
|
174
|
+
def _safe_write_message(self, message: str) -> bool:
|
|
175
|
+
try:
|
|
176
|
+
self.write_message(message)
|
|
177
|
+
except WebSocketClosedError:
|
|
178
|
+
return False
|
|
179
|
+
except Exception:
|
|
180
|
+
return False
|
|
181
|
+
return True
|
|
182
|
+
|
|
183
|
+
def _consume_task_exception(self, task: asyncio.Task[Any]) -> None:
|
|
184
|
+
try:
|
|
185
|
+
task.result()
|
|
186
|
+
except asyncio.CancelledError:
|
|
187
|
+
return
|
|
188
|
+
except BaseException:
|
|
189
|
+
return
|
|
190
|
+
|
|
96
191
|
def check_origin(self, origin: str) -> bool:
|
|
97
192
|
return super().check_origin(origin)
|
|
98
193
|
|
|
194
|
+
def on_close(self) -> None:
|
|
195
|
+
for run_id, run_context in list(self._active_runs.items()):
|
|
196
|
+
task = run_context.get("task")
|
|
197
|
+
if isinstance(task, asyncio.Task) and not task.done():
|
|
198
|
+
task.cancel()
|
|
199
|
+
self._active_runs.pop(run_id, None)
|
|
200
|
+
|
|
99
201
|
def open(self):
|
|
100
|
-
self.
|
|
202
|
+
self._safe_write_message(json.dumps(build_status_payload(state="ready")))
|
|
101
203
|
self._send_cli_defaults()
|
|
102
204
|
self._send_model_catalog()
|
|
103
205
|
self._send_rate_limits_snapshot()
|
|
@@ -106,40 +208,44 @@ class CodexWSHandler(WebSocketHandler):
|
|
|
106
208
|
try:
|
|
107
209
|
payload = json.loads(message)
|
|
108
210
|
except json.JSONDecodeError:
|
|
109
|
-
self.
|
|
211
|
+
self._safe_write_message(json.dumps(build_error_payload(message="Invalid JSON")))
|
|
110
212
|
return
|
|
111
213
|
|
|
112
|
-
|
|
214
|
+
try:
|
|
215
|
+
msg_type, normalized_payload = parse_client_message(payload)
|
|
216
|
+
except ProtocolParseError as exc:
|
|
217
|
+
self._safe_write_message(json.dumps(build_error_payload(message=str(exc))))
|
|
218
|
+
return
|
|
113
219
|
|
|
114
220
|
if msg_type == "start_session":
|
|
115
|
-
await self._handle_start_session(
|
|
221
|
+
await self._handle_start_session(normalized_payload)
|
|
116
222
|
return
|
|
117
223
|
|
|
118
224
|
if msg_type == "send":
|
|
119
|
-
await self._handle_send(
|
|
225
|
+
await self._handle_send(normalized_payload)
|
|
120
226
|
return
|
|
121
227
|
|
|
122
228
|
if msg_type == "delete_session":
|
|
123
|
-
self._handle_delete_session(
|
|
229
|
+
self._handle_delete_session(normalized_payload)
|
|
124
230
|
return
|
|
125
231
|
|
|
126
232
|
if msg_type == "delete_all_sessions":
|
|
127
|
-
self._handle_delete_all_sessions(
|
|
233
|
+
self._handle_delete_all_sessions(normalized_payload)
|
|
128
234
|
return
|
|
129
235
|
|
|
130
236
|
if msg_type == "cancel":
|
|
131
|
-
await self._handle_cancel(
|
|
237
|
+
await self._handle_cancel(normalized_payload)
|
|
132
238
|
return
|
|
133
239
|
|
|
134
240
|
if msg_type == "end_session":
|
|
135
|
-
await self._handle_end_session(
|
|
241
|
+
await self._handle_end_session(normalized_payload)
|
|
136
242
|
return
|
|
137
243
|
|
|
138
244
|
if msg_type == "refresh_rate_limits":
|
|
139
245
|
self._send_rate_limits_snapshot(force=True)
|
|
140
246
|
return
|
|
141
247
|
|
|
142
|
-
self.
|
|
248
|
+
self._safe_write_message(json.dumps(build_error_payload(message="Unknown message type")))
|
|
143
249
|
|
|
144
250
|
def _send_cli_defaults(self) -> None:
|
|
145
251
|
try:
|
|
@@ -148,31 +254,35 @@ class CodexWSHandler(WebSocketHandler):
|
|
|
148
254
|
defaults = {"model": None, "reasoningEffort": None}
|
|
149
255
|
|
|
150
256
|
try:
|
|
151
|
-
self.
|
|
257
|
+
self._safe_write_message(json.dumps(build_cli_defaults_payload(**defaults)))
|
|
152
258
|
except Exception:
|
|
153
259
|
return
|
|
154
260
|
|
|
155
|
-
def _send_model_catalog(self) -> None:
|
|
261
|
+
def _send_model_catalog(self, command: str | None = None, force_refresh: bool = False) -> None:
|
|
262
|
+
requested_command = _coerce_command_path(command)
|
|
263
|
+
|
|
156
264
|
async def _send() -> None:
|
|
157
|
-
models = await self._runner.list_available_models(
|
|
265
|
+
models = await self._runner.list_available_models(
|
|
266
|
+
command=requested_command or None, force_refresh=force_refresh
|
|
267
|
+
)
|
|
158
268
|
if not models:
|
|
159
269
|
return
|
|
160
270
|
try:
|
|
161
|
-
self.
|
|
271
|
+
self._safe_write_message(json.dumps(build_cli_defaults_payload(available_models=models)))
|
|
162
272
|
except Exception:
|
|
163
273
|
return
|
|
164
274
|
|
|
165
275
|
try:
|
|
166
|
-
asyncio.create_task(_send())
|
|
276
|
+
task = asyncio.create_task(_send())
|
|
277
|
+
task.add_done_callback(self._consume_task_exception)
|
|
167
278
|
except Exception:
|
|
168
279
|
return
|
|
169
280
|
|
|
170
281
|
async def _handle_start_session(self, payload: Dict[str, Any]):
|
|
171
|
-
requested_session_id = payload.get("sessionId")
|
|
172
|
-
if not isinstance(requested_session_id, str):
|
|
173
|
-
requested_session_id = str(requested_session_id)
|
|
174
|
-
requested_session_id = requested_session_id.strip()
|
|
282
|
+
requested_session_id = _coerce_session_id(payload.get("sessionId"))
|
|
175
283
|
force_new_thread = _coerce_bool_flag(payload.get("forceNewThread"))
|
|
284
|
+
requested_command_path = _coerce_command_path(payload.get("commandPath"))
|
|
285
|
+
self._send_model_catalog(command=requested_command_path, force_refresh=force_new_thread)
|
|
176
286
|
notebook_path = payload.get("notebookPath", "")
|
|
177
287
|
if not isinstance(notebook_path, str):
|
|
178
288
|
notebook_path = str(notebook_path)
|
|
@@ -181,16 +291,53 @@ class CodexWSHandler(WebSocketHandler):
|
|
|
181
291
|
notebook_os_path = self._resolve_notebook_os_path(notebook_path)
|
|
182
292
|
|
|
183
293
|
resolved_session_id = requested_session_id
|
|
294
|
+
session_resolution = "client"
|
|
295
|
+
session_resolution_notice = ""
|
|
296
|
+
mapped_session_id = _coerce_session_id(
|
|
297
|
+
self._store.resolve_session_for_notebook(notebook_path, notebook_os_path)
|
|
298
|
+
)
|
|
184
299
|
if force_new_thread:
|
|
185
|
-
|
|
300
|
+
session_resolution = "force-new"
|
|
301
|
+
previous_session_id = mapped_session_id
|
|
186
302
|
if previous_session_id and previous_session_id != resolved_session_id:
|
|
187
303
|
self._store.delete_session(previous_session_id)
|
|
188
304
|
if not resolved_session_id:
|
|
189
305
|
resolved_session_id = str(uuid.uuid4())
|
|
306
|
+
session_resolution = "new"
|
|
190
307
|
else:
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
308
|
+
# Prefer the client-provided thread id when available, but validate
|
|
309
|
+
# that it belongs to the current notebook before trusting it.
|
|
310
|
+
if resolved_session_id:
|
|
311
|
+
requested_session_exists = self._store.has_session(resolved_session_id)
|
|
312
|
+
if not requested_session_exists:
|
|
313
|
+
# The client can send a brand-new local thread id before the
|
|
314
|
+
# server has persisted metadata for it. That is not a mismatch.
|
|
315
|
+
if mapped_session_id and mapped_session_id != resolved_session_id:
|
|
316
|
+
resolved_session_id = mapped_session_id
|
|
317
|
+
session_resolution = "mapping-on-missing"
|
|
318
|
+
else:
|
|
319
|
+
session_resolution = "client-new"
|
|
320
|
+
else:
|
|
321
|
+
requested_matches_notebook = self._store.session_matches_notebook(
|
|
322
|
+
resolved_session_id, notebook_path, notebook_os_path
|
|
323
|
+
)
|
|
324
|
+
if requested_matches_notebook:
|
|
325
|
+
session_resolution = "client"
|
|
326
|
+
else:
|
|
327
|
+
if mapped_session_id and mapped_session_id != resolved_session_id:
|
|
328
|
+
resolved_session_id = mapped_session_id
|
|
329
|
+
session_resolution = "mapping-on-mismatch"
|
|
330
|
+
else:
|
|
331
|
+
resolved_session_id = str(uuid.uuid4())
|
|
332
|
+
session_resolution = "new-on-mismatch"
|
|
333
|
+
session_resolution_notice = (
|
|
334
|
+
"Thread mismatch detected. Switched to a notebook-matched thread to avoid context loss."
|
|
335
|
+
)
|
|
336
|
+
else:
|
|
337
|
+
resolved_session_id = mapped_session_id or ""
|
|
338
|
+
session_resolution = "mapping" if resolved_session_id else "new"
|
|
339
|
+
if not resolved_session_id:
|
|
340
|
+
resolved_session_id = str(uuid.uuid4())
|
|
194
341
|
|
|
195
342
|
self._store.ensure_session(resolved_session_id, notebook_path, notebook_os_path)
|
|
196
343
|
if notebook_path:
|
|
@@ -205,37 +352,52 @@ class CodexWSHandler(WebSocketHandler):
|
|
|
205
352
|
continue
|
|
206
353
|
if not isinstance(content, str):
|
|
207
354
|
continue
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
355
|
+
entry: Dict[str, Any] = {"role": role, "content": content}
|
|
356
|
+
ui = item.get("ui")
|
|
357
|
+
if isinstance(ui, dict):
|
|
358
|
+
selection_preview = _coerce_ui_selection_preview(ui.get("selectionPreview"))
|
|
359
|
+
if selection_preview:
|
|
360
|
+
entry["selectionPreview"] = selection_preview
|
|
361
|
+
cell_output_preview = _coerce_ui_cell_output_preview(ui.get("cellOutputPreview"))
|
|
362
|
+
if cell_output_preview:
|
|
363
|
+
entry["cellOutputPreview"] = cell_output_preview
|
|
364
|
+
history.append(entry)
|
|
365
|
+
|
|
366
|
+
paired_ok, paired_path, paired_os_path, paired_message, notebook_mode = _compute_pairing_status(
|
|
211
367
|
notebook_path, notebook_os_path
|
|
212
368
|
)
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
369
|
+
effective_sandbox = load_effective_sandbox_for_thread(resolved_session_id)
|
|
370
|
+
status_payload = build_status_payload(
|
|
371
|
+
state="ready",
|
|
372
|
+
session_id=resolved_session_id,
|
|
373
|
+
notebook_path=notebook_path,
|
|
374
|
+
session_context_key=session_context_key,
|
|
375
|
+
session_resolution=session_resolution,
|
|
376
|
+
session_resolution_notice=session_resolution_notice if session_resolution_notice else None,
|
|
377
|
+
history=history,
|
|
378
|
+
paired_ok=paired_ok,
|
|
379
|
+
paired_path=paired_path,
|
|
380
|
+
paired_os_path=paired_os_path,
|
|
381
|
+
paired_message=paired_message,
|
|
382
|
+
notebook_mode=notebook_mode,
|
|
383
|
+
effective_sandbox=effective_sandbox,
|
|
228
384
|
)
|
|
385
|
+
self._safe_write_message(json.dumps(status_payload))
|
|
229
386
|
|
|
230
387
|
async def _handle_send(self, payload: Dict[str, Any]):
|
|
231
|
-
session_id = payload.get("sessionId") or
|
|
232
|
-
if not isinstance(session_id, str):
|
|
233
|
-
session_id = str(session_id)
|
|
234
|
-
session_id = session_id.strip() or str(uuid.uuid4())
|
|
388
|
+
session_id = _coerce_session_id(payload.get("sessionId")) or str(uuid.uuid4())
|
|
235
389
|
content = payload.get("content", "")
|
|
236
390
|
session_context_key = _coerce_session_context_key(payload.get("sessionContextKey"))
|
|
237
391
|
selection = payload.get("selection", "")
|
|
392
|
+
if not isinstance(selection, str):
|
|
393
|
+
selection = str(selection) if selection is not None else ""
|
|
238
394
|
cell_output = payload.get("cellOutput", "")
|
|
395
|
+
if not isinstance(cell_output, str):
|
|
396
|
+
cell_output = str(cell_output) if cell_output is not None else ""
|
|
397
|
+
ui_selection_preview = _coerce_ui_selection_preview(payload.get("uiSelectionPreview"))
|
|
398
|
+
ui_cell_output_preview = _coerce_ui_cell_output_preview(payload.get("uiCellOutputPreview"))
|
|
399
|
+
selection_truncated = bool(payload.get("selectionTruncated"))
|
|
400
|
+
cell_output_truncated = bool(payload.get("cellOutputTruncated"))
|
|
239
401
|
images_payload = payload.get("images")
|
|
240
402
|
notebook_path = payload.get("notebookPath", "")
|
|
241
403
|
requested_model_raw = payload.get("model")
|
|
@@ -250,144 +412,319 @@ class CodexWSHandler(WebSocketHandler):
|
|
|
250
412
|
|
|
251
413
|
has_images = bool(images_payload)
|
|
252
414
|
if not content and not has_images:
|
|
253
|
-
self.
|
|
415
|
+
self._safe_write_message(
|
|
416
|
+
json.dumps(
|
|
417
|
+
build_error_payload(
|
|
418
|
+
message="Empty content",
|
|
419
|
+
run_id=run_id,
|
|
420
|
+
session_id=session_id,
|
|
421
|
+
session_context_key=session_context_key,
|
|
422
|
+
notebook_path=notebook_path,
|
|
423
|
+
)
|
|
424
|
+
)
|
|
425
|
+
)
|
|
254
426
|
return
|
|
255
427
|
if requested_model_raw and not requested_model:
|
|
256
|
-
self.
|
|
428
|
+
self._safe_write_message(
|
|
429
|
+
json.dumps(
|
|
430
|
+
build_error_payload(
|
|
431
|
+
message="Invalid model name",
|
|
432
|
+
run_id=run_id,
|
|
433
|
+
session_id=session_id,
|
|
434
|
+
session_context_key=session_context_key,
|
|
435
|
+
notebook_path=notebook_path,
|
|
436
|
+
)
|
|
437
|
+
)
|
|
438
|
+
)
|
|
257
439
|
return
|
|
258
440
|
if requested_reasoning_raw and not requested_reasoning:
|
|
259
|
-
self.
|
|
441
|
+
self._safe_write_message(
|
|
442
|
+
json.dumps(
|
|
443
|
+
build_error_payload(
|
|
444
|
+
message="Invalid reasoning level",
|
|
445
|
+
run_id=run_id,
|
|
446
|
+
session_id=session_id,
|
|
447
|
+
session_context_key=session_context_key,
|
|
448
|
+
notebook_path=notebook_path,
|
|
449
|
+
)
|
|
450
|
+
)
|
|
451
|
+
)
|
|
260
452
|
return
|
|
261
453
|
if requested_sandbox_raw and not requested_sandbox:
|
|
262
|
-
self.
|
|
454
|
+
self._safe_write_message(
|
|
455
|
+
json.dumps(
|
|
456
|
+
build_error_payload(
|
|
457
|
+
message="Invalid sandbox mode",
|
|
458
|
+
run_id=run_id,
|
|
459
|
+
session_id=session_id,
|
|
460
|
+
session_context_key=session_context_key,
|
|
461
|
+
notebook_path=notebook_path,
|
|
462
|
+
)
|
|
463
|
+
)
|
|
464
|
+
)
|
|
263
465
|
return
|
|
264
466
|
|
|
265
467
|
images: list[dict[str, str]] = []
|
|
266
468
|
if images_payload:
|
|
267
469
|
if not isinstance(images_payload, list):
|
|
268
|
-
self.
|
|
470
|
+
self._safe_write_message(
|
|
471
|
+
json.dumps(
|
|
472
|
+
build_error_payload(
|
|
473
|
+
message="Invalid images payload",
|
|
474
|
+
run_id=run_id,
|
|
475
|
+
session_id=session_id,
|
|
476
|
+
session_context_key=session_context_key,
|
|
477
|
+
notebook_path=notebook_path,
|
|
478
|
+
)
|
|
479
|
+
)
|
|
480
|
+
)
|
|
269
481
|
return
|
|
270
482
|
if len(images_payload) > _MAX_IMAGE_ATTACHMENTS:
|
|
271
|
-
self.
|
|
483
|
+
self._safe_write_message(
|
|
484
|
+
json.dumps(
|
|
485
|
+
build_error_payload(
|
|
486
|
+
message="Too many images attached",
|
|
487
|
+
run_id=run_id,
|
|
488
|
+
session_id=session_id,
|
|
489
|
+
session_context_key=session_context_key,
|
|
490
|
+
notebook_path=notebook_path,
|
|
491
|
+
)
|
|
492
|
+
)
|
|
493
|
+
)
|
|
272
494
|
return
|
|
273
495
|
for item in images_payload:
|
|
274
496
|
if not isinstance(item, dict):
|
|
275
|
-
self.
|
|
497
|
+
self._safe_write_message(
|
|
498
|
+
json.dumps(
|
|
499
|
+
build_error_payload(
|
|
500
|
+
message="Invalid images payload",
|
|
501
|
+
run_id=run_id,
|
|
502
|
+
session_id=session_id,
|
|
503
|
+
session_context_key=session_context_key,
|
|
504
|
+
notebook_path=notebook_path,
|
|
505
|
+
)
|
|
506
|
+
)
|
|
507
|
+
)
|
|
276
508
|
return
|
|
277
509
|
data_url = item.get("dataUrl")
|
|
278
510
|
if not isinstance(data_url, str) or not data_url.strip():
|
|
279
|
-
self.
|
|
511
|
+
self._safe_write_message(
|
|
512
|
+
json.dumps(
|
|
513
|
+
build_error_payload(
|
|
514
|
+
message="Invalid images payload",
|
|
515
|
+
run_id=run_id,
|
|
516
|
+
session_id=session_id,
|
|
517
|
+
session_context_key=session_context_key,
|
|
518
|
+
notebook_path=notebook_path,
|
|
519
|
+
)
|
|
520
|
+
)
|
|
521
|
+
)
|
|
280
522
|
return
|
|
281
523
|
name = item.get("name")
|
|
282
524
|
images.append({"dataUrl": data_url, "name": name if isinstance(name, str) else ""})
|
|
283
525
|
|
|
284
|
-
paired_ok, paired_path, paired_os_path, paired_message = _compute_pairing_status(
|
|
526
|
+
paired_ok, paired_path, paired_os_path, paired_message, notebook_mode = _compute_pairing_status(
|
|
285
527
|
notebook_path, notebook_os_path
|
|
286
528
|
)
|
|
529
|
+
run_mode = "resume"
|
|
530
|
+
|
|
531
|
+
def _build_status_payload(state: str) -> Dict[str, Any]:
|
|
532
|
+
return build_status_payload(
|
|
533
|
+
state=state,
|
|
534
|
+
run_id=run_id,
|
|
535
|
+
session_id=session_id,
|
|
536
|
+
session_context_key=session_context_key,
|
|
537
|
+
notebook_path=notebook_path,
|
|
538
|
+
run_mode=run_mode,
|
|
539
|
+
paired_ok=paired_ok,
|
|
540
|
+
paired_path=paired_path,
|
|
541
|
+
paired_os_path=paired_os_path,
|
|
542
|
+
paired_message=paired_message,
|
|
543
|
+
notebook_mode=notebook_mode,
|
|
544
|
+
effective_sandbox=load_effective_sandbox_for_thread(session_id),
|
|
545
|
+
)
|
|
546
|
+
|
|
287
547
|
if not paired_ok:
|
|
288
548
|
# Enforce paired workflow on the server as well (front-end can be bypassed).
|
|
289
|
-
self.
|
|
549
|
+
self._safe_write_message(
|
|
290
550
|
json.dumps(
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
{
|
|
308
|
-
"type": "status",
|
|
309
|
-
"state": "ready",
|
|
310
|
-
"runId": run_id,
|
|
311
|
-
"sessionId": session_id,
|
|
312
|
-
"sessionContextKey": session_context_key,
|
|
313
|
-
"notebookPath": notebook_path,
|
|
314
|
-
"pairedOk": paired_ok,
|
|
315
|
-
"pairedPath": paired_path,
|
|
316
|
-
"pairedOsPath": paired_os_path,
|
|
317
|
-
"pairedMessage": paired_message,
|
|
318
|
-
}
|
|
551
|
+
build_error_payload(
|
|
552
|
+
run_id=run_id,
|
|
553
|
+
session_id=session_id,
|
|
554
|
+
session_context_key=session_context_key,
|
|
555
|
+
notebook_path=notebook_path,
|
|
556
|
+
message=(
|
|
557
|
+
paired_message
|
|
558
|
+
or "Jupytext paired file is required for this extension."
|
|
559
|
+
),
|
|
560
|
+
run_mode=run_mode,
|
|
561
|
+
paired_ok=paired_ok,
|
|
562
|
+
paired_path=paired_path,
|
|
563
|
+
paired_os_path=paired_os_path,
|
|
564
|
+
paired_message=paired_message,
|
|
565
|
+
notebook_mode=notebook_mode,
|
|
566
|
+
)
|
|
319
567
|
)
|
|
320
568
|
)
|
|
569
|
+
self._safe_write_message(json.dumps(_build_status_payload("ready")))
|
|
321
570
|
return
|
|
322
571
|
|
|
323
572
|
self._store.ensure_session(session_id, notebook_path, notebook_os_path)
|
|
324
573
|
if notebook_path:
|
|
325
574
|
self._store.update_notebook_path(session_id, notebook_path, notebook_os_path)
|
|
326
575
|
|
|
576
|
+
prior_messages = self._store.load_messages(session_id)
|
|
577
|
+
has_conversation_history = any(
|
|
578
|
+
isinstance(item, dict)
|
|
579
|
+
and item.get("role") in {"user", "assistant"}
|
|
580
|
+
and isinstance(item.get("content"), str)
|
|
581
|
+
for item in prior_messages
|
|
582
|
+
)
|
|
583
|
+
is_first_turn = not has_conversation_history
|
|
584
|
+
|
|
327
585
|
cwd = None
|
|
328
586
|
if notebook_os_path:
|
|
329
587
|
candidate = os.path.dirname(os.path.abspath(notebook_os_path))
|
|
330
588
|
if candidate and os.path.isdir(candidate):
|
|
331
589
|
cwd = candidate
|
|
332
590
|
watch_paths = _refresh_watch_paths(notebook_os_path)
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
prompt = self._store.build_prompt(
|
|
336
|
-
|
|
591
|
+
before_file_signatures = _capture_file_signatures(watch_paths)
|
|
592
|
+
|
|
593
|
+
prompt = self._store.build_prompt(
|
|
594
|
+
session_id,
|
|
595
|
+
content,
|
|
596
|
+
selection,
|
|
597
|
+
cell_output,
|
|
598
|
+
selection_truncated=selection_truncated,
|
|
599
|
+
cell_output_truncated=cell_output_truncated,
|
|
600
|
+
cwd=cwd,
|
|
601
|
+
notebook_mode=notebook_mode,
|
|
602
|
+
include_history=False,
|
|
603
|
+
)
|
|
604
|
+
resume_target_session_id = None if is_first_turn else session_id
|
|
337
605
|
|
|
338
606
|
async def _run():
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
"type": "status",
|
|
343
|
-
"state": "running",
|
|
344
|
-
"runId": run_id,
|
|
345
|
-
"sessionId": session_id,
|
|
346
|
-
"sessionContextKey": session_context_key,
|
|
347
|
-
"notebookPath": notebook_path,
|
|
348
|
-
"pairedOk": paired_ok,
|
|
349
|
-
"pairedPath": paired_path,
|
|
350
|
-
"pairedOsPath": paired_os_path,
|
|
351
|
-
"pairedMessage": paired_message,
|
|
352
|
-
}
|
|
353
|
-
)
|
|
607
|
+
nonlocal run_mode
|
|
608
|
+
self._safe_write_message(
|
|
609
|
+
json.dumps(_build_status_payload("running"))
|
|
354
610
|
)
|
|
355
611
|
|
|
356
612
|
temp_images_dir = None
|
|
357
613
|
image_paths: list[str] = []
|
|
358
614
|
assistant_buffer = []
|
|
615
|
+
pending_output_chunks: list[str] = []
|
|
616
|
+
pending_output_chars = 0
|
|
617
|
+
last_output_flush_at = 0.0
|
|
618
|
+
output_flush_interval_s = 0.35
|
|
619
|
+
output_flush_max_chars = 6000
|
|
620
|
+
auth_hint_sent = False
|
|
621
|
+
user_message_logged = False
|
|
622
|
+
current_resume_session_id = resume_target_session_id
|
|
623
|
+
|
|
624
|
+
def _append_user_message_once() -> None:
|
|
625
|
+
nonlocal user_message_logged
|
|
626
|
+
if user_message_logged:
|
|
627
|
+
return
|
|
628
|
+
ui_payload = {}
|
|
629
|
+
if ui_selection_preview:
|
|
630
|
+
ui_payload["selectionPreview"] = ui_selection_preview
|
|
631
|
+
if ui_cell_output_preview:
|
|
632
|
+
ui_payload["cellOutputPreview"] = ui_cell_output_preview
|
|
633
|
+
self._store.append_message(session_id, "user", content, ui=ui_payload)
|
|
634
|
+
user_message_logged = True
|
|
635
|
+
|
|
636
|
+
def _flush_pending_output(force: bool = False) -> None:
|
|
637
|
+
nonlocal pending_output_chunks, pending_output_chars, last_output_flush_at
|
|
638
|
+
if not pending_output_chunks:
|
|
639
|
+
return
|
|
640
|
+
now = time.monotonic()
|
|
641
|
+
if (
|
|
642
|
+
not force
|
|
643
|
+
and pending_output_chars < output_flush_max_chars
|
|
644
|
+
and (now - last_output_flush_at) < output_flush_interval_s
|
|
645
|
+
):
|
|
646
|
+
return
|
|
647
|
+
combined_text = "".join(pending_output_chunks)
|
|
648
|
+
pending_output_chunks = []
|
|
649
|
+
pending_output_chars = 0
|
|
650
|
+
last_output_flush_at = now
|
|
651
|
+
self._safe_write_message(
|
|
652
|
+
json.dumps(
|
|
653
|
+
build_output_payload(
|
|
654
|
+
run_id=run_id,
|
|
655
|
+
session_id=session_id,
|
|
656
|
+
session_context_key=session_context_key,
|
|
657
|
+
notebook_path=notebook_path,
|
|
658
|
+
text=combined_text,
|
|
659
|
+
)
|
|
660
|
+
)
|
|
661
|
+
)
|
|
359
662
|
|
|
360
663
|
async def on_event(event: Dict[str, Any]):
|
|
664
|
+
nonlocal auth_hint_sent, session_id, pending_output_chars
|
|
665
|
+
if event.get("type") == "thread.started":
|
|
666
|
+
_flush_pending_output(force=True)
|
|
667
|
+
thread_id_raw = event.get("thread_id")
|
|
668
|
+
thread_id = _coerce_session_id(thread_id_raw)
|
|
669
|
+
if (
|
|
670
|
+
current_resume_session_id
|
|
671
|
+
and thread_id
|
|
672
|
+
and thread_id != current_resume_session_id
|
|
673
|
+
):
|
|
674
|
+
raise _ResumeFallbackRequested(
|
|
675
|
+
f"requested={current_resume_session_id}, started={thread_id}"
|
|
676
|
+
)
|
|
677
|
+
if thread_id and thread_id != session_id:
|
|
678
|
+
session_id = self._store.rename_session(session_id, thread_id)
|
|
679
|
+
run_context = self._active_runs.get(run_id)
|
|
680
|
+
if isinstance(run_context, dict):
|
|
681
|
+
run_context["sessionId"] = session_id
|
|
682
|
+
self._safe_write_message(
|
|
683
|
+
json.dumps(_build_status_payload("running"))
|
|
684
|
+
)
|
|
685
|
+
return
|
|
686
|
+
|
|
687
|
+
if event.get("type") == "stderr":
|
|
688
|
+
raw_stderr = event.get("text", "")
|
|
689
|
+
if isinstance(raw_stderr, str) and _is_missing_auth_stderr(raw_stderr):
|
|
690
|
+
if not auth_hint_sent:
|
|
691
|
+
auth_hint_sent = True
|
|
692
|
+
_flush_pending_output(force=True)
|
|
693
|
+
self._safe_write_message(
|
|
694
|
+
json.dumps(
|
|
695
|
+
build_output_payload(
|
|
696
|
+
run_id=run_id,
|
|
697
|
+
session_id=session_id,
|
|
698
|
+
session_context_key=session_context_key,
|
|
699
|
+
notebook_path=notebook_path,
|
|
700
|
+
text=_AUTH_REQUIRED_HINT,
|
|
701
|
+
role="system",
|
|
702
|
+
)
|
|
703
|
+
)
|
|
704
|
+
)
|
|
705
|
+
return
|
|
706
|
+
|
|
361
707
|
text = event_to_text(event)
|
|
362
708
|
if text:
|
|
363
709
|
assistant_buffer.append(text)
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
"type": "output",
|
|
368
|
-
"runId": run_id,
|
|
369
|
-
"sessionId": session_id,
|
|
370
|
-
"sessionContextKey": session_context_key,
|
|
371
|
-
"notebookPath": notebook_path,
|
|
372
|
-
"text": text,
|
|
373
|
-
}
|
|
374
|
-
)
|
|
375
|
-
)
|
|
710
|
+
pending_output_chunks.append(text)
|
|
711
|
+
pending_output_chars += len(text)
|
|
712
|
+
_flush_pending_output(force=False)
|
|
376
713
|
elif event.get("type") == "stderr":
|
|
377
714
|
# Ignore filtered/no-op stderr chunks to avoid rendering them
|
|
378
715
|
# again via the generic "event" UI path.
|
|
379
716
|
return
|
|
380
717
|
else:
|
|
381
|
-
|
|
718
|
+
_flush_pending_output(force=True)
|
|
719
|
+
self._safe_write_message(
|
|
382
720
|
json.dumps(
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
}
|
|
721
|
+
build_event_payload(
|
|
722
|
+
run_id=run_id,
|
|
723
|
+
session_id=session_id,
|
|
724
|
+
session_context_key=session_context_key,
|
|
725
|
+
notebook_path=notebook_path,
|
|
726
|
+
payload=event,
|
|
727
|
+
)
|
|
391
728
|
)
|
|
392
729
|
)
|
|
393
730
|
|
|
@@ -408,150 +745,208 @@ class CodexWSHandler(WebSocketHandler):
|
|
|
408
745
|
handle.write(decoded)
|
|
409
746
|
image_paths.append(out_path)
|
|
410
747
|
|
|
411
|
-
exit_code =
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
748
|
+
exit_code = None
|
|
749
|
+
try:
|
|
750
|
+
exit_code = await self._runner.run(
|
|
751
|
+
prompt,
|
|
752
|
+
on_event,
|
|
753
|
+
cwd=cwd,
|
|
754
|
+
model=requested_model,
|
|
755
|
+
reasoning_effort=requested_reasoning,
|
|
756
|
+
sandbox=requested_sandbox,
|
|
757
|
+
command=requested_command_path,
|
|
758
|
+
images=image_paths,
|
|
759
|
+
resume_session_id=current_resume_session_id,
|
|
760
|
+
)
|
|
761
|
+
except _ResumeFallbackRequested:
|
|
762
|
+
run_mode = "fallback"
|
|
763
|
+
current_resume_session_id = ""
|
|
764
|
+
assistant_buffer = []
|
|
765
|
+
pending_output_chunks = []
|
|
766
|
+
pending_output_chars = 0
|
|
767
|
+
self._safe_write_message(
|
|
768
|
+
json.dumps(
|
|
769
|
+
build_output_payload(
|
|
770
|
+
run_id=run_id,
|
|
771
|
+
session_id=session_id,
|
|
772
|
+
session_context_key=session_context_key,
|
|
773
|
+
notebook_path=notebook_path,
|
|
774
|
+
text=_RESUME_FALLBACK_HINT,
|
|
775
|
+
role="system",
|
|
776
|
+
)
|
|
777
|
+
)
|
|
778
|
+
)
|
|
779
|
+
self._safe_write_message(json.dumps(_build_status_payload("running")))
|
|
780
|
+
fallback_prompt = self._store.build_prompt(
|
|
781
|
+
session_id,
|
|
782
|
+
content,
|
|
783
|
+
selection,
|
|
784
|
+
cell_output,
|
|
785
|
+
selection_truncated=selection_truncated,
|
|
786
|
+
cell_output_truncated=cell_output_truncated,
|
|
787
|
+
cwd=cwd,
|
|
788
|
+
notebook_mode=notebook_mode,
|
|
789
|
+
include_history=True,
|
|
790
|
+
)
|
|
791
|
+
exit_code = await self._runner.run(
|
|
792
|
+
fallback_prompt,
|
|
793
|
+
on_event,
|
|
794
|
+
cwd=cwd,
|
|
795
|
+
model=requested_model,
|
|
796
|
+
reasoning_effort=requested_reasoning,
|
|
797
|
+
sandbox=requested_sandbox,
|
|
798
|
+
command=requested_command_path,
|
|
799
|
+
images=image_paths,
|
|
800
|
+
resume_session_id=None,
|
|
801
|
+
)
|
|
802
|
+
if exit_code is None:
|
|
803
|
+
exit_code = 1
|
|
804
|
+
if (
|
|
805
|
+
exit_code != 0
|
|
806
|
+
and current_resume_session_id
|
|
807
|
+
and not assistant_buffer
|
|
808
|
+
and not auth_hint_sent
|
|
809
|
+
):
|
|
810
|
+
run_mode = "fallback"
|
|
811
|
+
current_resume_session_id = ""
|
|
812
|
+
pending_output_chunks = []
|
|
813
|
+
pending_output_chars = 0
|
|
814
|
+
self._safe_write_message(
|
|
815
|
+
json.dumps(
|
|
816
|
+
build_output_payload(
|
|
817
|
+
run_id=run_id,
|
|
818
|
+
session_id=session_id,
|
|
819
|
+
session_context_key=session_context_key,
|
|
820
|
+
notebook_path=notebook_path,
|
|
821
|
+
text=_RESUME_FALLBACK_HINT,
|
|
822
|
+
role="system",
|
|
823
|
+
)
|
|
824
|
+
)
|
|
825
|
+
)
|
|
826
|
+
self._safe_write_message(json.dumps(_build_status_payload("running")))
|
|
827
|
+
fallback_prompt = self._store.build_prompt(
|
|
828
|
+
session_id,
|
|
829
|
+
content,
|
|
830
|
+
selection,
|
|
831
|
+
cell_output,
|
|
832
|
+
selection_truncated=selection_truncated,
|
|
833
|
+
cell_output_truncated=cell_output_truncated,
|
|
834
|
+
cwd=cwd,
|
|
835
|
+
notebook_mode=notebook_mode,
|
|
836
|
+
include_history=True,
|
|
837
|
+
)
|
|
838
|
+
assistant_buffer = []
|
|
839
|
+
exit_code = await self._runner.run(
|
|
840
|
+
fallback_prompt,
|
|
841
|
+
on_event,
|
|
842
|
+
cwd=cwd,
|
|
843
|
+
model=requested_model,
|
|
844
|
+
reasoning_effort=requested_reasoning,
|
|
845
|
+
sandbox=requested_sandbox,
|
|
846
|
+
command=requested_command_path,
|
|
847
|
+
images=image_paths,
|
|
848
|
+
resume_session_id=None,
|
|
849
|
+
)
|
|
850
|
+
_append_user_message_once()
|
|
421
851
|
if assistant_buffer:
|
|
422
852
|
self._store.append_message(session_id, "assistant", "".join(assistant_buffer))
|
|
423
|
-
|
|
424
|
-
|
|
853
|
+
_flush_pending_output(force=True)
|
|
854
|
+
file_changed = _has_path_changes(before_file_signatures, _capture_file_signatures(watch_paths))
|
|
855
|
+
self._safe_write_message(
|
|
425
856
|
json.dumps(
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
857
|
+
build_done_payload(
|
|
858
|
+
run_id=run_id,
|
|
859
|
+
session_id=session_id,
|
|
860
|
+
session_context_key=session_context_key,
|
|
861
|
+
notebook_path=notebook_path,
|
|
862
|
+
exit_code=exit_code,
|
|
863
|
+
file_changed=file_changed,
|
|
864
|
+
run_mode=run_mode,
|
|
865
|
+
paired_ok=paired_ok,
|
|
866
|
+
paired_path=paired_path,
|
|
867
|
+
paired_os_path=paired_os_path,
|
|
868
|
+
paired_message=paired_message,
|
|
869
|
+
notebook_mode=notebook_mode,
|
|
870
|
+
)
|
|
439
871
|
)
|
|
440
872
|
)
|
|
441
|
-
self.
|
|
442
|
-
json.dumps(
|
|
443
|
-
{
|
|
444
|
-
"type": "status",
|
|
445
|
-
"state": "ready",
|
|
446
|
-
"runId": run_id,
|
|
447
|
-
"sessionId": session_id,
|
|
448
|
-
"sessionContextKey": session_context_key,
|
|
449
|
-
"notebookPath": notebook_path,
|
|
450
|
-
"pairedOk": paired_ok,
|
|
451
|
-
"pairedPath": paired_path,
|
|
452
|
-
"pairedOsPath": paired_os_path,
|
|
453
|
-
"pairedMessage": paired_message,
|
|
454
|
-
}
|
|
455
|
-
)
|
|
873
|
+
self._safe_write_message(
|
|
874
|
+
json.dumps(_build_status_payload("ready"))
|
|
456
875
|
)
|
|
457
876
|
except asyncio.CancelledError:
|
|
458
|
-
|
|
459
|
-
|
|
877
|
+
_append_user_message_once()
|
|
878
|
+
_flush_pending_output(force=True)
|
|
879
|
+
file_changed = _has_path_changes(before_file_signatures, _capture_file_signatures(watch_paths))
|
|
880
|
+
self._safe_write_message(
|
|
460
881
|
json.dumps(
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
882
|
+
build_done_payload(
|
|
883
|
+
run_id=run_id,
|
|
884
|
+
session_id=session_id,
|
|
885
|
+
session_context_key=session_context_key,
|
|
886
|
+
notebook_path=notebook_path,
|
|
887
|
+
exit_code=None,
|
|
888
|
+
file_changed=file_changed,
|
|
889
|
+
run_mode=run_mode,
|
|
890
|
+
paired_ok=paired_ok,
|
|
891
|
+
paired_path=paired_path,
|
|
892
|
+
paired_os_path=paired_os_path,
|
|
893
|
+
paired_message=paired_message,
|
|
894
|
+
notebook_mode=notebook_mode,
|
|
895
|
+
cancelled=True,
|
|
896
|
+
)
|
|
475
897
|
)
|
|
476
898
|
)
|
|
477
|
-
self.
|
|
478
|
-
json.dumps(
|
|
479
|
-
{
|
|
480
|
-
"type": "status",
|
|
481
|
-
"state": "ready",
|
|
482
|
-
"runId": run_id,
|
|
483
|
-
"sessionId": session_id,
|
|
484
|
-
"sessionContextKey": session_context_key,
|
|
485
|
-
"notebookPath": notebook_path,
|
|
486
|
-
"pairedOk": paired_ok,
|
|
487
|
-
"pairedPath": paired_path,
|
|
488
|
-
"pairedOsPath": paired_os_path,
|
|
489
|
-
"pairedMessage": paired_message,
|
|
490
|
-
}
|
|
491
|
-
)
|
|
899
|
+
self._safe_write_message(
|
|
900
|
+
json.dumps(_build_status_payload("ready"))
|
|
492
901
|
)
|
|
493
|
-
|
|
902
|
+
return
|
|
494
903
|
except FileNotFoundError:
|
|
904
|
+
_append_user_message_once()
|
|
905
|
+
_flush_pending_output(force=True)
|
|
495
906
|
hint = _build_command_not_found_hint(requested_command_path)
|
|
496
|
-
|
|
497
|
-
"type": "error",
|
|
498
|
-
"runId": run_id,
|
|
499
|
-
"sessionId": session_id,
|
|
500
|
-
"sessionContextKey": session_context_key,
|
|
501
|
-
"notebookPath": notebook_path,
|
|
502
|
-
"message": hint["message"],
|
|
503
|
-
"pairedOk": paired_ok,
|
|
504
|
-
"pairedPath": paired_path,
|
|
505
|
-
"pairedOsPath": paired_os_path,
|
|
506
|
-
"pairedMessage": paired_message,
|
|
507
|
-
}
|
|
508
|
-
if suggested_command_path := hint.get("suggestedCommandPath"):
|
|
509
|
-
error_payload["suggestedCommandPath"] = suggested_command_path
|
|
510
|
-
self.write_message(json.dumps(error_payload))
|
|
511
|
-
self.write_message(
|
|
907
|
+
self._safe_write_message(
|
|
512
908
|
json.dumps(
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
"
|
|
519
|
-
|
|
520
|
-
"
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
909
|
+
build_error_payload(
|
|
910
|
+
run_id=run_id,
|
|
911
|
+
session_id=session_id,
|
|
912
|
+
session_context_key=session_context_key,
|
|
913
|
+
notebook_path=notebook_path,
|
|
914
|
+
message=hint["message"],
|
|
915
|
+
run_mode=run_mode,
|
|
916
|
+
suggested_command_path=hint.get("suggestedCommandPath"),
|
|
917
|
+
paired_ok=paired_ok,
|
|
918
|
+
paired_path=paired_path,
|
|
919
|
+
paired_os_path=paired_os_path,
|
|
920
|
+
paired_message=paired_message,
|
|
921
|
+
notebook_mode=notebook_mode,
|
|
922
|
+
)
|
|
525
923
|
)
|
|
526
924
|
)
|
|
925
|
+
self._safe_write_message(
|
|
926
|
+
json.dumps(_build_status_payload("ready"))
|
|
927
|
+
)
|
|
527
928
|
except Exception as exc: # pragma: no cover - defensive path
|
|
528
|
-
|
|
929
|
+
_append_user_message_once()
|
|
930
|
+
_flush_pending_output(force=True)
|
|
931
|
+
self._safe_write_message(
|
|
529
932
|
json.dumps(
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
933
|
+
build_error_payload(
|
|
934
|
+
run_id=run_id,
|
|
935
|
+
session_id=session_id,
|
|
936
|
+
session_context_key=session_context_key,
|
|
937
|
+
notebook_path=notebook_path,
|
|
938
|
+
message=str(exc),
|
|
939
|
+
run_mode=run_mode,
|
|
940
|
+
paired_ok=paired_ok,
|
|
941
|
+
paired_path=paired_path,
|
|
942
|
+
paired_os_path=paired_os_path,
|
|
943
|
+
paired_message=paired_message,
|
|
944
|
+
notebook_mode=notebook_mode,
|
|
945
|
+
)
|
|
538
946
|
)
|
|
539
947
|
)
|
|
540
|
-
self.
|
|
541
|
-
json.dumps(
|
|
542
|
-
{
|
|
543
|
-
"type": "status",
|
|
544
|
-
"state": "ready",
|
|
545
|
-
"runId": run_id,
|
|
546
|
-
"sessionId": session_id,
|
|
547
|
-
"sessionContextKey": session_context_key,
|
|
548
|
-
"notebookPath": notebook_path,
|
|
549
|
-
"pairedOk": paired_ok,
|
|
550
|
-
"pairedPath": paired_path,
|
|
551
|
-
"pairedOsPath": paired_os_path,
|
|
552
|
-
"pairedMessage": paired_message,
|
|
553
|
-
}
|
|
554
|
-
)
|
|
948
|
+
self._safe_write_message(
|
|
949
|
+
json.dumps(_build_status_payload("ready"))
|
|
555
950
|
)
|
|
556
951
|
finally:
|
|
557
952
|
# Rate limits are recorded by the Codex Desktop app/CLI in ~/.codex/sessions/*.
|
|
@@ -562,6 +957,7 @@ class CodexWSHandler(WebSocketHandler):
|
|
|
562
957
|
self._active_runs.pop(run_id, None)
|
|
563
958
|
|
|
564
959
|
task = asyncio.create_task(_run())
|
|
960
|
+
task.add_done_callback(self._consume_task_exception)
|
|
565
961
|
self._active_runs[run_id] = {
|
|
566
962
|
"task": task,
|
|
567
963
|
"sessionId": session_id,
|
|
@@ -570,37 +966,37 @@ class CodexWSHandler(WebSocketHandler):
|
|
|
570
966
|
}
|
|
571
967
|
|
|
572
968
|
def _handle_delete_session(self, payload: Dict[str, Any]) -> None:
|
|
573
|
-
session_id = payload.get("sessionId")
|
|
574
|
-
if not isinstance(session_id, str):
|
|
575
|
-
session_id = str(session_id) if session_id else ""
|
|
576
|
-
session_id = session_id.strip()
|
|
969
|
+
session_id = _coerce_session_id(payload.get("sessionId"))
|
|
577
970
|
if session_id:
|
|
578
971
|
self._store.delete_session(session_id)
|
|
579
972
|
|
|
580
973
|
def _handle_delete_all_sessions(self, payload: Dict[str, Any]) -> None:
|
|
581
974
|
del payload
|
|
582
|
-
response: Dict[str, Any] = {
|
|
583
|
-
"type": "delete_all_sessions",
|
|
584
|
-
"ok": False,
|
|
585
|
-
"deletedCount": 0,
|
|
586
|
-
"failedCount": 0,
|
|
587
|
-
"message": "Unknown error"
|
|
588
|
-
}
|
|
589
975
|
try:
|
|
590
976
|
deleted_count, failed_count = self._store.delete_all_sessions()
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
response["ok"] = failed_count == 0
|
|
594
|
-
response["message"] = (
|
|
977
|
+
ok = failed_count == 0
|
|
978
|
+
message = (
|
|
595
979
|
f"Deleted {deleted_count} conversations" if deleted_count else "No conversations found to delete"
|
|
596
980
|
)
|
|
597
981
|
if failed_count:
|
|
598
|
-
|
|
982
|
+
message = f"Deleted {deleted_count} conversations, failed to delete {failed_count}"
|
|
599
983
|
except Exception as exc: # pragma: no cover - defensive path
|
|
600
|
-
|
|
984
|
+
ok = False
|
|
985
|
+
deleted_count = 0
|
|
986
|
+
failed_count = 1
|
|
987
|
+
message = str(exc)
|
|
601
988
|
|
|
602
989
|
try:
|
|
603
|
-
self.
|
|
990
|
+
self._safe_write_message(
|
|
991
|
+
json.dumps(
|
|
992
|
+
build_delete_all_payload(
|
|
993
|
+
ok=ok,
|
|
994
|
+
deleted_count=deleted_count,
|
|
995
|
+
failed_count=failed_count,
|
|
996
|
+
message=message,
|
|
997
|
+
)
|
|
998
|
+
)
|
|
999
|
+
)
|
|
604
1000
|
except Exception:
|
|
605
1001
|
return
|
|
606
1002
|
|
|
@@ -611,7 +1007,7 @@ class CodexWSHandler(WebSocketHandler):
|
|
|
611
1007
|
snapshot = None
|
|
612
1008
|
|
|
613
1009
|
try:
|
|
614
|
-
self.
|
|
1010
|
+
self._safe_write_message(json.dumps(build_rate_limits_payload(snapshot)))
|
|
615
1011
|
except Exception:
|
|
616
1012
|
# Socket may already be closed; ignore.
|
|
617
1013
|
return
|
|
@@ -621,30 +1017,35 @@ class CodexWSHandler(WebSocketHandler):
|
|
|
621
1017
|
run_context = self._active_runs.get(run_id)
|
|
622
1018
|
|
|
623
1019
|
if not run_context:
|
|
624
|
-
self.
|
|
1020
|
+
self._safe_write_message(
|
|
1021
|
+
json.dumps(
|
|
1022
|
+
build_error_payload(
|
|
1023
|
+
run_id=run_id,
|
|
1024
|
+
message="Run not found",
|
|
1025
|
+
)
|
|
1026
|
+
)
|
|
1027
|
+
)
|
|
625
1028
|
return
|
|
626
1029
|
|
|
627
1030
|
task = run_context["task"]
|
|
628
1031
|
task.cancel()
|
|
629
1032
|
session_context_key = _coerce_session_context_key(run_context.get("sessionContextKey"))
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
"notebookPath": run_context["notebookPath"],
|
|
639
|
-
}
|
|
640
|
-
)
|
|
1033
|
+
session_id = run_context["sessionId"]
|
|
1034
|
+
status_payload = build_status_payload(
|
|
1035
|
+
state="ready",
|
|
1036
|
+
run_id=run_id,
|
|
1037
|
+
session_id=session_id,
|
|
1038
|
+
session_context_key=session_context_key,
|
|
1039
|
+
notebook_path=run_context["notebookPath"],
|
|
1040
|
+
effective_sandbox=load_effective_sandbox_for_thread(session_id),
|
|
641
1041
|
)
|
|
1042
|
+
self._safe_write_message(json.dumps(status_payload))
|
|
642
1043
|
|
|
643
1044
|
async def _handle_end_session(self, payload: Dict[str, Any]):
|
|
644
|
-
session_id = payload.get("sessionId")
|
|
1045
|
+
session_id = _coerce_session_id(payload.get("sessionId"))
|
|
645
1046
|
if session_id:
|
|
646
1047
|
self._store.close_session(session_id)
|
|
647
|
-
self.
|
|
1048
|
+
self._safe_write_message(json.dumps(build_status_payload(state="ready")))
|
|
648
1049
|
|
|
649
1050
|
def _resolve_notebook_os_path(self, notebook_path: str) -> str:
|
|
650
1051
|
if not notebook_path:
|
|
@@ -676,6 +1077,113 @@ class CodexWSHandler(WebSocketHandler):
|
|
|
676
1077
|
|
|
677
1078
|
_RATE_LIMITS_CACHE_TTL_SECONDS = 30.0
|
|
678
1079
|
_RATE_LIMITS_CACHE: Dict[str, Any] = {"fetched_at": 0.0, "snapshot": None}
|
|
1080
|
+
_EFFECTIVE_SANDBOX_CACHE_TTL_SECONDS = 5.0
|
|
1081
|
+
_EFFECTIVE_SANDBOX_CACHE: Dict[str, Dict[str, Any]] = {}
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
def load_effective_sandbox_for_thread(thread_id: str, force: bool = False) -> str | None:
|
|
1085
|
+
normalized_thread_id = (thread_id or "").strip()
|
|
1086
|
+
if not normalized_thread_id:
|
|
1087
|
+
return None
|
|
1088
|
+
|
|
1089
|
+
now = time.time()
|
|
1090
|
+
cached = _EFFECTIVE_SANDBOX_CACHE.get(normalized_thread_id)
|
|
1091
|
+
if (
|
|
1092
|
+
not force
|
|
1093
|
+
and isinstance(cached, dict)
|
|
1094
|
+
and isinstance(cached.get("fetched_at"), (int, float))
|
|
1095
|
+
and now - float(cached["fetched_at"]) < _EFFECTIVE_SANDBOX_CACHE_TTL_SECONDS
|
|
1096
|
+
):
|
|
1097
|
+
cached_mode = cached.get("mode")
|
|
1098
|
+
return cached_mode if isinstance(cached_mode, str) else None
|
|
1099
|
+
|
|
1100
|
+
mode = _scan_effective_sandbox_for_thread(normalized_thread_id)
|
|
1101
|
+
_EFFECTIVE_SANDBOX_CACHE[normalized_thread_id] = {"fetched_at": now, "mode": mode}
|
|
1102
|
+
return mode
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
def _scan_effective_sandbox_for_thread(thread_id: str) -> str | None:
|
|
1106
|
+
base = Path(os.path.expanduser("~")) / ".codex" / "sessions"
|
|
1107
|
+
if not base.is_dir():
|
|
1108
|
+
return None
|
|
1109
|
+
|
|
1110
|
+
pattern = f"rollout-*{thread_id}.jsonl"
|
|
1111
|
+
candidates: list[tuple[float, Path]] = []
|
|
1112
|
+
for path in base.rglob(pattern):
|
|
1113
|
+
try:
|
|
1114
|
+
candidates.append((path.stat().st_mtime, path))
|
|
1115
|
+
except OSError:
|
|
1116
|
+
continue
|
|
1117
|
+
candidates.sort(key=lambda item: item[0], reverse=True)
|
|
1118
|
+
|
|
1119
|
+
for _mtime, path in candidates[:8]:
|
|
1120
|
+
mode = _extract_effective_sandbox_from_rollout_file(path)
|
|
1121
|
+
if mode:
|
|
1122
|
+
return mode
|
|
1123
|
+
return None
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
def _extract_effective_sandbox_from_rollout_file(path: Path) -> str | None:
|
|
1127
|
+
tail = _read_file_tail(path, max_bytes=512 * 1024)
|
|
1128
|
+
if not tail:
|
|
1129
|
+
return None
|
|
1130
|
+
|
|
1131
|
+
for line in reversed(tail.splitlines()):
|
|
1132
|
+
text = line.strip()
|
|
1133
|
+
if not text or "sandbox" not in text:
|
|
1134
|
+
continue
|
|
1135
|
+
try:
|
|
1136
|
+
obj = json.loads(text)
|
|
1137
|
+
except json.JSONDecodeError:
|
|
1138
|
+
continue
|
|
1139
|
+
|
|
1140
|
+
mode = _extract_effective_sandbox_from_rollout_event(obj)
|
|
1141
|
+
if mode:
|
|
1142
|
+
return mode
|
|
1143
|
+
return None
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
def _extract_effective_sandbox_from_rollout_event(obj: Dict[str, Any]) -> str | None:
|
|
1147
|
+
turn_context_payload: Dict[str, Any] | None = None
|
|
1148
|
+
|
|
1149
|
+
if obj.get("type") == "event_msg":
|
|
1150
|
+
payload = obj.get("payload")
|
|
1151
|
+
if isinstance(payload, dict) and payload.get("type") == "turn_context":
|
|
1152
|
+
nested_payload = payload.get("payload")
|
|
1153
|
+
if isinstance(nested_payload, dict):
|
|
1154
|
+
turn_context_payload = nested_payload
|
|
1155
|
+
elif obj.get("type") == "turn_context":
|
|
1156
|
+
payload = obj.get("payload")
|
|
1157
|
+
if isinstance(payload, dict):
|
|
1158
|
+
turn_context_payload = payload
|
|
1159
|
+
|
|
1160
|
+
if turn_context_payload is None:
|
|
1161
|
+
payload = obj.get("payload")
|
|
1162
|
+
if isinstance(payload, dict) and payload.get("type") == "turn_context":
|
|
1163
|
+
nested_payload = payload.get("payload")
|
|
1164
|
+
if isinstance(nested_payload, dict):
|
|
1165
|
+
turn_context_payload = nested_payload
|
|
1166
|
+
|
|
1167
|
+
if not isinstance(turn_context_payload, dict):
|
|
1168
|
+
return None
|
|
1169
|
+
|
|
1170
|
+
sandbox_policy = turn_context_payload.get("sandbox_policy")
|
|
1171
|
+
if not isinstance(sandbox_policy, dict):
|
|
1172
|
+
sandbox_policy = turn_context_payload.get("sandboxPolicy")
|
|
1173
|
+
if not isinstance(sandbox_policy, dict):
|
|
1174
|
+
return None
|
|
1175
|
+
|
|
1176
|
+
mode = sandbox_policy.get("type")
|
|
1177
|
+
if mode is None:
|
|
1178
|
+
mode = sandbox_policy.get("mode")
|
|
1179
|
+
return _coerce_effective_sandbox_mode(mode)
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
def _coerce_effective_sandbox_mode(value: Any) -> str | None:
|
|
1183
|
+
if not isinstance(value, str):
|
|
1184
|
+
return None
|
|
1185
|
+
normalized = value.strip().lower().replace("_", "-")
|
|
1186
|
+
return _sanitize_sandbox_mode(normalized)
|
|
679
1187
|
|
|
680
1188
|
|
|
681
1189
|
def load_latest_rate_limits(force: bool = False) -> Dict[str, Any] | None:
|
|
@@ -787,6 +1295,7 @@ def _extract_rate_limits_from_session_event(obj: Dict[str, Any]) -> Dict[str, An
|
|
|
787
1295
|
"updatedAt": _normalize_iso8601(updated_at) if updated_at else None,
|
|
788
1296
|
"primary": _coerce_rate_limit_window(primary),
|
|
789
1297
|
"secondary": _coerce_rate_limit_window(secondary),
|
|
1298
|
+
"contextWindow": _coerce_context_window_snapshot(payload.get("info")),
|
|
790
1299
|
}
|
|
791
1300
|
|
|
792
1301
|
|
|
@@ -799,6 +1308,50 @@ def _coerce_rate_limit_window(window: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
799
1308
|
return {"usedPercent": used, "windowMinutes": window_mins, "resetsAt": resets_at}
|
|
800
1309
|
|
|
801
1310
|
|
|
1311
|
+
def _coerce_context_window_snapshot(info: Any) -> Dict[str, Any] | None:
|
|
1312
|
+
if not isinstance(info, dict):
|
|
1313
|
+
return None
|
|
1314
|
+
|
|
1315
|
+
window_tokens = _coerce_int(_pick(info, "model_context_window", "modelContextWindow"))
|
|
1316
|
+
if window_tokens is not None and window_tokens < 0:
|
|
1317
|
+
window_tokens = None
|
|
1318
|
+
|
|
1319
|
+
last_usage = info.get("last_token_usage")
|
|
1320
|
+
if not isinstance(last_usage, dict):
|
|
1321
|
+
last_usage = info.get("lastTokenUsage")
|
|
1322
|
+
total_usage = info.get("total_token_usage")
|
|
1323
|
+
if not isinstance(total_usage, dict):
|
|
1324
|
+
total_usage = info.get("totalTokenUsage")
|
|
1325
|
+
|
|
1326
|
+
used_tokens: int | None = None
|
|
1327
|
+
if isinstance(last_usage, dict):
|
|
1328
|
+
used_tokens = _coerce_int(_pick(last_usage, "input_tokens", "inputTokens"))
|
|
1329
|
+
if used_tokens is None and isinstance(total_usage, dict):
|
|
1330
|
+
used_tokens = _coerce_int(_pick(total_usage, "input_tokens", "inputTokens"))
|
|
1331
|
+
if used_tokens is not None and used_tokens < 0:
|
|
1332
|
+
used_tokens = 0
|
|
1333
|
+
|
|
1334
|
+
left_tokens: int | None = None
|
|
1335
|
+
used_percent: float | None = None
|
|
1336
|
+
if window_tokens is not None and used_tokens is not None:
|
|
1337
|
+
used_tokens = min(used_tokens, window_tokens)
|
|
1338
|
+
left_tokens = max(0, window_tokens - used_tokens)
|
|
1339
|
+
if window_tokens > 0:
|
|
1340
|
+
used_percent = (used_tokens / window_tokens) * 100.0
|
|
1341
|
+
else:
|
|
1342
|
+
used_percent = 0.0
|
|
1343
|
+
|
|
1344
|
+
if window_tokens is None and used_tokens is None and left_tokens is None:
|
|
1345
|
+
return None
|
|
1346
|
+
|
|
1347
|
+
return {
|
|
1348
|
+
"windowTokens": window_tokens,
|
|
1349
|
+
"usedTokens": used_tokens,
|
|
1350
|
+
"leftTokens": left_tokens,
|
|
1351
|
+
"usedPercent": used_percent,
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
|
|
802
1355
|
def _pick(d: Dict[str, Any], *keys: str) -> Any:
|
|
803
1356
|
for key in keys:
|
|
804
1357
|
if key in d:
|
|
@@ -970,6 +1523,7 @@ def _refresh_watch_paths(notebook_os_path: str) -> list[str]:
|
|
|
970
1523
|
|
|
971
1524
|
absolute = os.path.abspath(notebook_os_path)
|
|
972
1525
|
root, ext = os.path.splitext(absolute)
|
|
1526
|
+
ext = ext.lower()
|
|
973
1527
|
paths = [absolute]
|
|
974
1528
|
if ext == ".ipynb":
|
|
975
1529
|
paths.append(f"{root}.py")
|
|
@@ -978,62 +1532,149 @@ def _refresh_watch_paths(notebook_os_path: str) -> list[str]:
|
|
|
978
1532
|
return paths
|
|
979
1533
|
|
|
980
1534
|
|
|
981
|
-
def
|
|
1535
|
+
def _read_file_prefix_lines(
|
|
1536
|
+
path: str,
|
|
1537
|
+
*,
|
|
1538
|
+
max_lines: int = 240,
|
|
1539
|
+
max_chars: int = 128_000,
|
|
1540
|
+
) -> list[str]:
|
|
1541
|
+
if not path:
|
|
1542
|
+
return []
|
|
1543
|
+
|
|
1544
|
+
lines: list[str] = []
|
|
1545
|
+
total_chars = 0
|
|
1546
|
+
try:
|
|
1547
|
+
with open(path, "r", encoding="utf-8", errors="replace") as handle:
|
|
1548
|
+
for _ in range(max_lines):
|
|
1549
|
+
line = handle.readline()
|
|
1550
|
+
if not line:
|
|
1551
|
+
break
|
|
1552
|
+
lines.append(line)
|
|
1553
|
+
total_chars += len(line)
|
|
1554
|
+
if total_chars >= max_chars:
|
|
1555
|
+
break
|
|
1556
|
+
except OSError:
|
|
1557
|
+
return []
|
|
1558
|
+
|
|
1559
|
+
return lines
|
|
1560
|
+
|
|
1561
|
+
|
|
1562
|
+
def _has_jupytext_yaml_header(lines: list[str]) -> bool:
|
|
1563
|
+
if not lines:
|
|
1564
|
+
return False
|
|
1565
|
+
|
|
1566
|
+
idx = 0
|
|
1567
|
+
while idx < len(lines) and not lines[idx].strip():
|
|
1568
|
+
idx += 1
|
|
1569
|
+
if idx >= len(lines) or lines[idx].strip() != "# ---":
|
|
1570
|
+
return False
|
|
1571
|
+
|
|
1572
|
+
header_lines: list[str] = []
|
|
1573
|
+
for line in lines[idx + 1 : idx + 120]:
|
|
1574
|
+
stripped = line.strip()
|
|
1575
|
+
if stripped == "# ---":
|
|
1576
|
+
break
|
|
1577
|
+
# Jupytext YAML headers are comment blocks. Abort if code appears before closing marker.
|
|
1578
|
+
if stripped and not line.lstrip().startswith("#"):
|
|
1579
|
+
return False
|
|
1580
|
+
header_lines.append(line)
|
|
1581
|
+
|
|
1582
|
+
if not header_lines:
|
|
1583
|
+
return False
|
|
1584
|
+
|
|
1585
|
+
normalized = "\n".join(part.lstrip("#").strip().lower() for part in header_lines)
|
|
1586
|
+
return any(hint in normalized for hint in _PY_JUPYTEXT_HEADER_HINTS)
|
|
1587
|
+
|
|
1588
|
+
|
|
1589
|
+
def _detect_python_notebook_mode(notebook_os_path: str) -> str:
|
|
1590
|
+
lines = _read_file_prefix_lines(notebook_os_path)
|
|
1591
|
+
if not lines:
|
|
1592
|
+
return "plain_py"
|
|
1593
|
+
|
|
1594
|
+
if _has_jupytext_yaml_header(lines):
|
|
1595
|
+
return "jupytext_py"
|
|
1596
|
+
|
|
1597
|
+
if any(_PY_CELL_MARKER_RE.match(line) for line in lines):
|
|
1598
|
+
return "jupytext_py"
|
|
1599
|
+
|
|
1600
|
+
return "plain_py"
|
|
1601
|
+
|
|
1602
|
+
|
|
1603
|
+
def _compute_pairing_status(notebook_path: str, notebook_os_path: str) -> tuple[bool, str, str, str, str]:
|
|
982
1604
|
"""
|
|
983
|
-
|
|
1605
|
+
Determine run gating status and notebook mode.
|
|
984
1606
|
|
|
985
|
-
|
|
1607
|
+
Supported modes:
|
|
1608
|
+
- ipynb: requires a paired .py file to exist.
|
|
1609
|
+
- jupytext_py: .py file with Jupytext metadata/cell markers.
|
|
1610
|
+
- plain_py: regular .py script opened as a notebook.
|
|
986
1611
|
"""
|
|
987
1612
|
nb_path = (notebook_path or "").strip()
|
|
988
1613
|
nb_os_path = (notebook_os_path or "").strip()
|
|
1614
|
+
nb_path_lower = nb_path.lower()
|
|
1615
|
+
nb_os_path_lower = nb_os_path.lower()
|
|
989
1616
|
|
|
990
1617
|
paired_path = ""
|
|
991
|
-
if
|
|
1618
|
+
if nb_path_lower.endswith(".ipynb"):
|
|
992
1619
|
paired_path = nb_path[:-6] + ".py"
|
|
1620
|
+
elif nb_path_lower.endswith(".py"):
|
|
1621
|
+
paired_path = nb_path[:-3] + ".ipynb"
|
|
993
1622
|
|
|
994
1623
|
paired_os_path = ""
|
|
995
|
-
if
|
|
1624
|
+
if nb_os_path_lower.endswith(".ipynb"):
|
|
996
1625
|
paired_os_path = nb_os_path[:-6] + ".py"
|
|
1626
|
+
elif nb_os_path_lower.endswith(".py"):
|
|
1627
|
+
paired_os_path = nb_os_path[:-3] + ".ipynb"
|
|
997
1628
|
|
|
998
1629
|
# If we cannot resolve OS paths (e.g. non-local content manager), be conservative and block.
|
|
999
|
-
if
|
|
1630
|
+
if (nb_path_lower.endswith(".ipynb") or nb_os_path_lower.endswith(".ipynb")) and not paired_os_path:
|
|
1000
1631
|
return (
|
|
1001
1632
|
False,
|
|
1002
1633
|
paired_path,
|
|
1003
1634
|
"",
|
|
1004
1635
|
"Jupytext paired file is required, but the server could not resolve a local path for this notebook.",
|
|
1636
|
+
"ipynb",
|
|
1005
1637
|
)
|
|
1006
1638
|
|
|
1007
|
-
if
|
|
1639
|
+
if nb_path_lower.endswith(".ipynb") or nb_os_path_lower.endswith(".ipynb"):
|
|
1008
1640
|
exists = bool(paired_os_path) and os.path.isfile(paired_os_path)
|
|
1009
1641
|
if exists:
|
|
1010
|
-
return True, paired_path, paired_os_path, ""
|
|
1642
|
+
return True, paired_path, paired_os_path, "", "ipynb"
|
|
1011
1643
|
message = (
|
|
1012
1644
|
"Jupytext paired file not found. This extension requires a paired .py file.\n"
|
|
1013
1645
|
f"Expected: {paired_os_path or paired_path or '<notebook>.py'}"
|
|
1014
1646
|
)
|
|
1015
|
-
return False, paired_path, paired_os_path, message
|
|
1647
|
+
return False, paired_path, paired_os_path, message, "ipynb"
|
|
1648
|
+
|
|
1649
|
+
if nb_path_lower.endswith(".py") or nb_os_path_lower.endswith(".py"):
|
|
1650
|
+
notebook_mode = _detect_python_notebook_mode(nb_os_path)
|
|
1651
|
+
return True, paired_path, paired_os_path, "", notebook_mode
|
|
1016
1652
|
|
|
1017
1653
|
# Unknown/unsupported path types: block to avoid telling Codex to edit the wrong thing.
|
|
1018
1654
|
return (
|
|
1019
1655
|
False,
|
|
1020
1656
|
paired_path,
|
|
1021
1657
|
paired_os_path,
|
|
1022
|
-
"
|
|
1658
|
+
"Only .ipynb and .py notebook documents are supported.",
|
|
1659
|
+
"unsupported",
|
|
1023
1660
|
)
|
|
1024
1661
|
|
|
1025
1662
|
|
|
1026
|
-
def
|
|
1027
|
-
|
|
1663
|
+
def _capture_file_signatures(paths: list[str]) -> Dict[str, str | None]:
|
|
1664
|
+
signatures: Dict[str, str | None] = {}
|
|
1028
1665
|
for path in paths:
|
|
1029
1666
|
try:
|
|
1030
|
-
|
|
1667
|
+
digest = hashlib.sha256()
|
|
1668
|
+
with open(path, "rb") as handle:
|
|
1669
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
1670
|
+
digest.update(chunk)
|
|
1671
|
+
signatures[path] = digest.hexdigest()
|
|
1031
1672
|
except OSError:
|
|
1032
|
-
|
|
1033
|
-
return
|
|
1673
|
+
signatures[path] = None
|
|
1674
|
+
return signatures
|
|
1034
1675
|
|
|
1035
1676
|
|
|
1036
|
-
def _has_path_changes(before: Dict[str,
|
|
1677
|
+
def _has_path_changes(before: Dict[str, str | None], after: Dict[str, str | None]) -> bool:
|
|
1037
1678
|
keys = set(before.keys()) | set(after.keys())
|
|
1038
1679
|
for key in keys:
|
|
1039
1680
|
if before.get(key) != after.get(key):
|