jupyterlab-codex-sidebar 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +9 -0
- package/.github/workflows/unit-tests.yml +27 -0
- package/AGENTS.md +42 -0
- package/README.md +67 -9
- package/docs/images/codex-sidebar-screenshot.png +0 -0
- package/jupyterlab_codex/handlers.py +938 -297
- package/jupyterlab_codex/labextension/package.json +13 -3
- package/jupyterlab_codex/labextension/static/525.224526d045c727069de6.js +2 -0
- package/jupyterlab_codex/labextension/static/855.d20f6158cd81bb4c9056.js +1 -0
- package/jupyterlab_codex/labextension/static/{remoteEntry.b2fdc03a1c4582e79156.js → remoteEntry.c1e865f207776f7f24ff.js} +1 -1
- package/jupyterlab_codex/protocol.py +297 -0
- package/jupyterlab_codex/runner.py +137 -31
- package/jupyterlab_codex/sessions.py +582 -97
- package/lib/codexChat.d.ts +13 -0
- package/lib/codexChat.js +2410 -0
- package/lib/codexChat.js.map +1 -0
- package/lib/codexChatAttachmentDedup.d.ts +10 -0
- package/lib/codexChatAttachmentDedup.js +35 -0
- package/lib/codexChatAttachmentDedup.js.map +1 -0
- package/lib/codexChatAttachmentLimit.d.ts +8 -0
- package/lib/codexChatAttachmentLimit.js +61 -0
- package/lib/codexChatAttachmentLimit.js.map +1 -0
- package/lib/codexChatDocumentUtils.d.ts +68 -0
- package/lib/codexChatDocumentUtils.js +480 -0
- package/lib/codexChatDocumentUtils.js.map +1 -0
- package/lib/codexChatFormatting.d.ts +11 -0
- package/lib/codexChatFormatting.js +83 -0
- package/lib/codexChatFormatting.js.map +1 -0
- package/lib/codexChatNotice.d.ts +3 -0
- package/lib/codexChatNotice.js +74 -0
- package/lib/codexChatNotice.js.map +1 -0
- package/lib/codexChatPersistence.d.ts +35 -0
- package/lib/codexChatPersistence.js +158 -0
- package/lib/codexChatPersistence.js.map +1 -0
- package/lib/codexChatPrimitives.d.ts +41 -0
- package/lib/codexChatPrimitives.js +152 -0
- package/lib/codexChatPrimitives.js.map +1 -0
- package/lib/codexChatRender.d.ts +24 -0
- package/lib/codexChatRender.js +293 -0
- package/lib/codexChatRender.js.map +1 -0
- package/lib/codexChatSessionFactory.d.ts +15 -0
- package/lib/codexChatSessionFactory.js +45 -0
- package/lib/codexChatSessionFactory.js.map +1 -0
- package/lib/codexChatSessionKey.d.ts +3 -0
- package/lib/codexChatSessionKey.js +14 -0
- package/lib/codexChatSessionKey.js.map +1 -0
- package/lib/codexChatStorage.d.ts +4 -0
- package/lib/codexChatStorage.js +37 -0
- package/lib/codexChatStorage.js.map +1 -0
- package/lib/codexSessionResolver.d.ts +12 -0
- package/lib/codexSessionResolver.js +38 -0
- package/lib/codexSessionResolver.js.map +1 -0
- package/lib/handlers/activitySummarizer.d.ts +15 -0
- package/lib/handlers/activitySummarizer.js +327 -0
- package/lib/handlers/activitySummarizer.js.map +1 -0
- package/lib/handlers/codexMessageTypes.d.ts +30 -0
- package/lib/handlers/codexMessageTypes.js +2 -0
- package/lib/handlers/codexMessageTypes.js.map +1 -0
- package/lib/handlers/codexMessageUtils.d.ts +46 -0
- package/lib/handlers/codexMessageUtils.js +144 -0
- package/lib/handlers/codexMessageUtils.js.map +1 -0
- package/lib/handlers/handleCodexSocketMessage.d.ts +107 -0
- package/lib/handlers/handleCodexSocketMessage.js +78 -0
- package/lib/handlers/handleCodexSocketMessage.js.map +1 -0
- package/lib/handlers/sessionSyncHandler.d.ts +34 -0
- package/lib/handlers/sessionSyncHandler.js +181 -0
- package/lib/handlers/sessionSyncHandler.js.map +1 -0
- package/lib/hooks/useCodexSocket.d.ts +15 -0
- package/lib/hooks/useCodexSocket.js +84 -0
- package/lib/hooks/useCodexSocket.js.map +1 -0
- package/lib/index.js +1 -1
- package/lib/index.js.map +1 -1
- package/lib/panel.d.ts +1 -11
- package/lib/panel.js +1 -2768
- package/lib/panel.js.map +1 -1
- package/lib/protocol.d.ts +235 -0
- package/lib/protocol.js +278 -0
- package/lib/protocol.js.map +1 -0
- package/package.json +13 -3
- package/playwright.config.cjs +24 -0
- package/playwright.unit.config.cjs +19 -0
- package/pyproject.toml +1 -1
- package/release.sh +243 -0
- package/scripts/run_playwright_e2e.sh +96 -0
- package/scripts/run_playwright_freeze_repro.sh +58 -0
- package/scripts/run_playwright_queue_repro.sh +60 -0
- package/scripts/run_playwright_repro.sh +55 -0
- package/src/codexChat.tsx +3755 -0
- package/src/codexChatAttachmentDedup.ts +47 -0
- package/src/codexChatAttachmentLimit.ts +82 -0
- package/src/codexChatDocumentUtils.ts +612 -0
- package/src/codexChatFormatting.ts +94 -0
- package/src/codexChatNotice.ts +95 -0
- package/src/codexChatPersistence.ts +191 -0
- package/src/codexChatPrimitives.tsx +422 -0
- package/src/codexChatRender.tsx +376 -0
- package/src/codexChatSessionFactory.ts +79 -0
- package/src/codexChatSessionKey.ts +16 -0
- package/src/codexChatStorage.ts +36 -0
- package/src/codexSessionResolver.ts +56 -0
- package/src/handlers/activitySummarizer.ts +369 -0
- package/src/handlers/codexMessageTypes.ts +34 -0
- package/src/handlers/codexMessageUtils.ts +217 -0
- package/src/handlers/handleCodexSocketMessage.ts +204 -0
- package/src/handlers/sessionSyncHandler.ts +308 -0
- package/src/hooks/useCodexSocket.ts +109 -0
- package/src/index.ts +1 -1
- package/src/panel.tsx +1 -4131
- package/src/protocol.ts +582 -0
- package/style/index.css +424 -11
- package/tests/e2e/fixtures/notebooks/tab1.ipynb +322 -0
- package/tests/e2e/fixtures/notebooks/tab1.py +272 -0
- package/tests/e2e/fixtures/notebooks/tab2.ipynb +252 -0
- package/tests/e2e/fixtures/notebooks/tab2.py +231 -0
- package/tests/e2e/fixtures/notebooks/tab3.ipynb +403 -0
- package/tests/e2e/fixtures/notebooks/tab3.py +331 -0
- package/tests/e2e/fixtures/notebooks/tab4.py +339 -0
- package/tests/e2e/freeze-notebook-tabs-repro.spec.js +295 -0
- package/tests/e2e/mock-codex-cli-flood.py +127 -0
- package/tests/e2e/mock-codex-cli.py +95 -0
- package/tests/e2e/queue-multitab-repro.spec.js +189 -0
- package/tests/test_handlers.py +116 -0
- package/tests/test_protocol.py +169 -0
- package/tests/test_session_store_limits.py +50 -0
- package/tests/unit/codexChatAttachmentDedup.spec.ts +56 -0
- package/tests/unit/codexChatAttachmentLimit.spec.ts +42 -0
- package/tests/unit/codexChatLimit.spec.ts +18 -0
- package/tests/unit/codexChatNotice.spec.ts +45 -0
- package/tests/unit/codexChatPersistence.spec.ts +199 -0
- package/tests/unit/codexChatSessionFactory.spec.ts +94 -0
- package/tests/unit/codexChatSessionKey.spec.ts +18 -0
- package/tests/unit/codexMessageUtils.spec.ts +89 -0
- package/tests/unit/codexSessionResolver.spec.ts +92 -0
- package/tests/unit/handleCodexSocketMessage.spec.ts +476 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/webpack.config.js +6 -0
- package/jupyterlab_codex/labextension/static/504.335f3447c84ba3d74517.js +0 -2
- package/jupyterlab_codex/labextension/static/972.d43137b7438a053eeb72.js +0 -1
- /package/jupyterlab_codex/labextension/static/{504.335f3447c84ba3d74517.js.LICENSE.txt → 525.224526d045c727069de6.js.LICENSE.txt} +0 -0
|
@@ -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(
|
|
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
|
-
|
|
41
|
-
|
|
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.
|
|
64
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
64
65
|
)
|
|
65
|
-
if proc.stdin is None or proc.stdout 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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
|
80
|
+
return {}
|
|
75
81
|
try:
|
|
76
82
|
payload = json.loads(line)
|
|
77
83
|
except json.JSONDecodeError:
|
|
78
|
-
return
|
|
84
|
+
return {}
|
|
79
85
|
if isinstance(payload, dict):
|
|
80
86
|
return payload
|
|
81
|
-
return
|
|
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,
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
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[
|
|
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])
|