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.
- {optio_codex-0.1.0 → optio_codex-0.2.0}/PKG-INFO +3 -2
- {optio_codex-0.1.0 → optio_codex-0.2.0}/pyproject.toml +3 -2
- {optio_codex-0.1.0 → optio_codex-0.2.0}/src/optio_codex/conversation.py +110 -9
- {optio_codex-0.1.0 → optio_codex-0.2.0}/src/optio_codex/conversation_listener.py +9 -44
- {optio_codex-0.1.0 → optio_codex-0.2.0}/src/optio_codex/fs_allowlist.py +6 -5
- {optio_codex-0.1.0 → optio_codex-0.2.0}/src/optio_codex/host_actions.py +21 -17
- optio_codex-0.2.0/src/optio_codex/models.py +126 -0
- {optio_codex-0.1.0 → optio_codex-0.2.0}/src/optio_codex/session.py +247 -43
- {optio_codex-0.1.0 → optio_codex-0.2.0}/src/optio_codex/types.py +132 -70
- {optio_codex-0.1.0 → optio_codex-0.2.0}/src/optio_codex.egg-info/PKG-INFO +3 -2
- {optio_codex-0.1.0 → optio_codex-0.2.0}/src/optio_codex.egg-info/SOURCES.txt +3 -0
- {optio_codex-0.1.0 → optio_codex-0.2.0}/src/optio_codex.egg-info/requires.txt +2 -1
- {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_config.py +21 -3
- optio_codex-0.2.0/tests/test_config_hooks.py +321 -0
- {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_conversation.py +232 -4
- optio_codex-0.2.0/tests/test_conversation_controls.py +143 -0
- {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_conversation_listener.py +46 -9
- optio_codex-0.2.0/tests/test_file_upload.py +30 -0
- optio_codex-0.2.0/tests/test_input_wiring.py +99 -0
- optio_codex-0.2.0/tests/test_models.py +143 -0
- {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_session_conversation.py +77 -7
- {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_session_lease.py +1 -1
- {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_session_local.py +7 -7
- {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_session_remote.py +8 -53
- {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_session_resume.py +1 -1
- {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_session_sandbox.py +2 -2
- {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_session_seed.py +1 -1
- optio_codex-0.1.0/src/optio_codex/models.py +0 -68
- optio_codex-0.1.0/tests/test_file_upload.py +0 -126
- optio_codex-0.1.0/tests/test_models.py +0 -67
- {optio_codex-0.1.0 → optio_codex-0.2.0}/README.md +0 -0
- {optio_codex-0.1.0 → optio_codex-0.2.0}/setup.cfg +0 -0
- {optio_codex-0.1.0 → optio_codex-0.2.0}/src/optio_codex/__init__.py +0 -0
- {optio_codex-0.1.0 → optio_codex-0.2.0}/src/optio_codex/cred_watcher.py +0 -0
- {optio_codex-0.1.0 → optio_codex-0.2.0}/src/optio_codex/prompt.py +0 -0
- {optio_codex-0.1.0 → optio_codex-0.2.0}/src/optio_codex/seed_manifest.py +0 -0
- {optio_codex-0.1.0 → optio_codex-0.2.0}/src/optio_codex/snapshots.py +0 -0
- {optio_codex-0.1.0 → optio_codex-0.2.0}/src/optio_codex/verify.py +0 -0
- {optio_codex-0.1.0 → optio_codex-0.2.0}/src/optio_codex.egg-info/dependency_links.txt +0 -0
- {optio_codex-0.1.0 → optio_codex-0.2.0}/src/optio_codex.egg-info/top_level.txt +0 -0
- {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_await_codex_gone.py +0 -0
- {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_codex_cache.py +0 -0
- {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_cred_watcher.py +0 -0
- {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_file_download.py +0 -0
- {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_fs_allowlist.py +0 -0
- {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_host_actions.py +0 -0
- {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_import.py +0 -0
- {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_kill_ttyd_by_socket.py +0 -0
- {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_prompt.py +0 -0
- {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_real_codex_conversation.py +0 -0
- {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_real_codex_seed_resume.py +0 -0
- {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_real_codex_session.py +0 -0
- {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_sandbox_enforce.py +0 -0
- {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_seed_manifest.py +0 -0
- {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_snapshots.py +0 -0
- {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_teardown_session_tree.py +0 -0
- {optio_codex-0.1.0 → optio_codex-0.2.0}/tests/test_verify.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
(
|
|
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
|
|
486
|
-
"""
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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.
|
|
577
|
+
"CodexConversation.set_control before bootstrap() completed"
|
|
495
578
|
)
|
|
496
|
-
|
|
497
|
-
|
|
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 /
|
|
11
|
-
(
|
|
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
|
|
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
|
-
|
|
257
|
-
if not isinstance(
|
|
258
|
-
return web.json_response({"ok": False, "reason": "bad-
|
|
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.
|
|
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("/
|
|
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 ``~/``
|
|
94
|
-
against ``host_home
|
|
95
|
-
|
|
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
|
|
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"(
|
|
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.
|
|
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
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
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
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
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
|
+
}
|