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.
Files changed (139) hide show
  1. package/.claude/settings.local.json +9 -0
  2. package/.github/workflows/unit-tests.yml +27 -0
  3. package/AGENTS.md +42 -0
  4. package/README.md +67 -9
  5. package/docs/images/codex-sidebar-screenshot.png +0 -0
  6. package/jupyterlab_codex/handlers.py +938 -297
  7. package/jupyterlab_codex/labextension/package.json +13 -3
  8. package/jupyterlab_codex/labextension/static/525.224526d045c727069de6.js +2 -0
  9. package/jupyterlab_codex/labextension/static/855.d20f6158cd81bb4c9056.js +1 -0
  10. package/jupyterlab_codex/labextension/static/{remoteEntry.b2fdc03a1c4582e79156.js → remoteEntry.c1e865f207776f7f24ff.js} +1 -1
  11. package/jupyterlab_codex/protocol.py +297 -0
  12. package/jupyterlab_codex/runner.py +137 -31
  13. package/jupyterlab_codex/sessions.py +582 -97
  14. package/lib/codexChat.d.ts +13 -0
  15. package/lib/codexChat.js +2410 -0
  16. package/lib/codexChat.js.map +1 -0
  17. package/lib/codexChatAttachmentDedup.d.ts +10 -0
  18. package/lib/codexChatAttachmentDedup.js +35 -0
  19. package/lib/codexChatAttachmentDedup.js.map +1 -0
  20. package/lib/codexChatAttachmentLimit.d.ts +8 -0
  21. package/lib/codexChatAttachmentLimit.js +61 -0
  22. package/lib/codexChatAttachmentLimit.js.map +1 -0
  23. package/lib/codexChatDocumentUtils.d.ts +68 -0
  24. package/lib/codexChatDocumentUtils.js +480 -0
  25. package/lib/codexChatDocumentUtils.js.map +1 -0
  26. package/lib/codexChatFormatting.d.ts +11 -0
  27. package/lib/codexChatFormatting.js +83 -0
  28. package/lib/codexChatFormatting.js.map +1 -0
  29. package/lib/codexChatNotice.d.ts +3 -0
  30. package/lib/codexChatNotice.js +74 -0
  31. package/lib/codexChatNotice.js.map +1 -0
  32. package/lib/codexChatPersistence.d.ts +35 -0
  33. package/lib/codexChatPersistence.js +158 -0
  34. package/lib/codexChatPersistence.js.map +1 -0
  35. package/lib/codexChatPrimitives.d.ts +41 -0
  36. package/lib/codexChatPrimitives.js +152 -0
  37. package/lib/codexChatPrimitives.js.map +1 -0
  38. package/lib/codexChatRender.d.ts +24 -0
  39. package/lib/codexChatRender.js +293 -0
  40. package/lib/codexChatRender.js.map +1 -0
  41. package/lib/codexChatSessionFactory.d.ts +15 -0
  42. package/lib/codexChatSessionFactory.js +45 -0
  43. package/lib/codexChatSessionFactory.js.map +1 -0
  44. package/lib/codexChatSessionKey.d.ts +3 -0
  45. package/lib/codexChatSessionKey.js +14 -0
  46. package/lib/codexChatSessionKey.js.map +1 -0
  47. package/lib/codexChatStorage.d.ts +4 -0
  48. package/lib/codexChatStorage.js +37 -0
  49. package/lib/codexChatStorage.js.map +1 -0
  50. package/lib/codexSessionResolver.d.ts +12 -0
  51. package/lib/codexSessionResolver.js +38 -0
  52. package/lib/codexSessionResolver.js.map +1 -0
  53. package/lib/handlers/activitySummarizer.d.ts +15 -0
  54. package/lib/handlers/activitySummarizer.js +327 -0
  55. package/lib/handlers/activitySummarizer.js.map +1 -0
  56. package/lib/handlers/codexMessageTypes.d.ts +30 -0
  57. package/lib/handlers/codexMessageTypes.js +2 -0
  58. package/lib/handlers/codexMessageTypes.js.map +1 -0
  59. package/lib/handlers/codexMessageUtils.d.ts +46 -0
  60. package/lib/handlers/codexMessageUtils.js +144 -0
  61. package/lib/handlers/codexMessageUtils.js.map +1 -0
  62. package/lib/handlers/handleCodexSocketMessage.d.ts +107 -0
  63. package/lib/handlers/handleCodexSocketMessage.js +78 -0
  64. package/lib/handlers/handleCodexSocketMessage.js.map +1 -0
  65. package/lib/handlers/sessionSyncHandler.d.ts +34 -0
  66. package/lib/handlers/sessionSyncHandler.js +181 -0
  67. package/lib/handlers/sessionSyncHandler.js.map +1 -0
  68. package/lib/hooks/useCodexSocket.d.ts +15 -0
  69. package/lib/hooks/useCodexSocket.js +84 -0
  70. package/lib/hooks/useCodexSocket.js.map +1 -0
  71. package/lib/index.js +1 -1
  72. package/lib/index.js.map +1 -1
  73. package/lib/panel.d.ts +1 -11
  74. package/lib/panel.js +1 -2768
  75. package/lib/panel.js.map +1 -1
  76. package/lib/protocol.d.ts +235 -0
  77. package/lib/protocol.js +278 -0
  78. package/lib/protocol.js.map +1 -0
  79. package/package.json +13 -3
  80. package/playwright.config.cjs +24 -0
  81. package/playwright.unit.config.cjs +19 -0
  82. package/pyproject.toml +1 -1
  83. package/release.sh +243 -0
  84. package/scripts/run_playwright_e2e.sh +96 -0
  85. package/scripts/run_playwright_freeze_repro.sh +58 -0
  86. package/scripts/run_playwright_queue_repro.sh +60 -0
  87. package/scripts/run_playwright_repro.sh +55 -0
  88. package/src/codexChat.tsx +3755 -0
  89. package/src/codexChatAttachmentDedup.ts +47 -0
  90. package/src/codexChatAttachmentLimit.ts +82 -0
  91. package/src/codexChatDocumentUtils.ts +612 -0
  92. package/src/codexChatFormatting.ts +94 -0
  93. package/src/codexChatNotice.ts +95 -0
  94. package/src/codexChatPersistence.ts +191 -0
  95. package/src/codexChatPrimitives.tsx +422 -0
  96. package/src/codexChatRender.tsx +376 -0
  97. package/src/codexChatSessionFactory.ts +79 -0
  98. package/src/codexChatSessionKey.ts +16 -0
  99. package/src/codexChatStorage.ts +36 -0
  100. package/src/codexSessionResolver.ts +56 -0
  101. package/src/handlers/activitySummarizer.ts +369 -0
  102. package/src/handlers/codexMessageTypes.ts +34 -0
  103. package/src/handlers/codexMessageUtils.ts +217 -0
  104. package/src/handlers/handleCodexSocketMessage.ts +204 -0
  105. package/src/handlers/sessionSyncHandler.ts +308 -0
  106. package/src/hooks/useCodexSocket.ts +109 -0
  107. package/src/index.ts +1 -1
  108. package/src/panel.tsx +1 -4131
  109. package/src/protocol.ts +582 -0
  110. package/style/index.css +424 -11
  111. package/tests/e2e/fixtures/notebooks/tab1.ipynb +322 -0
  112. package/tests/e2e/fixtures/notebooks/tab1.py +272 -0
  113. package/tests/e2e/fixtures/notebooks/tab2.ipynb +252 -0
  114. package/tests/e2e/fixtures/notebooks/tab2.py +231 -0
  115. package/tests/e2e/fixtures/notebooks/tab3.ipynb +403 -0
  116. package/tests/e2e/fixtures/notebooks/tab3.py +331 -0
  117. package/tests/e2e/fixtures/notebooks/tab4.py +339 -0
  118. package/tests/e2e/freeze-notebook-tabs-repro.spec.js +295 -0
  119. package/tests/e2e/mock-codex-cli-flood.py +127 -0
  120. package/tests/e2e/mock-codex-cli.py +95 -0
  121. package/tests/e2e/queue-multitab-repro.spec.js +189 -0
  122. package/tests/test_handlers.py +116 -0
  123. package/tests/test_protocol.py +169 -0
  124. package/tests/test_session_store_limits.py +50 -0
  125. package/tests/unit/codexChatAttachmentDedup.spec.ts +56 -0
  126. package/tests/unit/codexChatAttachmentLimit.spec.ts +42 -0
  127. package/tests/unit/codexChatLimit.spec.ts +18 -0
  128. package/tests/unit/codexChatNotice.spec.ts +45 -0
  129. package/tests/unit/codexChatPersistence.spec.ts +199 -0
  130. package/tests/unit/codexChatSessionFactory.spec.ts +94 -0
  131. package/tests/unit/codexChatSessionKey.spec.ts +18 -0
  132. package/tests/unit/codexMessageUtils.spec.ts +89 -0
  133. package/tests/unit/codexSessionResolver.spec.ts +92 -0
  134. package/tests/unit/handleCodexSocketMessage.spec.ts +476 -0
  135. package/tsconfig.tsbuildinfo +1 -1
  136. package/webpack.config.js +6 -0
  137. package/jupyterlab_codex/labextension/static/504.335f3447c84ba3d74517.js +0 -2
  138. package/jupyterlab_codex/labextension/static/972.d43137b7438a053eeb72.js +0 -1
  139. /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.write_message(json.dumps({"type": "status", "state": "ready"}))
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.write_message(json.dumps({"type": "error", "message": "Invalid JSON"}))
211
+ self._safe_write_message(json.dumps(build_error_payload(message="Invalid JSON")))
110
212
  return
111
213
 
112
- msg_type = payload.get("type")
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(payload)
221
+ await self._handle_start_session(normalized_payload)
116
222
  return
117
223
 
118
224
  if msg_type == "send":
119
- await self._handle_send(payload)
225
+ await self._handle_send(normalized_payload)
120
226
  return
121
227
 
122
228
  if msg_type == "delete_session":
123
- self._handle_delete_session(payload)
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(payload)
233
+ self._handle_delete_all_sessions(normalized_payload)
128
234
  return
129
235
 
130
236
  if msg_type == "cancel":
131
- await self._handle_cancel(payload)
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(payload)
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.write_message(json.dumps({"type": "error", "message": "Unknown message type"}))
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.write_message(json.dumps({"type": "cli_defaults", **defaults}))
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.write_message(json.dumps({"type": "cli_defaults", "availableModels": models}))
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") or ""
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
- previous_session_id = self._store.resolve_session_for_notebook(notebook_path, notebook_os_path)
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
- resolved_session_id = self._store.resolve_session_for_notebook(notebook_path, notebook_os_path) or resolved_session_id
192
- if not resolved_session_id:
193
- resolved_session_id = str(uuid.uuid4())
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
- history.append({"role": role, "content": content})
209
-
210
- paired_ok, paired_path, paired_os_path, paired_message = _compute_pairing_status(
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
- self.write_message(
214
- json.dumps(
215
- {
216
- "type": "status",
217
- "state": "ready",
218
- "sessionId": resolved_session_id,
219
- "notebookPath": notebook_path,
220
- "sessionContextKey": session_context_key,
221
- "history": history,
222
- "pairedOk": paired_ok,
223
- "pairedPath": paired_path,
224
- "pairedOsPath": paired_os_path,
225
- "pairedMessage": paired_message,
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.write_message(json.dumps({"type": "error", "message": "Empty content"}))
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.write_message(json.dumps({"type": "error", "message": "Invalid model name"}))
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.write_message(json.dumps({"type": "error", "message": "Invalid reasoning level"}))
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.write_message(json.dumps({"type": "error", "message": "Invalid sandbox mode"}))
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.write_message(json.dumps({"type": "error", "message": "Invalid images payload"}))
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.write_message(json.dumps({"type": "error", "message": "Too many images attached"}))
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.write_message(json.dumps({"type": "error", "message": "Invalid images payload"}))
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.write_message(json.dumps({"type": "error", "message": "Invalid images payload"}))
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.write_message(
549
+ self._safe_write_message(
290
550
  json.dumps(
291
- {
292
- "type": "error",
293
- "runId": run_id,
294
- "sessionId": session_id,
295
- "sessionContextKey": session_context_key,
296
- "notebookPath": notebook_path,
297
- "message": paired_message or "Jupytext paired file is required for this extension.",
298
- "pairedOk": paired_ok,
299
- "pairedPath": paired_path,
300
- "pairedOsPath": paired_os_path,
301
- "pairedMessage": paired_message,
302
- }
303
- )
304
- )
305
- self.write_message(
306
- json.dumps(
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
- before_mtimes = _capture_mtimes(watch_paths)
334
-
335
- prompt = self._store.build_prompt(session_id, content, selection, cell_output, cwd=cwd)
336
- self._store.append_message(session_id, "user", content)
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
- self.write_message(
340
- json.dumps(
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
- self.write_message(
365
- json.dumps(
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
- self.write_message(
718
+ _flush_pending_output(force=True)
719
+ self._safe_write_message(
382
720
  json.dumps(
383
- {
384
- "type": "event",
385
- "runId": run_id,
386
- "sessionId": session_id,
387
- "sessionContextKey": session_context_key,
388
- "notebookPath": notebook_path,
389
- "payload": event,
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 = await self._runner.run(
412
- prompt,
413
- on_event,
414
- cwd=cwd,
415
- model=requested_model,
416
- reasoning_effort=requested_reasoning,
417
- sandbox=requested_sandbox,
418
- command=requested_command_path,
419
- images=image_paths,
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
- file_changed = _has_path_changes(before_mtimes, _capture_mtimes(watch_paths))
424
- self.write_message(
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
- "type": "done",
428
- "runId": run_id,
429
- "sessionId": session_id,
430
- "sessionContextKey": session_context_key,
431
- "notebookPath": notebook_path,
432
- "exitCode": exit_code,
433
- "fileChanged": file_changed,
434
- "pairedOk": paired_ok,
435
- "pairedPath": paired_path,
436
- "pairedOsPath": paired_os_path,
437
- "pairedMessage": paired_message,
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.write_message(
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
- file_changed = _has_path_changes(before_mtimes, _capture_mtimes(watch_paths))
459
- self.write_message(
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
- "type": "done",
463
- "runId": run_id,
464
- "sessionId": session_id,
465
- "sessionContextKey": session_context_key,
466
- "notebookPath": notebook_path,
467
- "exitCode": None,
468
- "cancelled": True,
469
- "fileChanged": file_changed,
470
- "pairedOk": paired_ok,
471
- "pairedPath": paired_path,
472
- "pairedOsPath": paired_os_path,
473
- "pairedMessage": paired_message,
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.write_message(
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
- raise
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
- error_payload = {
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
- "type": "status",
515
- "state": "ready",
516
- "runId": run_id,
517
- "sessionId": session_id,
518
- "sessionContextKey": session_context_key,
519
- "notebookPath": notebook_path,
520
- "pairedOk": paired_ok,
521
- "pairedPath": paired_path,
522
- "pairedOsPath": paired_os_path,
523
- "pairedMessage": paired_message,
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
- self.write_message(
929
+ _append_user_message_once()
930
+ _flush_pending_output(force=True)
931
+ self._safe_write_message(
529
932
  json.dumps(
530
- {
531
- "type": "error",
532
- "runId": run_id,
533
- "sessionId": session_id,
534
- "sessionContextKey": session_context_key,
535
- "notebookPath": notebook_path,
536
- "message": str(exc),
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.write_message(
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
- response["deletedCount"] = deleted_count
592
- response["failedCount"] = failed_count
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
- response["message"] = f"Deleted {deleted_count} conversations, failed to delete {failed_count}"
982
+ message = f"Deleted {deleted_count} conversations, failed to delete {failed_count}"
599
983
  except Exception as exc: # pragma: no cover - defensive path
600
- response["message"] = str(exc)
984
+ ok = False
985
+ deleted_count = 0
986
+ failed_count = 1
987
+ message = str(exc)
601
988
 
602
989
  try:
603
- self.write_message(json.dumps(response))
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.write_message(json.dumps({"type": "rate_limits", "snapshot": snapshot}))
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.write_message(json.dumps({"type": "error", "message": "Run not found"}))
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
- self.write_message(
631
- json.dumps(
632
- {
633
- "type": "status",
634
- "state": "ready",
635
- "runId": run_id,
636
- "sessionId": run_context["sessionId"],
637
- "sessionContextKey": session_context_key,
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.write_message(json.dumps({"type": "status", "state": "ready"}))
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 _compute_pairing_status(notebook_path: str, notebook_os_path: str) -> tuple[bool, str, str, str]:
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
- This extension assumes a Jupytext paired workflow: <notebook>.ipynb <-> <notebook>.py.
1605
+ Determine run gating status and notebook mode.
984
1606
 
985
- We treat the session as "paired" only when the derived paired file exists on disk.
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 nb_path.endswith(".ipynb"):
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 nb_os_path.endswith(".ipynb"):
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 nb_path.endswith(".ipynb") and not paired_os_path:
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 nb_path.endswith(".ipynb"):
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
- "Jupytext paired workflow is required, but this file type is not supported.",
1658
+ "Only .ipynb and .py notebook documents are supported.",
1659
+ "unsupported",
1023
1660
  )
1024
1661
 
1025
1662
 
1026
- def _capture_mtimes(paths: list[str]) -> Dict[str, float | None]:
1027
- mtimes: Dict[str, float | None] = {}
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
- mtimes[path] = os.path.getmtime(path)
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
- mtimes[path] = None
1033
- return mtimes
1673
+ signatures[path] = None
1674
+ return signatures
1034
1675
 
1035
1676
 
1036
- def _has_path_changes(before: Dict[str, float | None], after: Dict[str, float | None]) -> bool:
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):