jupyterlab-codex-sidebar 0.1.4 → 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 (138) hide show
  1. package/.claude/settings.local.json +9 -0
  2. package/.github/workflows/unit-tests.yml +27 -0
  3. package/README.md +67 -9
  4. package/docs/images/codex-sidebar-screenshot.png +0 -0
  5. package/jupyterlab_codex/handlers.py +938 -297
  6. package/jupyterlab_codex/labextension/package.json +13 -3
  7. package/jupyterlab_codex/labextension/static/525.224526d045c727069de6.js +2 -0
  8. package/jupyterlab_codex/labextension/static/855.d20f6158cd81bb4c9056.js +1 -0
  9. package/jupyterlab_codex/labextension/static/{remoteEntry.a2982f776a1f0f515640.js → remoteEntry.c1e865f207776f7f24ff.js} +1 -1
  10. package/jupyterlab_codex/protocol.py +297 -0
  11. package/jupyterlab_codex/runner.py +58 -15
  12. package/jupyterlab_codex/sessions.py +582 -97
  13. package/lib/codexChat.d.ts +13 -0
  14. package/lib/codexChat.js +2410 -0
  15. package/lib/codexChat.js.map +1 -0
  16. package/lib/codexChatAttachmentDedup.d.ts +10 -0
  17. package/lib/codexChatAttachmentDedup.js +35 -0
  18. package/lib/codexChatAttachmentDedup.js.map +1 -0
  19. package/lib/codexChatAttachmentLimit.d.ts +8 -0
  20. package/lib/codexChatAttachmentLimit.js +61 -0
  21. package/lib/codexChatAttachmentLimit.js.map +1 -0
  22. package/lib/codexChatDocumentUtils.d.ts +68 -0
  23. package/lib/codexChatDocumentUtils.js +480 -0
  24. package/lib/codexChatDocumentUtils.js.map +1 -0
  25. package/lib/codexChatFormatting.d.ts +11 -0
  26. package/lib/codexChatFormatting.js +83 -0
  27. package/lib/codexChatFormatting.js.map +1 -0
  28. package/lib/codexChatNotice.d.ts +3 -0
  29. package/lib/codexChatNotice.js +74 -0
  30. package/lib/codexChatNotice.js.map +1 -0
  31. package/lib/codexChatPersistence.d.ts +35 -0
  32. package/lib/codexChatPersistence.js +158 -0
  33. package/lib/codexChatPersistence.js.map +1 -0
  34. package/lib/codexChatPrimitives.d.ts +41 -0
  35. package/lib/codexChatPrimitives.js +152 -0
  36. package/lib/codexChatPrimitives.js.map +1 -0
  37. package/lib/codexChatRender.d.ts +24 -0
  38. package/lib/codexChatRender.js +293 -0
  39. package/lib/codexChatRender.js.map +1 -0
  40. package/lib/codexChatSessionFactory.d.ts +15 -0
  41. package/lib/codexChatSessionFactory.js +45 -0
  42. package/lib/codexChatSessionFactory.js.map +1 -0
  43. package/lib/codexChatSessionKey.d.ts +3 -0
  44. package/lib/codexChatSessionKey.js +14 -0
  45. package/lib/codexChatSessionKey.js.map +1 -0
  46. package/lib/codexChatStorage.d.ts +4 -0
  47. package/lib/codexChatStorage.js +37 -0
  48. package/lib/codexChatStorage.js.map +1 -0
  49. package/lib/codexSessionResolver.d.ts +12 -0
  50. package/lib/codexSessionResolver.js +38 -0
  51. package/lib/codexSessionResolver.js.map +1 -0
  52. package/lib/handlers/activitySummarizer.d.ts +15 -0
  53. package/lib/handlers/activitySummarizer.js +327 -0
  54. package/lib/handlers/activitySummarizer.js.map +1 -0
  55. package/lib/handlers/codexMessageTypes.d.ts +30 -0
  56. package/lib/handlers/codexMessageTypes.js +2 -0
  57. package/lib/handlers/codexMessageTypes.js.map +1 -0
  58. package/lib/handlers/codexMessageUtils.d.ts +46 -0
  59. package/lib/handlers/codexMessageUtils.js +144 -0
  60. package/lib/handlers/codexMessageUtils.js.map +1 -0
  61. package/lib/handlers/handleCodexSocketMessage.d.ts +107 -0
  62. package/lib/handlers/handleCodexSocketMessage.js +78 -0
  63. package/lib/handlers/handleCodexSocketMessage.js.map +1 -0
  64. package/lib/handlers/sessionSyncHandler.d.ts +34 -0
  65. package/lib/handlers/sessionSyncHandler.js +181 -0
  66. package/lib/handlers/sessionSyncHandler.js.map +1 -0
  67. package/lib/hooks/useCodexSocket.d.ts +15 -0
  68. package/lib/hooks/useCodexSocket.js +84 -0
  69. package/lib/hooks/useCodexSocket.js.map +1 -0
  70. package/lib/index.js +1 -1
  71. package/lib/index.js.map +1 -1
  72. package/lib/panel.d.ts +1 -11
  73. package/lib/panel.js +1 -2815
  74. package/lib/panel.js.map +1 -1
  75. package/lib/protocol.d.ts +235 -0
  76. package/lib/protocol.js +278 -0
  77. package/lib/protocol.js.map +1 -0
  78. package/package.json +13 -3
  79. package/playwright.config.cjs +24 -0
  80. package/playwright.unit.config.cjs +19 -0
  81. package/pyproject.toml +1 -1
  82. package/release.sh +52 -14
  83. package/scripts/run_playwright_e2e.sh +96 -0
  84. package/scripts/run_playwright_freeze_repro.sh +58 -0
  85. package/scripts/run_playwright_queue_repro.sh +60 -0
  86. package/scripts/run_playwright_repro.sh +55 -0
  87. package/src/codexChat.tsx +3755 -0
  88. package/src/codexChatAttachmentDedup.ts +47 -0
  89. package/src/codexChatAttachmentLimit.ts +82 -0
  90. package/src/codexChatDocumentUtils.ts +612 -0
  91. package/src/codexChatFormatting.ts +94 -0
  92. package/src/codexChatNotice.ts +95 -0
  93. package/src/codexChatPersistence.ts +191 -0
  94. package/src/codexChatPrimitives.tsx +422 -0
  95. package/src/codexChatRender.tsx +376 -0
  96. package/src/codexChatSessionFactory.ts +79 -0
  97. package/src/codexChatSessionKey.ts +16 -0
  98. package/src/codexChatStorage.ts +36 -0
  99. package/src/codexSessionResolver.ts +56 -0
  100. package/src/handlers/activitySummarizer.ts +369 -0
  101. package/src/handlers/codexMessageTypes.ts +34 -0
  102. package/src/handlers/codexMessageUtils.ts +217 -0
  103. package/src/handlers/handleCodexSocketMessage.ts +204 -0
  104. package/src/handlers/sessionSyncHandler.ts +308 -0
  105. package/src/hooks/useCodexSocket.ts +109 -0
  106. package/src/index.ts +1 -1
  107. package/src/panel.tsx +1 -4184
  108. package/src/protocol.ts +582 -0
  109. package/style/index.css +424 -11
  110. package/tests/e2e/fixtures/notebooks/tab1.ipynb +322 -0
  111. package/tests/e2e/fixtures/notebooks/tab1.py +272 -0
  112. package/tests/e2e/fixtures/notebooks/tab2.ipynb +252 -0
  113. package/tests/e2e/fixtures/notebooks/tab2.py +231 -0
  114. package/tests/e2e/fixtures/notebooks/tab3.ipynb +403 -0
  115. package/tests/e2e/fixtures/notebooks/tab3.py +331 -0
  116. package/tests/e2e/fixtures/notebooks/tab4.py +339 -0
  117. package/tests/e2e/freeze-notebook-tabs-repro.spec.js +295 -0
  118. package/tests/e2e/mock-codex-cli-flood.py +127 -0
  119. package/tests/e2e/mock-codex-cli.py +95 -0
  120. package/tests/e2e/queue-multitab-repro.spec.js +189 -0
  121. package/tests/test_handlers.py +116 -0
  122. package/tests/test_protocol.py +169 -0
  123. package/tests/test_session_store_limits.py +50 -0
  124. package/tests/unit/codexChatAttachmentDedup.spec.ts +56 -0
  125. package/tests/unit/codexChatAttachmentLimit.spec.ts +42 -0
  126. package/tests/unit/codexChatLimit.spec.ts +18 -0
  127. package/tests/unit/codexChatNotice.spec.ts +45 -0
  128. package/tests/unit/codexChatPersistence.spec.ts +199 -0
  129. package/tests/unit/codexChatSessionFactory.spec.ts +94 -0
  130. package/tests/unit/codexChatSessionKey.spec.ts +18 -0
  131. package/tests/unit/codexMessageUtils.spec.ts +89 -0
  132. package/tests/unit/codexSessionResolver.spec.ts +92 -0
  133. package/tests/unit/handleCodexSocketMessage.spec.ts +476 -0
  134. package/tsconfig.tsbuildinfo +1 -1
  135. package/webpack.config.js +6 -0
  136. package/jupyterlab_codex/labextension/static/504.335f3447c84ba3d74517.js +0 -2
  137. package/jupyterlab_codex/labextension/static/972.8e856719e40acc1ef4cb.js +0 -1
  138. /package/jupyterlab_codex/labextension/static/{504.335f3447c84ba3d74517.js.LICENSE.txt → 525.224526d045c727069de6.js.LICENSE.txt} +0 -0
@@ -0,0 +1,297 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, Literal, Tuple
4
+
5
+ ProtocolVersion = Literal["1.0.0"]
6
+
7
+ PROTOCOL_VERSION: ProtocolVersion = "1.0.0"
8
+
9
+
10
+ class ProtocolParseError(ValueError):
11
+ """Raised when an incoming client payload cannot be interpreted."""
12
+
13
+
14
+ def _coerce_string(value: Any) -> str:
15
+ if not isinstance(value, str):
16
+ return ""
17
+ return value.strip()
18
+
19
+
20
+ def _coerce_string_untrimmed(value: Any) -> str:
21
+ if not isinstance(value, str):
22
+ return ""
23
+ return value
24
+
25
+
26
+ def _coerce_bool(value: Any) -> bool:
27
+ if isinstance(value, bool):
28
+ return value
29
+ if isinstance(value, str):
30
+ return value.strip().lower() in {"1", "true", "y", "yes", "on"}
31
+ return False
32
+
33
+
34
+ def parse_client_message(raw: Any) -> Tuple[str, Dict[str, Any]]:
35
+ """
36
+ Parse and normalize incoming websocket payloads.
37
+
38
+ Returns (msg_type, payload) with normalized string/bool fields.
39
+ """
40
+ if not isinstance(raw, dict):
41
+ raise ProtocolParseError("Invalid message payload")
42
+
43
+ msg_type = raw.get("type")
44
+ if msg_type == "start_session":
45
+ return (
46
+ "start_session",
47
+ {
48
+ "sessionId": _coerce_string(raw.get("sessionId")),
49
+ "notebookPath": _coerce_string(raw.get("notebookPath")),
50
+ "sessionContextKey": _coerce_string(raw.get("sessionContextKey")),
51
+ "forceNewThread": _coerce_bool(raw.get("forceNewThread")),
52
+ "commandPath": _coerce_string(raw.get("commandPath")),
53
+ },
54
+ )
55
+
56
+ if msg_type == "send":
57
+ return (
58
+ "send",
59
+ {
60
+ "sessionId": _coerce_string(raw.get("sessionId")),
61
+ "sessionContextKey": _coerce_string(raw.get("sessionContextKey")),
62
+ "content": _coerce_string(raw.get("content")),
63
+ "notebookPath": _coerce_string(raw.get("notebookPath")),
64
+ "commandPath": _coerce_string(raw.get("commandPath")),
65
+ "model": _coerce_string(raw.get("model")),
66
+ "reasoningEffort": _coerce_string(raw.get("reasoningEffort")),
67
+ "sandbox": _coerce_string(raw.get("sandbox")),
68
+ "selection": _coerce_string_untrimmed(raw.get("selection")),
69
+ "cellOutput": _coerce_string_untrimmed(raw.get("cellOutput")),
70
+ "images": raw.get("images") if isinstance(raw.get("images"), list) else [],
71
+ "uiSelectionPreview": raw.get("uiSelectionPreview"),
72
+ "uiCellOutputPreview": raw.get("uiCellOutputPreview"),
73
+ "selectionTruncated": _coerce_bool(raw.get("selectionTruncated")),
74
+ "cellOutputTruncated": _coerce_bool(raw.get("cellOutputTruncated")),
75
+ },
76
+ )
77
+
78
+ if msg_type == "delete_session":
79
+ return ("delete_session", {"sessionId": _coerce_string(raw.get("sessionId"))})
80
+
81
+ if msg_type == "delete_all_sessions":
82
+ return ("delete_all_sessions", {})
83
+
84
+ if msg_type == "cancel":
85
+ return ("cancel", {"runId": _coerce_string(raw.get("runId"))})
86
+
87
+ if msg_type == "end_session":
88
+ return ("end_session", {"sessionId": _coerce_string(raw.get("sessionId"))})
89
+
90
+ if msg_type == "refresh_rate_limits":
91
+ return ("refresh_rate_limits", {})
92
+
93
+ raise ProtocolParseError("Unknown message type")
94
+
95
+
96
+ def _build_base_message(msg_type: str) -> Dict[str, Any]:
97
+ return {"type": msg_type, "protocolVersion": PROTOCOL_VERSION}
98
+
99
+
100
+ def build_cli_defaults_payload(
101
+ *,
102
+ model: str | None = None,
103
+ reasoning_effort: str | None = None,
104
+ available_models: list[dict[str, Any]] | None = None,
105
+ ) -> Dict[str, Any]:
106
+ payload = _build_base_message("cli_defaults")
107
+ if model is not None:
108
+ payload["model"] = model
109
+ if reasoning_effort is not None:
110
+ payload["reasoningEffort"] = reasoning_effort
111
+ if available_models is not None:
112
+ payload["availableModels"] = available_models
113
+ return payload
114
+
115
+
116
+ def build_status_payload(
117
+ *,
118
+ state: str,
119
+ run_id: str | None = None,
120
+ session_id: str | None = None,
121
+ session_context_key: str | None = None,
122
+ notebook_path: str | None = None,
123
+ run_mode: str | None = None,
124
+ paired_ok: bool | None = None,
125
+ paired_path: str = "",
126
+ paired_os_path: str = "",
127
+ paired_message: str = "",
128
+ notebook_mode: str = "",
129
+ effective_sandbox: str | None = None,
130
+ history: list[dict[str, Any]] | None = None,
131
+ session_resolution: str | None = None,
132
+ session_resolution_notice: str | None = None,
133
+ ) -> Dict[str, Any]:
134
+ payload = _build_base_message("status")
135
+ payload["state"] = state
136
+
137
+ if run_id:
138
+ payload["runId"] = run_id
139
+ if session_id:
140
+ payload["sessionId"] = session_id
141
+ if session_context_key:
142
+ payload["sessionContextKey"] = session_context_key
143
+ if notebook_path:
144
+ payload["notebookPath"] = notebook_path
145
+ if run_mode:
146
+ payload["runMode"] = run_mode
147
+ if paired_ok is not None:
148
+ payload["pairedOk"] = paired_ok
149
+ if paired_path:
150
+ payload["pairedPath"] = paired_path
151
+ if paired_os_path:
152
+ payload["pairedOsPath"] = paired_os_path
153
+ if paired_message:
154
+ payload["pairedMessage"] = paired_message
155
+ if notebook_mode:
156
+ payload["notebookMode"] = notebook_mode
157
+ if history is not None:
158
+ payload["history"] = history
159
+ if session_resolution is not None:
160
+ payload["sessionResolution"] = session_resolution
161
+ if session_resolution_notice:
162
+ payload["sessionResolutionNotice"] = session_resolution_notice
163
+ if effective_sandbox:
164
+ payload["effectiveSandbox"] = effective_sandbox
165
+ return payload
166
+
167
+
168
+ def build_output_payload(
169
+ *,
170
+ run_id: str,
171
+ session_id: str,
172
+ session_context_key: str,
173
+ notebook_path: str,
174
+ text: str,
175
+ role: str = "assistant",
176
+ ) -> Dict[str, Any]:
177
+ return _build_base_message("output") | {
178
+ "runId": run_id,
179
+ "sessionId": session_id,
180
+ "sessionContextKey": session_context_key,
181
+ "notebookPath": notebook_path,
182
+ "text": text,
183
+ "role": role,
184
+ }
185
+
186
+
187
+ def build_event_payload(
188
+ *,
189
+ run_id: str,
190
+ session_id: str,
191
+ session_context_key: str,
192
+ notebook_path: str,
193
+ payload: Dict[str, Any],
194
+ ) -> Dict[str, Any]:
195
+ return _build_base_message("event") | {
196
+ "runId": run_id,
197
+ "sessionId": session_id,
198
+ "sessionContextKey": session_context_key,
199
+ "notebookPath": notebook_path,
200
+ "payload": payload,
201
+ }
202
+
203
+
204
+ def build_done_payload(
205
+ *,
206
+ run_id: str,
207
+ session_id: str,
208
+ session_context_key: str,
209
+ notebook_path: str,
210
+ exit_code: int | None,
211
+ file_changed: bool,
212
+ run_mode: str,
213
+ paired_ok: bool,
214
+ paired_path: str,
215
+ paired_os_path: str,
216
+ paired_message: str,
217
+ notebook_mode: str,
218
+ cancelled: bool = False,
219
+ ) -> Dict[str, Any]:
220
+ payload = _build_base_message("done") | {
221
+ "runId": run_id,
222
+ "sessionId": session_id,
223
+ "sessionContextKey": session_context_key,
224
+ "notebookPath": notebook_path,
225
+ "exitCode": exit_code,
226
+ "fileChanged": file_changed,
227
+ "runMode": run_mode,
228
+ "pairedOk": paired_ok,
229
+ }
230
+ payload["pairedPath"] = paired_path
231
+ payload["pairedOsPath"] = paired_os_path
232
+ payload["pairedMessage"] = paired_message
233
+ payload["notebookMode"] = notebook_mode
234
+ if cancelled:
235
+ payload["cancelled"] = True
236
+ return payload
237
+
238
+
239
+ def build_error_payload(
240
+ *,
241
+ run_id: str | None = None,
242
+ session_id: str | None = None,
243
+ session_context_key: str = "",
244
+ notebook_path: str = "",
245
+ message: str,
246
+ run_mode: str | None = None,
247
+ suggested_command_path: str | None = None,
248
+ paired_ok: bool | None = None,
249
+ paired_path: str = "",
250
+ paired_os_path: str = "",
251
+ paired_message: str = "",
252
+ notebook_mode: str = "",
253
+ ) -> Dict[str, Any]:
254
+ payload = _build_base_message("error")
255
+ if run_id:
256
+ payload["runId"] = run_id
257
+ if session_id:
258
+ payload["sessionId"] = session_id
259
+ if session_context_key:
260
+ payload["sessionContextKey"] = session_context_key
261
+ if notebook_path:
262
+ payload["notebookPath"] = notebook_path
263
+ payload["message"] = message
264
+ if run_mode is not None:
265
+ payload["runMode"] = run_mode
266
+ if suggested_command_path:
267
+ payload["suggestedCommandPath"] = suggested_command_path
268
+ if paired_ok is not None:
269
+ payload["pairedOk"] = paired_ok
270
+ if paired_path:
271
+ payload["pairedPath"] = paired_path
272
+ if paired_os_path:
273
+ payload["pairedOsPath"] = paired_os_path
274
+ if paired_message:
275
+ payload["pairedMessage"] = paired_message
276
+ if notebook_mode:
277
+ payload["notebookMode"] = notebook_mode
278
+ return payload
279
+
280
+
281
+ def build_rate_limits_payload(snapshot: Any) -> Dict[str, Any]:
282
+ return _build_base_message("rate_limits") | {"snapshot": snapshot}
283
+
284
+
285
+ def build_delete_all_payload(
286
+ *,
287
+ ok: bool,
288
+ deleted_count: int,
289
+ failed_count: int,
290
+ message: str,
291
+ ) -> Dict[str, Any]:
292
+ payload = _build_base_message("delete_all_sessions")
293
+ payload["ok"] = ok
294
+ payload["deletedCount"] = deleted_count
295
+ payload["failedCount"] = failed_count
296
+ payload["message"] = message
297
+ return payload
@@ -11,6 +11,7 @@ class CodexRunner:
11
11
  def __init__(self, command: str = "codex", args: List[str] | None = None):
12
12
  configured_command = os.environ.get("JUPYTERLAB_CODEX_COMMAND", "").strip()
13
13
  self._command = self._resolve_command(configured_command or command)
14
+ self._model_catalog_cache: dict[str, tuple[float, list[dict[str, Any]]]] = {}
14
15
  if args is not None:
15
16
  self._raw_args = list(args)
16
17
  self._common_args: List[str] = []
@@ -32,24 +33,24 @@ class CodexRunner:
32
33
  "never",
33
34
  "--skip-git-repo-check",
34
35
  ]
35
- self._model_catalog_cache: list[dict[str, Any]] = []
36
- self._model_catalog_cache_time = 0.0
37
36
 
38
- async def list_available_models(self, command: str | None = None) -> list[dict[str, Any]]:
37
+ async def list_available_models(
38
+ self, command: str | None = None, force_refresh: bool = False
39
+ ) -> list[dict[str, Any]]:
40
+ command_to_run = self._resolve_command((command or "").strip() or self._command)
39
41
  now = time.monotonic()
40
- if self._model_catalog_cache and now - self._model_catalog_cache_time < 600:
41
- return list(self._model_catalog_cache)
42
+ cached = self._model_catalog_cache.get(command_to_run)
43
+ if not force_refresh and cached and now - cached[0] < 600:
44
+ return list(cached[1])
42
45
 
43
- command_to_run = self._resolve_command((command or "").strip() or self._command)
44
46
  try:
45
47
  models = await self._load_available_models(command_to_run)
46
48
  except Exception:
47
- return []
49
+ return list(cached[1]) if cached else []
48
50
 
49
51
  if not isinstance(models, list) or not models:
50
- return []
51
- self._model_catalog_cache = models
52
- self._model_catalog_cache_time = now
52
+ return list(cached[1]) if cached else []
53
+ self._model_catalog_cache[command_to_run] = (now, models)
53
54
  return list(models)
54
55
 
55
56
  async def _load_available_models(self, command_to_run: str) -> list[dict[str, Any]]:
@@ -60,9 +61,9 @@ class CodexRunner:
60
61
  "stdio://",
61
62
  stdin=asyncio.subprocess.PIPE,
62
63
  stdout=asyncio.subprocess.PIPE,
63
- stderr=asyncio.subprocess.PIPE,
64
+ stderr=asyncio.subprocess.DEVNULL,
64
65
  )
65
- if proc.stdin is None or proc.stdout is None or proc.stderr is None:
66
+ if proc.stdin is None or proc.stdout is None:
66
67
  raise RuntimeError("Failed to open app-server subprocess streams")
67
68
 
68
69
  buffer = bytearray()
@@ -275,10 +276,15 @@ class CodexRunner:
275
276
  sandbox: str | None = None,
276
277
  images: List[str] | None = None,
277
278
  command: str | None = None,
279
+ resume_session_id: str | None = None,
278
280
  ) -> int:
279
281
  command_to_run = self._resolve_command((command or "").strip() or self._command)
280
282
  args = self._args_for_options(
281
- model=model, reasoning_effort=reasoning_effort, sandbox=sandbox, images=images
283
+ model=model,
284
+ reasoning_effort=reasoning_effort,
285
+ sandbox=sandbox,
286
+ images=images,
287
+ resume_session_id=resume_session_id,
282
288
  )
283
289
 
284
290
  proc = await asyncio.create_subprocess_exec(
@@ -371,11 +377,13 @@ class CodexRunner:
371
377
  reasoning_effort: str | None,
372
378
  sandbox: str | None,
373
379
  images: List[str] | None,
380
+ resume_session_id: str | None = None,
374
381
  ) -> List[str]:
375
382
  requested_model = (model or "").strip()
376
383
  requested_reasoning_effort = (reasoning_effort or "").strip()
377
384
  requested_sandbox = (sandbox or "").strip()
378
385
  requested_images = [p for p in (images or []) if isinstance(p, str) and p.strip()]
386
+ requested_resume_session_id = (resume_session_id or "").strip()
379
387
 
380
388
  if self._raw_args is not None:
381
389
  args = list(self._raw_args)
@@ -399,7 +407,17 @@ class CodexRunner:
399
407
  cleaned.append(token)
400
408
  idx += 1
401
409
 
402
- insertion_index = cleaned.index("-") if "-" in cleaned else len(cleaned)
410
+ stdin_insertion_index = cleaned.index("-") if "-" in cleaned else len(cleaned)
411
+ if requested_resume_session_id and "resume" not in cleaned:
412
+ cleaned[stdin_insertion_index:stdin_insertion_index] = [
413
+ "resume",
414
+ requested_resume_session_id,
415
+ ]
416
+ options_insertion_index = (
417
+ cleaned.index("resume")
418
+ if "resume" in cleaned
419
+ else (cleaned.index("-") if "-" in cleaned else len(cleaned))
420
+ )
403
421
  to_insert: List[str] = []
404
422
  if requested_images:
405
423
  to_insert.extend(["--image", *requested_images])
@@ -410,11 +428,36 @@ class CodexRunner:
410
428
  if requested_reasoning_effort:
411
429
  to_insert.extend(["-c", f'model_reasoning_effort="{requested_reasoning_effort}"'])
412
430
 
413
- cleaned[insertion_index:insertion_index] = to_insert
431
+ cleaned[options_insertion_index:options_insertion_index] = to_insert
414
432
  return cleaned
415
433
 
416
434
  effective_model = requested_model or self._default_model
417
435
  effective_sandbox = requested_sandbox or self._default_sandbox
436
+
437
+ if requested_resume_session_id:
438
+ # `codex exec resume` does not accept `--sandbox` after `resume`.
439
+ # Sandbox must be passed as an `exec` option before the `resume` subcommand.
440
+ args: List[str] = [
441
+ "--ask-for-approval",
442
+ "never",
443
+ "exec",
444
+ "--json",
445
+ "--color",
446
+ "never",
447
+ "--skip-git-repo-check",
448
+ ]
449
+ if effective_sandbox:
450
+ args.extend(["--sandbox", effective_sandbox])
451
+ args.append("resume")
452
+ if effective_model:
453
+ args.extend(["-m", effective_model])
454
+ if requested_reasoning_effort:
455
+ args.extend(["-c", f'model_reasoning_effort="{requested_reasoning_effort}"'])
456
+ if requested_images:
457
+ args.extend(["--image", *requested_images])
458
+ args.extend([requested_resume_session_id, "-"])
459
+ return args
460
+
418
461
  args = list(self._common_args)
419
462
  if effective_sandbox:
420
463
  args.extend(["--sandbox", effective_sandbox])