optio-codex 0.1.0__tar.gz → 0.2.1__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 (60) hide show
  1. {optio_codex-0.1.0 → optio_codex-0.2.1}/PKG-INFO +3 -2
  2. {optio_codex-0.1.0 → optio_codex-0.2.1}/pyproject.toml +3 -2
  3. {optio_codex-0.1.0 → optio_codex-0.2.1}/src/optio_codex/__init__.py +2 -0
  4. {optio_codex-0.1.0 → optio_codex-0.2.1}/src/optio_codex/conversation.py +112 -10
  5. {optio_codex-0.1.0 → optio_codex-0.2.1}/src/optio_codex/conversation_listener.py +9 -44
  6. {optio_codex-0.1.0 → optio_codex-0.2.1}/src/optio_codex/fs_allowlist.py +6 -5
  7. {optio_codex-0.1.0 → optio_codex-0.2.1}/src/optio_codex/host_actions.py +21 -17
  8. optio_codex-0.2.1/src/optio_codex/info.py +7 -0
  9. optio_codex-0.2.1/src/optio_codex/models.py +126 -0
  10. {optio_codex-0.1.0 → optio_codex-0.2.1}/src/optio_codex/session.py +252 -47
  11. {optio_codex-0.1.0 → optio_codex-0.2.1}/src/optio_codex/types.py +132 -70
  12. {optio_codex-0.1.0 → optio_codex-0.2.1}/src/optio_codex.egg-info/PKG-INFO +3 -2
  13. {optio_codex-0.1.0 → optio_codex-0.2.1}/src/optio_codex.egg-info/SOURCES.txt +5 -0
  14. {optio_codex-0.1.0 → optio_codex-0.2.1}/src/optio_codex.egg-info/requires.txt +2 -1
  15. optio_codex-0.2.1/tests/test_agent_info.py +15 -0
  16. {optio_codex-0.1.0 → optio_codex-0.2.1}/tests/test_config.py +21 -3
  17. optio_codex-0.2.1/tests/test_config_hooks.py +321 -0
  18. {optio_codex-0.1.0 → optio_codex-0.2.1}/tests/test_conversation.py +232 -4
  19. optio_codex-0.2.1/tests/test_conversation_controls.py +143 -0
  20. {optio_codex-0.1.0 → optio_codex-0.2.1}/tests/test_conversation_listener.py +46 -9
  21. optio_codex-0.2.1/tests/test_file_upload.py +30 -0
  22. optio_codex-0.2.1/tests/test_input_wiring.py +99 -0
  23. optio_codex-0.2.1/tests/test_models.py +143 -0
  24. {optio_codex-0.1.0 → optio_codex-0.2.1}/tests/test_session_conversation.py +77 -7
  25. {optio_codex-0.1.0 → optio_codex-0.2.1}/tests/test_session_lease.py +1 -1
  26. {optio_codex-0.1.0 → optio_codex-0.2.1}/tests/test_session_local.py +7 -7
  27. {optio_codex-0.1.0 → optio_codex-0.2.1}/tests/test_session_remote.py +8 -53
  28. {optio_codex-0.1.0 → optio_codex-0.2.1}/tests/test_session_resume.py +1 -1
  29. {optio_codex-0.1.0 → optio_codex-0.2.1}/tests/test_session_sandbox.py +2 -2
  30. {optio_codex-0.1.0 → optio_codex-0.2.1}/tests/test_session_seed.py +1 -1
  31. optio_codex-0.1.0/src/optio_codex/models.py +0 -68
  32. optio_codex-0.1.0/tests/test_file_upload.py +0 -126
  33. optio_codex-0.1.0/tests/test_models.py +0 -67
  34. {optio_codex-0.1.0 → optio_codex-0.2.1}/README.md +0 -0
  35. {optio_codex-0.1.0 → optio_codex-0.2.1}/setup.cfg +0 -0
  36. {optio_codex-0.1.0 → optio_codex-0.2.1}/src/optio_codex/cred_watcher.py +0 -0
  37. {optio_codex-0.1.0 → optio_codex-0.2.1}/src/optio_codex/prompt.py +0 -0
  38. {optio_codex-0.1.0 → optio_codex-0.2.1}/src/optio_codex/seed_manifest.py +0 -0
  39. {optio_codex-0.1.0 → optio_codex-0.2.1}/src/optio_codex/snapshots.py +0 -0
  40. {optio_codex-0.1.0 → optio_codex-0.2.1}/src/optio_codex/verify.py +0 -0
  41. {optio_codex-0.1.0 → optio_codex-0.2.1}/src/optio_codex.egg-info/dependency_links.txt +0 -0
  42. {optio_codex-0.1.0 → optio_codex-0.2.1}/src/optio_codex.egg-info/top_level.txt +0 -0
  43. {optio_codex-0.1.0 → optio_codex-0.2.1}/tests/test_await_codex_gone.py +0 -0
  44. {optio_codex-0.1.0 → optio_codex-0.2.1}/tests/test_codex_cache.py +0 -0
  45. {optio_codex-0.1.0 → optio_codex-0.2.1}/tests/test_cred_watcher.py +0 -0
  46. {optio_codex-0.1.0 → optio_codex-0.2.1}/tests/test_file_download.py +0 -0
  47. {optio_codex-0.1.0 → optio_codex-0.2.1}/tests/test_fs_allowlist.py +0 -0
  48. {optio_codex-0.1.0 → optio_codex-0.2.1}/tests/test_host_actions.py +0 -0
  49. {optio_codex-0.1.0 → optio_codex-0.2.1}/tests/test_import.py +0 -0
  50. {optio_codex-0.1.0 → optio_codex-0.2.1}/tests/test_kill_ttyd_by_socket.py +0 -0
  51. {optio_codex-0.1.0 → optio_codex-0.2.1}/tests/test_prompt.py +0 -0
  52. {optio_codex-0.1.0 → optio_codex-0.2.1}/tests/test_real_codex_conversation.py +0 -0
  53. {optio_codex-0.1.0 → optio_codex-0.2.1}/tests/test_real_codex_seed_resume.py +0 -0
  54. {optio_codex-0.1.0 → optio_codex-0.2.1}/tests/test_real_codex_session.py +0 -0
  55. {optio_codex-0.1.0 → optio_codex-0.2.1}/tests/test_sandbox_enforce.py +0 -0
  56. {optio_codex-0.1.0 → optio_codex-0.2.1}/tests/test_seed_manifest.py +0 -0
  57. {optio_codex-0.1.0 → optio_codex-0.2.1}/tests/test_snapshots.py +0 -0
  58. {optio_codex-0.1.0 → optio_codex-0.2.1}/tests/test_teardown_session_tree.py +0 -0
  59. {optio_codex-0.1.0 → optio_codex-0.2.1}/tests/test_verify.py +0 -0
  60. {optio_codex-0.1.0 → optio_codex-0.2.1}/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.1
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.1"
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]
@@ -9,6 +9,7 @@ from optio_host import (
9
9
  SSHConfig,
10
10
  )
11
11
 
12
+ from optio_codex.info import AGENT_INFO
12
13
  from optio_codex.seed_manifest import (
13
14
  CODEX_CRED_MANIFEST,
14
15
  CODEX_SEED_MANIFEST,
@@ -38,6 +39,7 @@ _logging.getLogger("asyncssh").setLevel(_logging.WARNING)
38
39
 
39
40
 
40
41
  __all__ = [
42
+ "AGENT_INFO",
41
43
  "create_codex_task",
42
44
  "run_codex_session",
43
45
  "AllowedDir",
@@ -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,8 @@ from optio_agents.conversation import (
100
100
  PermissionDecision,
101
101
  PermissionRequest,
102
102
  )
103
+ from optio_codex import models as codex_models
104
+ from optio_codex.info import AGENT_INFO
103
105
 
104
106
  _LOG = logging.getLogger(__name__)
105
107
 
@@ -119,9 +121,10 @@ class CodexConversation:
119
121
  self,
120
122
  *,
121
123
  cwd: str,
122
- agent_label: str = "codex",
124
+ agent_label: str = AGENT_INFO.slug,
123
125
  permission_gate: bool = False,
124
126
  model: str | None = None,
127
+ reasoning_effort: str | None = None,
125
128
  sandbox: str = "workspace-write",
126
129
  resume_thread_id: str | None = None,
127
130
  ) -> None:
@@ -135,6 +138,11 @@ class CodexConversation:
135
138
  # With the gate on codex must ask (on-request); without it, never.
136
139
  self._approval_policy = "on-request" if permission_gate else "never"
137
140
  self._resume_thread_id = resume_thread_id
141
+ # Prior conversation captured at bootstrap when resuming: thread/resume
142
+ # returns the whole thing inline as thread.turns[].items[] (verified
143
+ # live). Stashed here (bootstrap runs before any listener subscribes) so
144
+ # replay_history can backfill it into on_event once the listener is up.
145
+ self._resume_turns: list = []
138
146
  self._handle = None
139
147
  # Captured at bootstrap; Plan B's snapshot sessionId seam reads
140
148
  # thread_id at capture time.
@@ -147,6 +155,12 @@ class CodexConversation:
147
155
  self.model_list: dict | None = None
148
156
  self.current_model_id: str | None = None
149
157
  self._requested_model: str | None = None
158
+ # Graded reasoning effort armed for the next turn/start (codex's
159
+ # per-turn `effort` override; None → codex uses the model default).
160
+ # Seeded from config at launch (initial effort, like model), updated
161
+ # INLINE by set_control("reasoning_effort", …), and reconciled to a
162
+ # valid value / dropped when the model changes.
163
+ self._requested_effort: str | None = reasoning_effort
150
164
  self._pending = 0 # user turns awaiting turn/completed
151
165
  self._closed = asyncio.Event()
152
166
  self._close_reason: str | None = None
@@ -240,6 +254,57 @@ class CodexConversation:
240
254
  f"codex {method} returned no thread id: {result!r}"
241
255
  )
242
256
  self.current_model_id = result.get("model") or self._model
257
+ # thread/resume returns the FULL prior conversation inline as
258
+ # thread.turns[].items[] (verified live); stash it for replay_history to
259
+ # backfill into on_event after the listener subscribes. thread/start has
260
+ # no prior turns → [].
261
+ self._resume_turns = thread.get("turns") or []
262
+
263
+ async def replay_history(self) -> int:
264
+ """Backfill on_event with the RESUMED thread's prior turns.
265
+
266
+ On resume, thread/resume returned the whole prior conversation inline as
267
+ result.thread.turns[].items[], which bootstrap stashed — but the
268
+ listener's replay buffer starts empty and only accrues LIVE turns, so a
269
+ viewer attaching after a resume would see none of the prior conversation.
270
+ So re-emit each stored item as the ``item/completed`` notification the
271
+ live stream would have sent for it, plus a ``turn/completed`` after each
272
+ turn, through the SAME on_event fan-out live items use — landing them in
273
+ the listener's buffer so a late viewer reconstructs the full prior
274
+ history, each item rendered identically to a live one (the UI reducer
275
+ treats a replayed item/completed exactly like a streamed one — its
276
+ untracked-completion branch IS the replay-gap path; the per-turn
277
+ turn/completed closes each turn's answer bubble and opens the next).
278
+
279
+ Drives ONLY on_event — no answer accumulation, no on_message: a resume
280
+ must not synthesise a phantom answer turn (mirrors optio-antigravity's
281
+ replay_history). Safe with no history (thread/start, or a resume with no
282
+ prior turns): emits nothing, returns 0. Returns the number of events
283
+ emitted.
284
+ """
285
+ count = 0
286
+ for turn in self._resume_turns:
287
+ turn_id = turn.get("id")
288
+ for item in turn.get("items") or []:
289
+ await self._emit_event({"method": "item/completed", "params": {
290
+ "threadId": self.thread_id, "turnId": turn_id, "item": item}})
291
+ count += 1
292
+ # Close this historic turn's bubble and open the next's — a live
293
+ # multi-turn stream ends every turn with turn/completed, so replay
294
+ # must too (else the reducer coalesces all turns into one bubble).
295
+ await self._emit_event({"method": "turn/completed", "params": {
296
+ "threadId": self.thread_id,
297
+ "turn": {"id": turn_id, "status": "completed"}}})
298
+ count += 1
299
+ return count
300
+
301
+ async def _emit_event(self, obj: dict) -> None:
302
+ """Fan one event out to on_event subscribers directly — the tail of the
303
+ live _event_queue -> _dispatch_loop -> _event_handlers path, reused by
304
+ the resume-history backfill (which must NOT touch turn/answer state, so
305
+ it bypasses _route/_on_notification and the on_message coalescer)."""
306
+ for handler in list(self._event_handlers):
307
+ await self._call_handler(handler, obj, "on_event")
243
308
 
244
309
  async def run_reader(self) -> None:
245
310
  """Drain stdout until EOF; route JSON-RPC messages. Owned by the
@@ -432,6 +497,11 @@ class CodexConversation:
432
497
  # Inline model switch: the override becomes the thread default
433
498
  # for subsequent turns (app-server contract).
434
499
  params["model"] = self._requested_model
500
+ if self._requested_effort is not None:
501
+ # Inline graded-reasoning override: `effort` rides each turn/start
502
+ # (the app-server has no dedicated set-effort request). Seeded from
503
+ # config at launch, then moved by the reasoning_effort slider.
504
+ params["effort"] = self._requested_effort
435
505
  try:
436
506
  await self._write_json({
437
507
  "id": rid, "method": "turn/start", "params": params,
@@ -482,19 +552,51 @@ class CodexConversation:
482
552
  "threadId": self.thread_id, "turnId": self.current_turn_id,
483
553
  })
484
554
 
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)."""
555
+ async def set_control(self, control_id: str, value) -> None:
556
+ """Push a session-control value change to the native transport
557
+ (generalizes model selection). Codex exposes two controls, both
558
+ switched INLINE with NO dedicated wire write — a ``model`` / ``effort``
559
+ override on the next ``turn/start`` becomes the thread default for
560
+ subsequent turns (app-server contract; see models.py):
561
+
562
+ * ``model`` — pins the model on the next turn. Because graded-reasoning
563
+ support is model-dependent, a model switch RE-DERIVES the control set
564
+ for the new model and re-emits it as an ``x-optio-control-update`` so
565
+ the effort slider appears / disappears / relevels to follow the model
566
+ (reconciling the armed effort to a value the new model supports, or
567
+ dropping it when the new model has no graded reasoning).
568
+ * ``reasoning_effort`` — arms ``effort`` for the next turn (the client
569
+ folds the slider value optimistically; no re-emit needed).
570
+
571
+ Unknown control ids are ignored (no-op)."""
572
+ if control_id not in ("model", "reasoning_effort"):
573
+ return # codex exposes only the model + reasoning_effort controls
490
574
  if self._closed.is_set():
491
575
  raise ConversationClosed(self._close_reason or "conversation closed")
492
576
  if self.thread_id is None:
493
577
  raise RuntimeError(
494
- "CodexConversation.request_model_change before bootstrap() completed"
578
+ "CodexConversation.set_control before bootstrap() completed"
495
579
  )
496
- self._requested_model = model
497
- self.current_model_id = model # optimistic; the next turn pins it
580
+ if control_id == "reasoning_effort":
581
+ self._requested_effort = value # armed for the next turn/start
582
+ return
583
+ # control_id == "model": inline switch + reconcile effort to the new
584
+ # model, then re-emit the re-derived control set.
585
+ self._requested_model = value
586
+ self.current_model_id = value # optimistic; the next turn pins it
587
+ model_list = codex_models.parse_model_list(self.model_list)
588
+ levels, default = codex_models.effort_for_model(model_list, value)
589
+ if self._requested_effort is not None and self._requested_effort not in levels:
590
+ # The armed effort is invalid for the new model: fall back to the
591
+ # new model's default (still graded) or drop it (no graded effort).
592
+ self._requested_effort = default if levels else None
593
+ controls = codex_models.build_controls(
594
+ model_list, value, self._requested_effort,
595
+ )
596
+ await self._emit_event({
597
+ "type": "x-optio-control-update",
598
+ "controls": [c.to_dict() for c in controls],
599
+ })
498
600
 
499
601
  async def close(self) -> None:
500
602
  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,7 @@
1
+ from optio_agents import AgentInfo
2
+
3
+ AGENT_INFO = AgentInfo(
4
+ slug="codex",
5
+ name="Codex",
6
+ url="https://openai.com/codex",
7
+ )
@@ -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
+ }