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
@@ -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,25 +61,61 @@ 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
- async def read_message() -> dict[str, Any] | None:
69
- raw = await asyncio.wait_for(proc.stdout.readline(), timeout=3.0)
70
- if not raw:
69
+ buffer = bytearray()
70
+ max_message_bytes = 1024 * 1024
71
+
72
+ def _pop_message_from_buffer() -> dict[str, Any] | None:
73
+ separator_index = buffer.find(b"\n")
74
+ if separator_index < 0:
71
75
  return None
72
- line = raw.decode("utf-8", errors="replace").strip()
76
+ raw_line = bytes(buffer[:separator_index])
77
+ del buffer[:separator_index + 1]
78
+ line = raw_line.decode("utf-8", errors="replace").strip()
73
79
  if not line:
74
- return None
80
+ return {}
75
81
  try:
76
82
  payload = json.loads(line)
77
83
  except json.JSONDecodeError:
78
- return None
84
+ return {}
79
85
  if isinstance(payload, dict):
80
86
  return payload
81
- return None
87
+ return {}
88
+
89
+ async def read_message() -> dict[str, Any] | None:
90
+ deadline = time.monotonic() + 3.0
91
+ while True:
92
+ message = _pop_message_from_buffer()
93
+ if message is not None:
94
+ if message:
95
+ return message
96
+ continue
97
+
98
+ remaining = deadline - time.monotonic()
99
+ if remaining <= 0:
100
+ raise RuntimeError("App-server response timed out")
101
+
102
+ chunk = await asyncio.wait_for(proc.stdout.read(4096), timeout=remaining)
103
+ if not chunk:
104
+ if buffer:
105
+ line = buffer.decode("utf-8", errors="replace").strip()
106
+ buffer.clear()
107
+ if not line:
108
+ return None
109
+ try:
110
+ payload = json.loads(line)
111
+ except json.JSONDecodeError:
112
+ return None
113
+ if isinstance(payload, dict):
114
+ return payload
115
+ return None
116
+ buffer.extend(chunk)
117
+ if len(buffer) > max_message_bytes and b"\n" not in buffer:
118
+ raise RuntimeError("App-server emitted an oversized response line")
82
119
 
83
120
  async def read_response(expected_id: int) -> dict[str, Any]:
84
121
  while True:
@@ -239,10 +276,15 @@ class CodexRunner:
239
276
  sandbox: str | None = None,
240
277
  images: List[str] | None = None,
241
278
  command: str | None = None,
279
+ resume_session_id: str | None = None,
242
280
  ) -> int:
243
281
  command_to_run = self._resolve_command((command or "").strip() or self._command)
244
282
  args = self._args_for_options(
245
- 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,
246
288
  )
247
289
 
248
290
  proc = await asyncio.create_subprocess_exec(
@@ -263,15 +305,39 @@ class CodexRunner:
263
305
  proc.stdin.close()
264
306
 
265
307
  async def _read_stdout() -> None:
266
- async for raw in proc.stdout:
267
- line = raw.decode("utf-8", errors="replace").strip()
268
- if not line:
269
- continue
270
- try:
271
- event = json.loads(line)
272
- except json.JSONDecodeError:
273
- event = {"type": "raw", "text": line}
274
- await on_event(event)
308
+ buffer = bytearray()
309
+ max_event_line_bytes = 1024 * 1024
310
+ while True:
311
+ chunk = await proc.stdout.read(8192)
312
+ if not chunk:
313
+ if buffer:
314
+ line = buffer.decode("utf-8", errors="replace").strip()
315
+ if line:
316
+ try:
317
+ event = json.loads(line)
318
+ except json.JSONDecodeError:
319
+ event = {"type": "raw", "text": line}
320
+ await on_event(event)
321
+ break
322
+
323
+ buffer.extend(chunk)
324
+ if len(buffer) > max_event_line_bytes and b"\n" not in buffer:
325
+ raise RuntimeError("Codex emitted an oversized unterminated stdout line")
326
+ while True:
327
+ separator_index = buffer.find(b"\n")
328
+ if separator_index < 0:
329
+ break
330
+ raw_line = bytes(buffer[:separator_index])
331
+ del buffer[:separator_index + 1]
332
+
333
+ line = raw_line.decode("utf-8", errors="replace").strip()
334
+ if not line:
335
+ continue
336
+ try:
337
+ event = json.loads(line)
338
+ except json.JSONDecodeError:
339
+ event = {"type": "raw", "text": line}
340
+ await on_event(event)
275
341
 
276
342
  async def _read_stderr() -> None:
277
343
  # Stream stderr so users can see prompts/errors even if Codex blocks.
@@ -287,6 +353,9 @@ class CodexRunner:
287
353
  except asyncio.CancelledError:
288
354
  await self._terminate_process(proc)
289
355
  raise
356
+ except Exception:
357
+ await self._terminate_process(proc)
358
+ raise
290
359
 
291
360
  async def _terminate_process(self, proc: asyncio.subprocess.Process) -> None:
292
361
  if proc.returncode is not None:
@@ -308,11 +377,13 @@ class CodexRunner:
308
377
  reasoning_effort: str | None,
309
378
  sandbox: str | None,
310
379
  images: List[str] | None,
380
+ resume_session_id: str | None = None,
311
381
  ) -> List[str]:
312
382
  requested_model = (model or "").strip()
313
383
  requested_reasoning_effort = (reasoning_effort or "").strip()
314
384
  requested_sandbox = (sandbox or "").strip()
315
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()
316
387
 
317
388
  if self._raw_args is not None:
318
389
  args = list(self._raw_args)
@@ -336,7 +407,17 @@ class CodexRunner:
336
407
  cleaned.append(token)
337
408
  idx += 1
338
409
 
339
- 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
+ )
340
421
  to_insert: List[str] = []
341
422
  if requested_images:
342
423
  to_insert.extend(["--image", *requested_images])
@@ -347,11 +428,36 @@ class CodexRunner:
347
428
  if requested_reasoning_effort:
348
429
  to_insert.extend(["-c", f'model_reasoning_effort="{requested_reasoning_effort}"'])
349
430
 
350
- cleaned[insertion_index:insertion_index] = to_insert
431
+ cleaned[options_insertion_index:options_insertion_index] = to_insert
351
432
  return cleaned
352
433
 
353
434
  effective_model = requested_model or self._default_model
354
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
+
355
461
  args = list(self._common_args)
356
462
  if effective_sandbox:
357
463
  args.extend(["--sandbox", effective_sandbox])