optio-codex 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,66 @@
1
+ """optio-codex — run OpenAI Codex as an optio task."""
2
+
3
+ import logging as _logging
4
+
5
+ from optio_agents import HookContext, HookContextProtocol
6
+ from optio_host import (
7
+ HostCommandError,
8
+ RunResult,
9
+ SSHConfig,
10
+ )
11
+
12
+ from optio_codex.seed_manifest import (
13
+ CODEX_CRED_MANIFEST,
14
+ CODEX_SEED_MANIFEST,
15
+ CODEX_SEED_SUFFIX,
16
+ delete_seed,
17
+ list_seeds,
18
+ purge_seed,
19
+ )
20
+ from optio_codex.session import create_codex_task, run_codex_session
21
+ from optio_codex.types import (
22
+ AllowedDir,
23
+ ApprovalPolicy,
24
+ CodexTaskConfig,
25
+ ConversationMode,
26
+ DeliverableCallback,
27
+ HookCallback,
28
+ SandboxMode,
29
+ ToolVerbosity,
30
+ ThinkingVerbosity,
31
+ SeedProvider,
32
+ SeedUnavailableError,
33
+ )
34
+ from optio_codex.verify import verify_and_refresh_seed
35
+
36
+
37
+ _logging.getLogger("asyncssh").setLevel(_logging.WARNING)
38
+
39
+
40
+ __all__ = [
41
+ "create_codex_task",
42
+ "run_codex_session",
43
+ "AllowedDir",
44
+ "ApprovalPolicy",
45
+ "CodexTaskConfig",
46
+ "ConversationMode",
47
+ "DeliverableCallback",
48
+ "HookCallback",
49
+ "SandboxMode",
50
+ "ToolVerbosity",
51
+ "ThinkingVerbosity",
52
+ "SeedProvider",
53
+ "SeedUnavailableError",
54
+ "SSHConfig",
55
+ "HookContext",
56
+ "HookContextProtocol",
57
+ "HostCommandError",
58
+ "RunResult",
59
+ "CODEX_SEED_MANIFEST",
60
+ "CODEX_CRED_MANIFEST",
61
+ "CODEX_SEED_SUFFIX",
62
+ "delete_seed",
63
+ "list_seeds",
64
+ "purge_seed",
65
+ "verify_and_refresh_seed",
66
+ ]
@@ -0,0 +1,553 @@
1
+ """CodexConversation — engine-side driver for one headless Codex session over
2
+ the app-server protocol: JSON-RPC 2.0 over the stdin/stdout of
3
+ ``codex app-server``.
4
+
5
+ The session body launches ``codex app-server`` via
6
+ ``host.launch_subprocess(stdin=True, merge_stderr=False)``, attaches the
7
+ handle here, starts ``run_reader()``, runs ``bootstrap()`` (the handshake),
8
+ publishes this object via ``ctx.publish_result``, and waits until the
9
+ subprocess ends.
10
+
11
+ Event payloads are transparent: every parsed stdout JSON-RPC object is fanned
12
+ out to ``on_event`` subscribers as a dict, unmodified. Synthetic events use
13
+ the ``x-optio-`` type prefix. Structurally mirrors optio-grok's
14
+ GrokConversation, but frames the codex app-server protocol instead of ACP.
15
+
16
+ ============================================================================
17
+ APP-SERVER WIRE FACTS — pinned by a LIVE PROBE of the real ``codex
18
+ app-server`` (codex-cli 0.142.5) plus the schema dump generated by that exact
19
+ binary (``codex app-server generate-json-schema``). Evidence: the scratchpad
20
+ ``probe.py`` transcript + ``schema/{ClientRequest,ServerRequest,
21
+ ServerNotification,ClientNotification}.json``. See the design doc
22
+ "Conversation transport (Stage 6)".
23
+ ============================================================================
24
+
25
+ FRAMING: newline-delimited JSON (JSONL); JSON-RPC 2.0 semantics with the
26
+ ``"jsonrpc"`` field OMITTED on the wire (both directions; probed). NO
27
+ Content-Length headers. Backpressure: error ``-32001`` "Server overloaded;
28
+ retry later." is retryable.
29
+
30
+ Client -> server REQUESTS (have ``id``, expect a ``result``):
31
+ * ``initialize`` {clientInfo:{name,title,version}, capabilities?} ->
32
+ {userAgent, codexHome, platformFamily, platformOs}. We do NOT set
33
+ ``capabilities.experimentalApi`` (stable surface only);
34
+ ``optOutNotificationMethods`` suppresses exact-match notifications
35
+ (we opt out of the unrendered ``item/commandExecution/outputDelta``).
36
+ Followed by the ``initialized`` NOTIFICATION (no id). Any other request
37
+ first -> "Not initialized" error.
38
+ * ``account/read`` {refreshToken:false} -> {account|null,
39
+ requiresOpenaiAuth} — bootstrap auth sanity (warn-only, never fatal:
40
+ fakes and API-key setups may report no account).
41
+ * ``model/list`` {} -> {data:[{id, displayName, hidden, isDefault, …}],
42
+ nextCursor} — captured raw for the widget picker (models.py maps it).
43
+ * ``thread/start`` {cwd, sandbox, approvalPolicy, model?} ->
44
+ {thread:{id,…}, model, …} (+ a ``thread/started`` notification).
45
+ SCHEMA CATCH: on thread/start the field is ``sandbox`` with the
46
+ kebab-case enum "read-only"|"workspace-write"|"danger-full-access";
47
+ the camelCase ``sandboxPolicy`` OBJECT exists only on turn/start
48
+ (0.142.5 ThreadStartParams; the probe used sandbox:"read-only").
49
+ NOT ephemeral — the rollout file is the resume source (Plan B).
50
+ * ``thread/resume`` {threadId, …same overrides} — resume path; response
51
+ shape matches thread/start.
52
+ * ``turn/start`` {threadId, input:[{type:"text",text}], model?} ->
53
+ IMMEDIATE ACK {turn:{id, status:"inProgress", items:[]}} — the ACK is
54
+ NOT the turn end. Per-turn overrides (``model``) become the default for
55
+ subsequent turns on the thread == the INLINE model-switch seam
56
+ (request_model_change pins the next turn's model; no wire write).
57
+ * ``turn/interrupt`` {threadId, turnId} -> {} ACK; the completion signal
58
+ is the subsequent ``turn/completed`` with status "interrupted".
59
+
60
+ Server -> client NOTIFICATIONS (no ``id``):
61
+ * ``turn/started`` {threadId, turn:{id,…}} — tracks current_turn_id.
62
+ * ``item/agentMessage/delta`` {threadId, turnId, itemId, delta} —
63
+ concatenated per itemId -> the turn's answer text.
64
+ * ``item/completed`` {…, item:{type,…}} — for item.type "agentMessage"
65
+ the item's ``text`` is authoritative for that itemId.
66
+ * ``item/started`` / ``item/reasoning/summaryTextDelta`` /
67
+ ``item/reasoning/textDelta`` / ``thread/tokenUsage/updated`` /
68
+ ``error`` {error:{message, codexErrorInfo?}, willRetry} / … — passed
69
+ through untouched to on_event (the UI reducer renders them).
70
+ * ``turn/completed`` {threadId, turn:{id, status, error?}} — THE TURN-END
71
+ SIGNAL, status ∈ "completed" | "interrupted" | "failed".
72
+
73
+ Server -> client REQUESTS (have ``id`` AND ``method``, WE must respond):
74
+ * ``item/commandExecution/requestApproval`` {threadId, turnId, itemId,
75
+ command?, cwd?, reason?, …} and ``item/fileChange/requestApproval``
76
+ {threadId, turnId, itemId, reason?, grantRoot?} — the permission gate
77
+ seam. ANSWER with result {"decision": D}: optio allow -> "accept";
78
+ optio deny -> "decline" (the agent continues the turn — "cancel" would
79
+ also interrupt it, which is NOT what PermissionDecision deny means).
80
+ The full decision enums also carry "acceptForSession" and (commands
81
+ only) execpolicy/network amendment objects — unused here. A deny
82
+ *message* is not transmittable on this wire.
83
+ * Anything else (``item/tool/requestUserInput``, ``item/permissions/
84
+ requestApproval``, ``mcpServer/elicitation/request``, ``account/
85
+ chatgptAuthTokens/refresh``, ``attestation/generate``, legacy
86
+ ``applyPatchApproval``/``execCommandApproval``, ``item/tool/call``) —
87
+ answered with a JSON-RPC ``-32601`` error defensively (they only fire
88
+ for capabilities we don't advertise / flows we don't start).
89
+ ============================================================================
90
+ """
91
+
92
+ from __future__ import annotations
93
+
94
+ import asyncio
95
+ import json
96
+ import logging
97
+
98
+ from optio_agents.conversation import (
99
+ ConversationClosed,
100
+ PermissionDecision,
101
+ PermissionRequest,
102
+ )
103
+
104
+ _LOG = logging.getLogger(__name__)
105
+
106
+ # Server->client requests that ARE the permission gate; everything else with
107
+ # an id+method gets a defensive -32601.
108
+ _APPROVAL_METHODS = (
109
+ "item/commandExecution/requestApproval",
110
+ "item/fileChange/requestApproval",
111
+ )
112
+
113
+
114
+ class CodexConversation:
115
+ """Implements optio_agents.conversation.Conversation for Codex
116
+ (app-server)."""
117
+
118
+ def __init__(
119
+ self,
120
+ *,
121
+ cwd: str,
122
+ agent_label: str = "codex",
123
+ permission_gate: bool = False,
124
+ model: str | None = None,
125
+ sandbox: str = "workspace-write",
126
+ resume_thread_id: str | None = None,
127
+ ) -> None:
128
+ self._cwd = cwd
129
+ self._agent_label = agent_label
130
+ # When False, requestApproval is answered with a defensive deny
131
+ # instead of being queued for a handler.
132
+ self._permission_gate = permission_gate
133
+ self._model = model
134
+ self._sandbox = sandbox
135
+ # With the gate on codex must ask (on-request); without it, never.
136
+ self._approval_policy = "on-request" if permission_gate else "never"
137
+ self._resume_thread_id = resume_thread_id
138
+ self._handle = None
139
+ # Captured at bootstrap; Plan B's snapshot sessionId seam reads
140
+ # thread_id at capture time.
141
+ self.thread_id: str | None = None
142
+ self.current_turn_id: str | None = None
143
+ self.server_info: dict | None = None
144
+ self.account: dict | None = None
145
+ # Raw model/list result from bootstrap (models.parse_model_list maps
146
+ # it) so the session can populate the picker without a second probe.
147
+ self.model_list: dict | None = None
148
+ self.current_model_id: str | None = None
149
+ self._requested_model: str | None = None
150
+ self._pending = 0 # user turns awaiting turn/completed
151
+ self._closed = asyncio.Event()
152
+ self._close_reason: str | None = None
153
+ # Cooperative-shutdown request towards the owning task body.
154
+ self.close_requested = asyncio.Event()
155
+ self._write_lock = asyncio.Lock()
156
+ self._event_queue: asyncio.Queue[dict] = asyncio.Queue()
157
+ self._event_handlers: list = []
158
+ self._message_handlers: list = []
159
+ self._permission_handler = None
160
+ self._queued_permission_requests: list[dict] = []
161
+ # JSON-RPC id bookkeeping.
162
+ self._next_id = 0
163
+ self._req_futures: dict[int, asyncio.Future] = {} # handshake/interrupt
164
+ self._turn_req_ids: set[int] = set() # turn/start ACKs
165
+ # Accumulates agentMessage text for the current turn, per itemId.
166
+ self._answer_order: list[str] = []
167
+ self._answer_texts: dict[str, str] = {}
168
+ self._dispatcher_task: asyncio.Task | None = None
169
+
170
+ # -- wiring ------------------------------------------------------------
171
+
172
+ def attach(self, handle) -> None:
173
+ """Attach the live ProcessHandle (must have been launched with
174
+ stdin=True)."""
175
+ if handle.stdin is None:
176
+ raise ValueError(
177
+ "CodexConversation.attach: handle has no stdin writer; launch "
178
+ "the subprocess with stdin=True"
179
+ )
180
+ self._handle = handle
181
+
182
+ async def bootstrap(self) -> None:
183
+ """Run the app-server handshake: ``initialize`` + ``initialized``,
184
+ then ``account/read`` (auth sanity, warn-only), ``model/list`` (the
185
+ picker source), and ``thread/start`` / ``thread/resume``.
186
+
187
+ Requires ``run_reader()`` to already be running (it routes the
188
+ responses back to the futures created here).
189
+ """
190
+ resp = await self._request("initialize", {
191
+ "clientInfo": {
192
+ "name": "optio_codex", "title": "Optio", "version": "0.1.0",
193
+ },
194
+ # Stable surface only (no experimentalApi). Opt out of the one
195
+ # high-volume stream nothing downstream renders.
196
+ "capabilities": {
197
+ "optOutNotificationMethods": [
198
+ "item/commandExecution/outputDelta",
199
+ ],
200
+ },
201
+ })
202
+ self.server_info = (resp or {}).get("result") or {}
203
+ await self._write_json({"method": "initialized"})
204
+
205
+ resp = await self._request("account/read", {"refreshToken": False})
206
+ self.account = ((resp or {}).get("result") or {}).get("account")
207
+ if self.account is None:
208
+ _LOG.warning(
209
+ "codex conversation: account/read reports no active auth; "
210
+ "turns may fail until a seed/login provides credentials",
211
+ )
212
+
213
+ resp = await self._request("model/list", {})
214
+ self.model_list = (
215
+ (resp or {}).get("result") if "error" not in (resp or {}) else None
216
+ )
217
+
218
+ if self._resume_thread_id is not None:
219
+ method = "thread/resume"
220
+ params: dict = {"threadId": self._resume_thread_id}
221
+ else:
222
+ method = "thread/start"
223
+ params = {"cwd": self._cwd}
224
+ # thread/start takes `sandbox` (kebab-case enum) — NOT the turn-level
225
+ # `sandboxPolicy` object (see the module docstring's schema catch).
226
+ params["sandbox"] = self._sandbox
227
+ params["approvalPolicy"] = self._approval_policy
228
+ if self._model:
229
+ params["model"] = self._model
230
+ resp = await self._request(method, params)
231
+ if "error" in (resp or {}):
232
+ raise RuntimeError(
233
+ f"codex {method} failed: {(resp or {}).get('error')!r}"
234
+ )
235
+ result = (resp or {}).get("result") or {}
236
+ thread = result.get("thread") or {}
237
+ self.thread_id = thread.get("id")
238
+ if not self.thread_id:
239
+ raise RuntimeError(
240
+ f"codex {method} returned no thread id: {result!r}"
241
+ )
242
+ self.current_model_id = result.get("model") or self._model
243
+
244
+ async def run_reader(self) -> None:
245
+ """Drain stdout until EOF; route JSON-RPC messages. Owned by the
246
+ session body; ends when the subprocess ends."""
247
+ self._dispatcher_task = asyncio.create_task(self._dispatch_loop())
248
+ try:
249
+ async for raw in self._handle.stdout:
250
+ line = (
251
+ raw.decode("utf-8", errors="replace")
252
+ if isinstance(raw, bytes) else str(raw)
253
+ ).strip()
254
+ if not line:
255
+ continue
256
+ try:
257
+ obj = json.loads(line)
258
+ except ValueError:
259
+ _LOG.warning("codex conversation: unparseable line: %.200s", line)
260
+ self._event_queue.put_nowait(
261
+ {"type": "x-optio-unparseable", "line": line},
262
+ )
263
+ continue
264
+ self._route(obj)
265
+ finally:
266
+ await self._finish("process ended")
267
+
268
+ def _route(self, obj: dict) -> None:
269
+ rid = obj.get("id")
270
+ method = obj.get("method")
271
+ if method is None and rid is not None and ("result" in obj or "error" in obj):
272
+ # Response to one of OUR requests.
273
+ if rid in self._req_futures:
274
+ fut = self._req_futures.pop(rid)
275
+ if not fut.done():
276
+ fut.set_result(obj)
277
+ elif rid in self._turn_req_ids:
278
+ # turn/start ACK — NOT the turn end (that is turn/completed).
279
+ self._turn_req_ids.discard(rid)
280
+ if "error" in obj:
281
+ # The turn never started; nothing further will arrive.
282
+ _LOG.warning(
283
+ "codex conversation: turn/start rejected: %r",
284
+ obj.get("error"),
285
+ )
286
+ self._pending = max(0, self._pending - 1)
287
+ else:
288
+ turn = ((obj.get("result") or {}).get("turn")) or {}
289
+ if turn.get("id"):
290
+ self.current_turn_id = turn["id"]
291
+ elif method is not None and rid is not None:
292
+ # Server -> client REQUEST that we must answer.
293
+ if method in _APPROVAL_METHODS:
294
+ self._on_permission(obj)
295
+ else:
296
+ asyncio.ensure_future(self._write_json({
297
+ "id": rid,
298
+ "error": {"code": -32601,
299
+ "message": f"optio codex client does not implement {method}"},
300
+ }))
301
+ elif method is not None:
302
+ self._on_notification(method, obj)
303
+ self._event_queue.put_nowait(obj)
304
+
305
+ def _on_notification(self, method: str, obj: dict) -> None:
306
+ params = obj.get("params") or {}
307
+ if method == "item/agentMessage/delta":
308
+ item_id = str(params.get("itemId") or "")
309
+ delta = params.get("delta") or ""
310
+ if delta:
311
+ if item_id not in self._answer_texts:
312
+ self._answer_order.append(item_id)
313
+ self._answer_texts[item_id] = ""
314
+ self._answer_texts[item_id] += delta
315
+ elif method == "item/completed":
316
+ item = params.get("item") or {}
317
+ if item.get("type") == "agentMessage":
318
+ item_id = str(item.get("id") or "")
319
+ if item_id not in self._answer_texts:
320
+ self._answer_order.append(item_id)
321
+ # The completed item's text is authoritative for its itemId
322
+ # (heals any lost/duplicated deltas).
323
+ self._answer_texts[item_id] = (
324
+ item.get("text") or self._answer_texts.get(item_id, "")
325
+ )
326
+ elif method == "turn/started":
327
+ turn = params.get("turn") or {}
328
+ if turn.get("id"):
329
+ self.current_turn_id = turn["id"]
330
+ elif method == "turn/completed":
331
+ # THE turn-end signal (status completed | interrupted | failed).
332
+ self._pending = max(0, self._pending - 1)
333
+ self.current_turn_id = None
334
+ text = "".join(self._answer_texts[i] for i in self._answer_order)
335
+ self._answer_order = []
336
+ self._answer_texts = {}
337
+ self._fire_message(text)
338
+ # else: thread/started, item/started, reasoning deltas, tokenUsage,
339
+ # error, … — pass through to on_event only.
340
+
341
+ # -- event fan-out -----------------------------------------------------
342
+
343
+ async def _dispatch_loop(self) -> None:
344
+ while True:
345
+ obj = await self._event_queue.get()
346
+ for handler in list(self._event_handlers):
347
+ await self._call_handler(handler, obj, "on_event")
348
+
349
+ async def _call_handler(self, handler, arg, label: str) -> None:
350
+ try:
351
+ result = handler(arg)
352
+ if asyncio.iscoroutine(result):
353
+ await result
354
+ except Exception: # noqa: BLE001 — subscriber bugs never kill the driver
355
+ _LOG.exception("codex conversation: %s handler raised", label)
356
+
357
+ def _fire_message(self, text: str) -> None:
358
+ for handler in list(self._message_handlers):
359
+ asyncio.ensure_future(self._call_handler(handler, text, "on_message"))
360
+
361
+ # -- permission gate ----------------------------------------------------
362
+
363
+ def _on_permission(self, obj: dict) -> None:
364
+ if not self._permission_gate:
365
+ _LOG.warning(
366
+ "codex conversation: %s received with permission_gate off; "
367
+ "denying defensively", obj.get("method"),
368
+ )
369
+ asyncio.ensure_future(self._answer_permission_decision(
370
+ obj, PermissionDecision(
371
+ behavior="deny",
372
+ message="optio harness: permission gate not enabled",
373
+ ),
374
+ ))
375
+ return
376
+ if self._permission_handler is None:
377
+ # Queue until a handler is registered; the turn blocks
378
+ # server-side, which closes the publish/registration race.
379
+ self._queued_permission_requests.append(obj)
380
+ return
381
+ asyncio.ensure_future(self._answer_permission(obj))
382
+
383
+ async def _answer_permission(self, obj: dict) -> None:
384
+ params = obj.get("params") or {}
385
+ if obj.get("method") == "item/commandExecution/requestApproval":
386
+ tool_name = str(params.get("command") or "command execution")
387
+ else:
388
+ tool_name = "file change"
389
+ request = PermissionRequest(
390
+ tool_name=tool_name,
391
+ input=params,
392
+ raw=obj,
393
+ )
394
+ try:
395
+ decision = await self._permission_handler(request)
396
+ except Exception: # noqa: BLE001
397
+ _LOG.exception("codex conversation: permission handler raised; denying")
398
+ decision = PermissionDecision(
399
+ behavior="deny",
400
+ message="optio harness: permission handler failed",
401
+ )
402
+ await self._answer_permission_decision(obj, decision)
403
+
404
+ async def _answer_permission_decision(
405
+ self, obj: dict, decision: PermissionDecision,
406
+ ) -> None:
407
+ # allow -> accept; deny -> decline (the agent continues the turn —
408
+ # "cancel" would also interrupt it, which deny does not mean). The
409
+ # deny message is not transmittable on this wire.
410
+ decision_str = "accept" if decision.behavior == "allow" else "decline"
411
+ await self._write_json({
412
+ "id": obj.get("id"),
413
+ "result": {"decision": decision_str},
414
+ })
415
+
416
+ # -- Conversation protocol surface --------------------------------------
417
+
418
+ async def send(self, text: str) -> None:
419
+ if self._closed.is_set():
420
+ raise ConversationClosed(self._close_reason or "conversation closed")
421
+ if self.thread_id is None:
422
+ raise RuntimeError("CodexConversation.send before bootstrap() completed")
423
+ self._next_id += 1
424
+ rid = self._next_id
425
+ self._turn_req_ids.add(rid)
426
+ self._pending += 1
427
+ params: dict = {
428
+ "threadId": self.thread_id,
429
+ "input": [{"type": "text", "text": text}],
430
+ }
431
+ if self._requested_model is not None:
432
+ # Inline model switch: the override becomes the thread default
433
+ # for subsequent turns (app-server contract).
434
+ params["model"] = self._requested_model
435
+ try:
436
+ await self._write_json({
437
+ "id": rid, "method": "turn/start", "params": params,
438
+ })
439
+ except Exception:
440
+ self._turn_req_ids.discard(rid)
441
+ self._pending = max(0, self._pending - 1)
442
+ await self._finish("stdin write failed")
443
+ raise
444
+
445
+ def on_event(self, handler):
446
+ self._event_handlers.append(handler)
447
+ return lambda: self._event_handlers.remove(handler)
448
+
449
+ def on_message(self, handler):
450
+ self._message_handlers.append(handler)
451
+ return lambda: self._message_handlers.remove(handler)
452
+
453
+ def on_permission_request(self, handler):
454
+ self._permission_handler = handler
455
+ queued, self._queued_permission_requests = (
456
+ self._queued_permission_requests, [],
457
+ )
458
+ for obj in queued:
459
+ asyncio.ensure_future(self._answer_permission(obj))
460
+
461
+ def _unsub() -> None:
462
+ if self._permission_handler is handler:
463
+ self._permission_handler = None
464
+ return _unsub
465
+
466
+ def is_pending(self) -> bool:
467
+ return self._pending > 0
468
+
469
+ async def interrupt(self) -> None:
470
+ if self._closed.is_set():
471
+ raise ConversationClosed(self._close_reason or "conversation closed")
472
+ if (
473
+ self._pending == 0
474
+ or self.thread_id is None
475
+ or self.current_turn_id is None
476
+ ):
477
+ return
478
+ # turn/interrupt is a normal request; its {} ACK is NOT the
479
+ # completion signal — the in-flight turn ends via turn/completed
480
+ # with status "interrupted".
481
+ await self._request("turn/interrupt", {
482
+ "threadId": self.thread_id, "turnId": self.current_turn_id,
483
+ })
484
+
485
+ def request_model_change(self, model: str) -> None:
486
+ """Switch model mid-conversation INLINE — with NO wire write: a
487
+ ``model`` override on the next ``turn/start`` becomes the thread's
488
+ default for subsequent turns (app-server contract; see models.py).
489
+ Synchronous surface (the listener calls it without await)."""
490
+ if self._closed.is_set():
491
+ raise ConversationClosed(self._close_reason or "conversation closed")
492
+ if self.thread_id is None:
493
+ raise RuntimeError(
494
+ "CodexConversation.request_model_change before bootstrap() completed"
495
+ )
496
+ self._requested_model = model
497
+ self.current_model_id = model # optimistic; the next turn pins it
498
+
499
+ async def close(self) -> None:
500
+ self.close_requested.set()
501
+
502
+ @property
503
+ def closed(self) -> bool:
504
+ return self._closed.is_set()
505
+
506
+ # -- internals -----------------------------------------------------------
507
+
508
+ async def _request(self, method: str, params: dict) -> dict:
509
+ """Send a client->server request and await its response (handshake +
510
+ turn/interrupt only; turn/start is tracked via _turn_req_ids)."""
511
+ self._next_id += 1
512
+ rid = self._next_id
513
+ fut: asyncio.Future = asyncio.get_event_loop().create_future()
514
+ self._req_futures[rid] = fut
515
+ await self._write_json({"id": rid, "method": method, "params": params})
516
+ return await fut
517
+
518
+ async def _write_json(self, obj: dict) -> None:
519
+ # The app-server wire omits the "jsonrpc" field (probed; README).
520
+ await self._write_bytes((json.dumps(obj) + "\n").encode("utf-8"))
521
+
522
+ async def _write_bytes(self, data: bytes) -> None:
523
+ async with self._write_lock:
524
+ stdin = self._handle.stdin
525
+ stdin.write(data)
526
+ drain = getattr(stdin, "drain", None)
527
+ if drain is not None:
528
+ await drain()
529
+
530
+ async def _finish(self, reason: str) -> None:
531
+ if self._closed.is_set():
532
+ return
533
+ self._closed.set()
534
+ self._close_reason = reason
535
+ # Fail any in-flight handshake/interrupt requests.
536
+ for fut in self._req_futures.values():
537
+ if not fut.done():
538
+ fut.set_exception(ConversationClosed(reason))
539
+ self._req_futures.clear()
540
+ self._event_queue.put_nowait({"type": "x-optio-closed", "reason": reason})
541
+ # Stop the dispatcher, then drain whatever it left so subscribers are
542
+ # guaranteed to see the final x-optio-closed event.
543
+ if self._dispatcher_task is not None:
544
+ self._dispatcher_task.cancel()
545
+ try:
546
+ await self._dispatcher_task
547
+ except asyncio.CancelledError:
548
+ pass
549
+ self._dispatcher_task = None
550
+ while not self._event_queue.empty():
551
+ obj = self._event_queue.get_nowait()
552
+ for handler in list(self._event_handlers):
553
+ await self._call_handler(handler, obj, "on_event")