optio-codex 0.1.0__tar.gz → 0.2.0__tar.gz

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 (58) hide show
  1. {optio_codex-0.1.0 → optio_codex-0.2.0}/PKG-INFO +3 -2
  2. {optio_codex-0.1.0 → optio_codex-0.2.0}/pyproject.toml +3 -2
  3. {optio_codex-0.1.0 → optio_codex-0.2.0}/src/optio_codex/conversation.py +110 -9
  4. {optio_codex-0.1.0 → optio_codex-0.2.0}/src/optio_codex/conversation_listener.py +9 -44
  5. {optio_codex-0.1.0 → optio_codex-0.2.0}/src/optio_codex/fs_allowlist.py +6 -5
  6. {optio_codex-0.1.0 → optio_codex-0.2.0}/src/optio_codex/host_actions.py +21 -17
  7. optio_codex-0.2.0/src/optio_codex/models.py +126 -0
  8. {optio_codex-0.1.0 → optio_codex-0.2.0}/src/optio_codex/session.py +247 -43
  9. {optio_codex-0.1.0 → optio_codex-0.2.0}/src/optio_codex/types.py +132 -70
  10. {optio_codex-0.1.0 → optio_codex-0.2.0}/src/optio_codex.egg-info/PKG-INFO +3 -2
  11. {optio_codex-0.1.0 → optio_codex-0.2.0}/src/optio_codex.egg-info/SOURCES.txt +3 -0
  12. {optio_codex-0.1.0 → optio_codex-0.2.0}/src/optio_codex.egg-info/requires.txt +2 -1
  13. {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_config.py +21 -3
  14. optio_codex-0.2.0/tests/test_config_hooks.py +321 -0
  15. {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_conversation.py +232 -4
  16. optio_codex-0.2.0/tests/test_conversation_controls.py +143 -0
  17. {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_conversation_listener.py +46 -9
  18. optio_codex-0.2.0/tests/test_file_upload.py +30 -0
  19. optio_codex-0.2.0/tests/test_input_wiring.py +99 -0
  20. optio_codex-0.2.0/tests/test_models.py +143 -0
  21. {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_session_conversation.py +77 -7
  22. {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_session_lease.py +1 -1
  23. {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_session_local.py +7 -7
  24. {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_session_remote.py +8 -53
  25. {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_session_resume.py +1 -1
  26. {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_session_sandbox.py +2 -2
  27. {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_session_seed.py +1 -1
  28. optio_codex-0.1.0/src/optio_codex/models.py +0 -68
  29. optio_codex-0.1.0/tests/test_file_upload.py +0 -126
  30. optio_codex-0.1.0/tests/test_models.py +0 -67
  31. {optio_codex-0.1.0 → optio_codex-0.2.0}/README.md +0 -0
  32. {optio_codex-0.1.0 → optio_codex-0.2.0}/setup.cfg +0 -0
  33. {optio_codex-0.1.0 → optio_codex-0.2.0}/src/optio_codex/__init__.py +0 -0
  34. {optio_codex-0.1.0 → optio_codex-0.2.0}/src/optio_codex/cred_watcher.py +0 -0
  35. {optio_codex-0.1.0 → optio_codex-0.2.0}/src/optio_codex/prompt.py +0 -0
  36. {optio_codex-0.1.0 → optio_codex-0.2.0}/src/optio_codex/seed_manifest.py +0 -0
  37. {optio_codex-0.1.0 → optio_codex-0.2.0}/src/optio_codex/snapshots.py +0 -0
  38. {optio_codex-0.1.0 → optio_codex-0.2.0}/src/optio_codex/verify.py +0 -0
  39. {optio_codex-0.1.0 → optio_codex-0.2.0}/src/optio_codex.egg-info/dependency_links.txt +0 -0
  40. {optio_codex-0.1.0 → optio_codex-0.2.0}/src/optio_codex.egg-info/top_level.txt +0 -0
  41. {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_await_codex_gone.py +0 -0
  42. {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_codex_cache.py +0 -0
  43. {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_cred_watcher.py +0 -0
  44. {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_file_download.py +0 -0
  45. {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_fs_allowlist.py +0 -0
  46. {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_host_actions.py +0 -0
  47. {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_import.py +0 -0
  48. {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_kill_ttyd_by_socket.py +0 -0
  49. {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_prompt.py +0 -0
  50. {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_real_codex_conversation.py +0 -0
  51. {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_real_codex_seed_resume.py +0 -0
  52. {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_real_codex_session.py +0 -0
  53. {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_sandbox_enforce.py +0 -0
  54. {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_seed_manifest.py +0 -0
  55. {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_snapshots.py +0 -0
  56. {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_teardown_session_tree.py +0 -0
  57. {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_verify.py +0 -0
  58. {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_workdir_trust.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: optio-codex
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Run OpenAI Codex as an optio task; local subprocess; ttyd-served TUI iframe.
5
5
  Author-email: Kristof Csillag <kristof.csillag@deai-labs.com>
6
6
  License-Expression: Apache-2.0
@@ -22,12 +22,13 @@ Requires-Python: >=3.11
22
22
  Description-Content-Type: text/markdown
23
23
  Requires-Dist: optio-core<0.4,>=0.3
24
24
  Requires-Dist: optio-host<0.3,>=0.2
25
- Requires-Dist: optio-agents<0.4,>=0.3
25
+ Requires-Dist: optio-agents<0.5,>=0.4
26
26
  Requires-Dist: asyncssh>=2.14
27
27
  Requires-Dist: aiohttp>=3.9
28
28
  Provides-Extra: dev
29
29
  Requires-Dist: pytest>=8.0; extra == "dev"
30
30
  Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
31
+ Requires-Dist: pytest-xdist>=3.6; extra == "dev"
31
32
 
32
33
  # optio-codex
33
34
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "optio-codex"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Run OpenAI Codex as an optio task; local subprocess; ttyd-served TUI iframe."
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -28,7 +28,7 @@ classifiers = [
28
28
  dependencies = [
29
29
  "optio-core>=0.3,<0.4",
30
30
  "optio-host>=0.2,<0.3",
31
- "optio-agents>=0.3,<0.4",
31
+ "optio-agents>=0.4,<0.5",
32
32
  "asyncssh>=2.14",
33
33
  "aiohttp>=3.9",
34
34
  ]
@@ -37,6 +37,7 @@ dependencies = [
37
37
  dev = [
38
38
  "pytest>=8.0",
39
39
  "pytest-asyncio>=0.23",
40
+ "pytest-xdist>=3.6",
40
41
  ]
41
42
 
42
43
  [project.urls]
@@ -53,7 +53,7 @@ Client -> server REQUESTS (have ``id``, expect a ``result``):
53
53
  IMMEDIATE ACK {turn:{id, status:"inProgress", items:[]}} — the ACK is
54
54
  NOT the turn end. Per-turn overrides (``model``) become the default for
55
55
  subsequent turns on the thread == the INLINE model-switch seam
56
- (request_model_change pins the next turn's model; no wire write).
56
+ (set_control("model", …) pins the next turn's model; no wire write).
57
57
  * ``turn/interrupt`` {threadId, turnId} -> {} ACK; the completion signal
58
58
  is the subsequent ``turn/completed`` with status "interrupted".
59
59
 
@@ -100,6 +100,7 @@ from optio_agents.conversation import (
100
100
  PermissionDecision,
101
101
  PermissionRequest,
102
102
  )
103
+ from optio_codex import models as codex_models
103
104
 
104
105
  _LOG = logging.getLogger(__name__)
105
106
 
@@ -122,6 +123,7 @@ class CodexConversation:
122
123
  agent_label: str = "codex",
123
124
  permission_gate: bool = False,
124
125
  model: str | None = None,
126
+ reasoning_effort: str | None = None,
125
127
  sandbox: str = "workspace-write",
126
128
  resume_thread_id: str | None = None,
127
129
  ) -> None:
@@ -135,6 +137,11 @@ class CodexConversation:
135
137
  # With the gate on codex must ask (on-request); without it, never.
136
138
  self._approval_policy = "on-request" if permission_gate else "never"
137
139
  self._resume_thread_id = resume_thread_id
140
+ # Prior conversation captured at bootstrap when resuming: thread/resume
141
+ # returns the whole thing inline as thread.turns[].items[] (verified
142
+ # live). Stashed here (bootstrap runs before any listener subscribes) so
143
+ # replay_history can backfill it into on_event once the listener is up.
144
+ self._resume_turns: list = []
138
145
  self._handle = None
139
146
  # Captured at bootstrap; Plan B's snapshot sessionId seam reads
140
147
  # thread_id at capture time.
@@ -147,6 +154,12 @@ class CodexConversation:
147
154
  self.model_list: dict | None = None
148
155
  self.current_model_id: str | None = None
149
156
  self._requested_model: str | None = None
157
+ # Graded reasoning effort armed for the next turn/start (codex's
158
+ # per-turn `effort` override; None → codex uses the model default).
159
+ # Seeded from config at launch (initial effort, like model), updated
160
+ # INLINE by set_control("reasoning_effort", …), and reconciled to a
161
+ # valid value / dropped when the model changes.
162
+ self._requested_effort: str | None = reasoning_effort
150
163
  self._pending = 0 # user turns awaiting turn/completed
151
164
  self._closed = asyncio.Event()
152
165
  self._close_reason: str | None = None
@@ -240,6 +253,57 @@ class CodexConversation:
240
253
  f"codex {method} returned no thread id: {result!r}"
241
254
  )
242
255
  self.current_model_id = result.get("model") or self._model
256
+ # thread/resume returns the FULL prior conversation inline as
257
+ # thread.turns[].items[] (verified live); stash it for replay_history to
258
+ # backfill into on_event after the listener subscribes. thread/start has
259
+ # no prior turns → [].
260
+ self._resume_turns = thread.get("turns") or []
261
+
262
+ async def replay_history(self) -> int:
263
+ """Backfill on_event with the RESUMED thread's prior turns.
264
+
265
+ On resume, thread/resume returned the whole prior conversation inline as
266
+ result.thread.turns[].items[], which bootstrap stashed — but the
267
+ listener's replay buffer starts empty and only accrues LIVE turns, so a
268
+ viewer attaching after a resume would see none of the prior conversation.
269
+ So re-emit each stored item as the ``item/completed`` notification the
270
+ live stream would have sent for it, plus a ``turn/completed`` after each
271
+ turn, through the SAME on_event fan-out live items use — landing them in
272
+ the listener's buffer so a late viewer reconstructs the full prior
273
+ history, each item rendered identically to a live one (the UI reducer
274
+ treats a replayed item/completed exactly like a streamed one — its
275
+ untracked-completion branch IS the replay-gap path; the per-turn
276
+ turn/completed closes each turn's answer bubble and opens the next).
277
+
278
+ Drives ONLY on_event — no answer accumulation, no on_message: a resume
279
+ must not synthesise a phantom answer turn (mirrors optio-antigravity's
280
+ replay_history). Safe with no history (thread/start, or a resume with no
281
+ prior turns): emits nothing, returns 0. Returns the number of events
282
+ emitted.
283
+ """
284
+ count = 0
285
+ for turn in self._resume_turns:
286
+ turn_id = turn.get("id")
287
+ for item in turn.get("items") or []:
288
+ await self._emit_event({"method": "item/completed", "params": {
289
+ "threadId": self.thread_id, "turnId": turn_id, "item": item}})
290
+ count += 1
291
+ # Close this historic turn's bubble and open the next's — a live
292
+ # multi-turn stream ends every turn with turn/completed, so replay
293
+ # must too (else the reducer coalesces all turns into one bubble).
294
+ await self._emit_event({"method": "turn/completed", "params": {
295
+ "threadId": self.thread_id,
296
+ "turn": {"id": turn_id, "status": "completed"}}})
297
+ count += 1
298
+ return count
299
+
300
+ async def _emit_event(self, obj: dict) -> None:
301
+ """Fan one event out to on_event subscribers directly — the tail of the
302
+ live _event_queue -> _dispatch_loop -> _event_handlers path, reused by
303
+ the resume-history backfill (which must NOT touch turn/answer state, so
304
+ it bypasses _route/_on_notification and the on_message coalescer)."""
305
+ for handler in list(self._event_handlers):
306
+ await self._call_handler(handler, obj, "on_event")
243
307
 
244
308
  async def run_reader(self) -> None:
245
309
  """Drain stdout until EOF; route JSON-RPC messages. Owned by the
@@ -432,6 +496,11 @@ class CodexConversation:
432
496
  # Inline model switch: the override becomes the thread default
433
497
  # for subsequent turns (app-server contract).
434
498
  params["model"] = self._requested_model
499
+ if self._requested_effort is not None:
500
+ # Inline graded-reasoning override: `effort` rides each turn/start
501
+ # (the app-server has no dedicated set-effort request). Seeded from
502
+ # config at launch, then moved by the reasoning_effort slider.
503
+ params["effort"] = self._requested_effort
435
504
  try:
436
505
  await self._write_json({
437
506
  "id": rid, "method": "turn/start", "params": params,
@@ -482,19 +551,51 @@ class CodexConversation:
482
551
  "threadId": self.thread_id, "turnId": self.current_turn_id,
483
552
  })
484
553
 
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)."""
554
+ async def set_control(self, control_id: str, value) -> None:
555
+ """Push a session-control value change to the native transport
556
+ (generalizes model selection). Codex exposes two controls, both
557
+ switched INLINE with NO dedicated wire write — a ``model`` / ``effort``
558
+ override on the next ``turn/start`` becomes the thread default for
559
+ subsequent turns (app-server contract; see models.py):
560
+
561
+ * ``model`` — pins the model on the next turn. Because graded-reasoning
562
+ support is model-dependent, a model switch RE-DERIVES the control set
563
+ for the new model and re-emits it as an ``x-optio-control-update`` so
564
+ the effort slider appears / disappears / relevels to follow the model
565
+ (reconciling the armed effort to a value the new model supports, or
566
+ dropping it when the new model has no graded reasoning).
567
+ * ``reasoning_effort`` — arms ``effort`` for the next turn (the client
568
+ folds the slider value optimistically; no re-emit needed).
569
+
570
+ Unknown control ids are ignored (no-op)."""
571
+ if control_id not in ("model", "reasoning_effort"):
572
+ return # codex exposes only the model + reasoning_effort controls
490
573
  if self._closed.is_set():
491
574
  raise ConversationClosed(self._close_reason or "conversation closed")
492
575
  if self.thread_id is None:
493
576
  raise RuntimeError(
494
- "CodexConversation.request_model_change before bootstrap() completed"
577
+ "CodexConversation.set_control before bootstrap() completed"
495
578
  )
496
- self._requested_model = model
497
- self.current_model_id = model # optimistic; the next turn pins it
579
+ if control_id == "reasoning_effort":
580
+ self._requested_effort = value # armed for the next turn/start
581
+ return
582
+ # control_id == "model": inline switch + reconcile effort to the new
583
+ # model, then re-emit the re-derived control set.
584
+ self._requested_model = value
585
+ self.current_model_id = value # optimistic; the next turn pins it
586
+ model_list = codex_models.parse_model_list(self.model_list)
587
+ levels, default = codex_models.effort_for_model(model_list, value)
588
+ if self._requested_effort is not None and self._requested_effort not in levels:
589
+ # The armed effort is invalid for the new model: fall back to the
590
+ # new model's default (still graded) or drop it (no graded effort).
591
+ self._requested_effort = default if levels else None
592
+ controls = codex_models.build_controls(
593
+ model_list, value, self._requested_effort,
594
+ )
595
+ await self._emit_event({
596
+ "type": "x-optio-control-update",
597
+ "controls": [c.to_dict() for c in controls],
598
+ })
498
599
 
499
600
  async def close(self) -> None:
500
601
  self.close_requested.set()
@@ -7,10 +7,8 @@ widget proxy (which injects the basic-auth credential):
7
7
  monotonic seq; Last-Event-ID resumes without dupes.
8
8
  POST /send — {text} -> conversation.send
9
9
  POST /interrupt — {} -> conversation.interrupt
10
- POST /model — {model} -> conversation.request_model_change
11
- (INLINE: pins the next turn/start's model — no restart)
12
- POST /upload — multipart {file} parts -> upload_writer; returns
13
- {ok, files:[{filename, path}]}
10
+ POST /control — {id, value} -> conversation.set_control
11
+ (model: INLINE — pins the next turn/start's model, no restart)
14
12
  GET /download — ?path=<relpath> -> download_reader; returns the
15
13
  bytes with Content-Disposition: attachment
16
14
  POST /permission — {request_id, behavior, updated_input?, message?}
@@ -53,15 +51,11 @@ _STOP = object()
53
51
  class ConversationListener:
54
52
  def __init__(
55
53
  self, conversation, *, password: str,
56
- upload_writer: "Callable[[str, bytes], Awaitable[str]] | None" = None,
57
- max_upload_bytes: int = 10_000_000,
58
54
  download_reader: "Callable[[str], Awaitable[tuple[bytes, str]]] | None" = None,
59
55
  max_download_bytes: int = 10_000_000,
60
56
  ) -> None:
61
57
  self._conversation = conversation
62
58
  self._password = password
63
- self._upload_writer = upload_writer
64
- self._max_upload_bytes = max_upload_bytes
65
59
  self._download_reader = download_reader
66
60
  self._max_download_bytes = max_download_bytes
67
61
  self._buffer: deque[tuple[int, dict]] = deque(maxlen=BUFFER_MAXLEN)
@@ -192,35 +186,6 @@ class ConversationListener:
192
186
  return web.json_response({"ok": False, "reason": "closed"}, status=409)
193
187
  return web.json_response({"ok": True})
194
188
 
195
- async def _handle_upload(self, request: web.Request) -> web.Response:
196
- if not self._authorized(request):
197
- return web.json_response({"ok": False}, status=401)
198
- if self._upload_writer is None:
199
- return web.json_response({"ok": False, "reason": "no-writer"}, status=409)
200
- stored: list[dict] = []
201
- try:
202
- reader = await request.multipart()
203
- except Exception: # noqa: BLE001
204
- return web.json_response({"ok": False, "reason": "bad-multipart"}, status=400)
205
- while True:
206
- part = await reader.next()
207
- if part is None:
208
- break
209
- if part.name != "file":
210
- continue
211
- filename = part.filename or "file"
212
- buf = bytearray()
213
- while True:
214
- chunk = await part.read_chunk()
215
- if not chunk:
216
- break
217
- buf.extend(chunk)
218
- if len(buf) > self._max_upload_bytes:
219
- return web.json_response({"ok": False, "reason": "too-large"}, status=413)
220
- path = await self._upload_writer(filename, bytes(buf))
221
- stored.append({"filename": filename, "path": path})
222
- return web.json_response({"ok": True, "files": stored})
223
-
224
189
  async def _handle_download(self, request: web.Request) -> web.Response:
225
190
  if not self._authorized(request):
226
191
  return web.json_response({"ok": False}, status=401)
@@ -246,18 +211,19 @@ class ConversationListener:
246
211
  },
247
212
  )
248
213
 
249
- async def _handle_model(self, request: web.Request) -> web.Response:
214
+ async def _handle_control(self, request: web.Request) -> web.Response:
250
215
  if not self._authorized(request):
251
216
  return web.json_response({"ok": False}, status=401)
252
217
  try:
253
218
  payload = await request.json()
254
219
  except Exception: # noqa: BLE001
255
220
  return web.json_response({"ok": False, "reason": "bad-json"}, status=400)
256
- model = payload.get("model")
257
- if not isinstance(model, str) or not model:
258
- return web.json_response({"ok": False, "reason": "bad-model"}, status=400)
221
+ cid = payload.get("id")
222
+ if not isinstance(cid, str) or not cid:
223
+ return web.json_response({"ok": False, "reason": "bad-id"}, status=400)
224
+ value = payload.get("value")
259
225
  try:
260
- self._conversation.request_model_change(model)
226
+ await self._conversation.set_control(cid, value)
261
227
  except ConversationClosed:
262
228
  return web.json_response({"ok": False, "reason": "closed"}, status=409)
263
229
  return web.json_response({"ok": True})
@@ -290,8 +256,7 @@ class ConversationListener:
290
256
  app.router.add_get("/events", self._handle_events)
291
257
  app.router.add_post("/send", self._handle_send)
292
258
  app.router.add_post("/interrupt", self._handle_interrupt)
293
- app.router.add_post("/model", self._handle_model)
294
- app.router.add_post("/upload", self._handle_upload)
259
+ app.router.add_post("/control", self._handle_control)
295
260
  app.router.add_get("/download", self._handle_download)
296
261
  app.router.add_post("/permission", self._handle_permission)
297
262
  self._runner = web.AppRunner(app, shutdown_timeout=SHUTDOWN_TIMEOUT_S)
@@ -89,16 +89,17 @@ def resolve_sandbox_settings(
89
89
  """Resolve ``fs_isolation``/``sandbox``/``extra_allowed_dirs``/
90
90
  ``network_access`` into one :class:`SandboxSettings`.
91
91
 
92
- ``ro`` grants are skipped (codex never restricts reads — see module
93
- docstring); ``rw`` grants become ``writable_roots`` with ``~/`` expanded
94
- against ``host_home``. Roots/network only apply to workspace-write
95
- (validated in CodexTaskConfig.__post_init__).
92
+ ``ro``/``rox`` grants are skipped (codex never restricts reads — see module
93
+ docstring); ``rw``/``rwx`` grants become ``writable_roots`` with ``~/``
94
+ expanded against ``host_home`` (codex's native sandbox has no execute bit,
95
+ so ``rwx``==``rw`` and ``rox``==``ro``). Roots/network only apply to
96
+ workspace-write (validated in CodexTaskConfig.__post_init__).
96
97
  """
97
98
  mode = config.effective_sandbox_mode
98
99
  roots: list[str] = []
99
100
  if mode == "workspace-write":
100
101
  for ad in config.extra_allowed_dirs or []:
101
- if ad.mode == "rw":
102
+ if ad.mode in ("rw", "rwx"):
102
103
  roots.append(_expand_home(ad.path, host_home).rstrip("/"))
103
104
  return SandboxSettings(
104
105
  mode=mode,
@@ -13,6 +13,7 @@ from datetime import datetime, timezone
13
13
  from typing import TYPE_CHECKING
14
14
 
15
15
  from optio_agents import RESUME_NOTICE, SYSTEM_MESSAGE_PREFIX
16
+ from optio_agents import tmux_input as _tmux_input
16
17
  from optio_host.host import proc_wait
17
18
 
18
19
  if TYPE_CHECKING:
@@ -104,7 +105,7 @@ async def resolve_codex(
104
105
  return candidate
105
106
  raise RuntimeError(
106
107
  f"codex not present at {candidate!r} on host "
107
- f"(codex_install_dir={install_dir!r})."
108
+ f"(install_dir={install_dir!r})."
108
109
  )
109
110
 
110
111
  result = await host.run_command("bash -lc 'command -v codex'")
@@ -127,7 +128,7 @@ async def _resolve_codex_cache_dir(host: "Host", override: str | None) -> str:
127
128
  """Resolve the optio-owned codex binary-cache dir as an absolute worker
128
129
  path.
129
130
 
130
- ``override`` (``config.codex_install_dir``) wins. Otherwise the worker's
131
+ ``override`` (``config.install_dir``) wins. Otherwise the worker's
131
132
  real env decides via a shell echo: ``OPTIO_CODEX_CACHE_DIR`` else
132
133
  ``${XDG_CACHE_HOME:-$HOME/.cache}/optio-codex/bin`` — resolved on the
133
134
  host so RemoteHost gets the remote location. Mirrors grok's
@@ -953,22 +954,25 @@ async def tmux_session_alive(
953
954
  async def send_text_to_codex(
954
955
  host: "Host", tmux_path: str, tmux_socket: str, tmux_session: str, text: str,
955
956
  ) -> None:
956
- s = shlex.quote(tmux_socket)
957
- sess = shlex.quote(tmux_session)
958
- tp = shlex.quote(tmux_path)
959
- buf = "optio-feedback"
960
- cmd = (
961
- f"{tp} -S {s} set-buffer -b {buf} -- {shlex.quote(text)} && "
962
- f"{tp} -S {s} paste-buffer -d -b {buf} -t {sess} && "
963
- f"sleep {_SUBMIT_SETTLE_S} && "
964
- f"{tp} -S {s} send-keys -t {sess} Enter"
957
+ """Fake-type a message into the codex TUI and submit it.
958
+
959
+ Thin wrapper over the shared
960
+ :func:`optio_agents.tmux_input.send_text_to_tmux`, pinned to codex's buffer
961
+ name (``optio-feedback``) and settle (``_SUBMIT_SETTLE_S``) so behavior is
962
+ identical to the previous inline implementation."""
963
+ await _tmux_input.send_text_to_tmux(
964
+ host, tmux_path, tmux_socket, tmux_session, text,
965
+ buffer="optio-feedback", submit_settle=_SUBMIT_SETTLE_S,
965
966
  )
966
- result = await host.run_command(cmd)
967
- if result.exit_code != 0:
968
- raise RuntimeError(
969
- f"send_text_to_codex: tmux injection failed "
970
- f"(exit {result.exit_code}): {result.stderr!r}"
971
- )
967
+
968
+
969
+ async def send_key_to_codex(
970
+ host: "Host", tmux_path: str, tmux_socket: str, tmux_session: str, key: str,
971
+ ) -> None:
972
+ """Send a single navigation keystroke into the codex TUI (iframe-input
973
+ empty-box TUI nav). Thin wrapper over
974
+ :func:`optio_agents.tmux_input.send_key_to_tmux`."""
975
+ await _tmux_input.send_key_to_tmux(host, tmux_path, tmux_socket, tmux_session, key)
972
976
 
973
977
 
974
978
  # --- resume bookkeeping (Stage 2; adapted from optio-grok) ------------------
@@ -0,0 +1,126 @@
1
+ """Available-model list for the codex conversation widget's model picker.
2
+
3
+ ============================================================================
4
+ MODEL-SWITCH MECHANISM — pinned from the app-server contract (codex-cli
5
+ 0.142.5 schema dump + upstream README; see the conversation docstring).
6
+ ============================================================================
7
+
8
+ Decision: **INLINE** (opencode/grok-style), NOT restart (claudecode-style) —
9
+ and codex needs NO dedicated set-model request at all: a ``model`` override
10
+ on ``turn/start`` "become[s] the default for subsequent turns" (README,
11
+ turn/start). So ``CodexConversation.set_control("model", …)`` just pins the
12
+ model sent with the next ``turn/start``; the session body needs no
13
+ model_change_requested restart loop.
14
+
15
+ MODEL LIST source: the ``model/list`` request, answered in-session
16
+ (``{data:[Model], nextCursor}``; ``Model`` carries ``id``, ``displayName``,
17
+ ``hidden``, ``isDefault``). The conversation captures the raw result at
18
+ bootstrap; this module maps it to the widget shape. There is no CLI listing
19
+ tier (unlike grok) — live result → static fallback, nothing in between.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ from optio_agents.session_controls import (
24
+ SessionControl,
25
+ effort_control,
26
+ model_control,
27
+ )
28
+
29
+ # Shown when the live model/list is unavailable (fake agents, offline, error
30
+ # response). Version-sensitive vendor strings; update alongside the pinned
31
+ # codex-cli version. ``efforts`` (per-model graded reasoning levels) is empty
32
+ # in the fallback — the effort control only appears once the live model/list
33
+ # advertises ``supportedReasoningEfforts``.
34
+ FALLBACK_MODELS: dict = {
35
+ "models": [
36
+ {"id": "gpt-5.5", "label": "GPT-5.5", "disabled": False,
37
+ "efforts": [], "defaultEffort": None},
38
+ {"id": "gpt-5.4-mini", "label": "GPT-5.4 Mini", "disabled": False,
39
+ "efforts": [], "defaultEffort": None},
40
+ ],
41
+ "default": "gpt-5.5",
42
+ }
43
+
44
+
45
+ def parse_model_list(result: "dict | None") -> dict:
46
+ """Map a raw ``model/list`` result to the widget shape
47
+ ``{models:[{id,label,disabled,efforts,defaultEffort}], default}``.
48
+
49
+ Hidden models are omitted; ``default`` is the ``isDefault`` entry's id
50
+ (None when absent). Each model also carries its graded reasoning capability
51
+ read from the app-server ``Model`` schema: ``efforts`` =
52
+ ``supportedReasoningEfforts`` (ordered; empty ⇒ no graded effort for that
53
+ model) and ``defaultEffort`` = ``defaultReasoningEffort``. Missing /
54
+ malformed input returns the static fallback (never raises, never falsely
55
+ empties the picker).
56
+ """
57
+ if not isinstance(result, dict):
58
+ return _copy_fallback()
59
+ data = result.get("data")
60
+ if not isinstance(data, list):
61
+ return _copy_fallback()
62
+ out: list[dict] = []
63
+ default: str | None = None
64
+ for m in data:
65
+ if not isinstance(m, dict):
66
+ continue
67
+ mid = m.get("id")
68
+ if not isinstance(mid, str) or not mid or m.get("hidden"):
69
+ continue
70
+ efforts = m.get("supportedReasoningEfforts")
71
+ out.append({
72
+ "id": mid,
73
+ "label": m.get("displayName") or mid,
74
+ "disabled": False,
75
+ "efforts": [
76
+ e["reasoningEffort"] for e in efforts
77
+ if isinstance(e, dict) and isinstance(e.get("reasoningEffort"), str)
78
+ ] if isinstance(efforts, list) else [],
79
+ "defaultEffort": m.get("defaultReasoningEffort"),
80
+ })
81
+ if m.get("isDefault"):
82
+ default = mid
83
+ if not out:
84
+ return _copy_fallback()
85
+ return {"models": out, "default": default}
86
+
87
+
88
+ def effort_for_model(
89
+ model_list: dict, model_id: "str | None"
90
+ ) -> "tuple[list[str], str | None]":
91
+ """Return ``(levels, default)`` graded-reasoning capability for
92
+ ``model_id`` from a ``parse_model_list`` result. ``([], None)`` when the
93
+ model is unknown or advertises no ``supportedReasoningEfforts``."""
94
+ for m in model_list.get("models", []):
95
+ if m.get("id") == model_id:
96
+ return list(m.get("efforts") or []), m.get("defaultEffort")
97
+ return [], None
98
+
99
+
100
+ def build_controls(
101
+ model_list: dict,
102
+ current_model: "str | None",
103
+ requested_effort: "str | None",
104
+ ) -> "list[SessionControl]":
105
+ """Build codex's session-control set for the current model: the
106
+ ``id="model"`` picker plus, WHEN the current model supports graded
107
+ reasoning, the ``id="reasoning_effort"`` slider. An unsupported model
108
+ yields no effort control (Spec-B model-dependent presence). The slider's
109
+ current value is the requested effort when it is valid for the model, else
110
+ the model's default. Re-derived on every model change so effort presence /
111
+ levels follow the model."""
112
+ controls: list[SessionControl] = [
113
+ model_control(models=model_list["models"], current=current_model),
114
+ ]
115
+ levels, default = effort_for_model(model_list, current_model)
116
+ if levels:
117
+ current = requested_effort if requested_effort in levels else default
118
+ controls.append(effort_control(levels=levels, current=current))
119
+ return controls
120
+
121
+
122
+ def _copy_fallback() -> dict:
123
+ return {
124
+ "models": [dict(m) for m in FALLBACK_MODELS["models"]],
125
+ "default": FALLBACK_MODELS["default"],
126
+ }