optio-codex 0.3.0__tar.gz → 0.4.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 (62) hide show
  1. {optio_codex-0.3.0 → optio_codex-0.4.1}/PKG-INFO +4 -4
  2. {optio_codex-0.3.0 → optio_codex-0.4.1}/pyproject.toml +4 -4
  3. {optio_codex-0.3.0 → optio_codex-0.4.1}/src/optio_codex/__init__.py +2 -0
  4. optio_codex-0.4.1/src/optio_codex/account.py +312 -0
  5. {optio_codex-0.3.0 → optio_codex-0.4.1}/src/optio_codex/conversation.py +21 -5
  6. {optio_codex-0.3.0 → optio_codex-0.4.1}/src/optio_codex/conversation_listener.py +86 -4
  7. {optio_codex-0.3.0 → optio_codex-0.4.1}/src/optio_codex/host_actions.py +143 -3
  8. optio_codex-0.4.1/src/optio_codex/rollout.py +317 -0
  9. {optio_codex-0.3.0 → optio_codex-0.4.1}/src/optio_codex/session.py +34 -9
  10. {optio_codex-0.3.0 → optio_codex-0.4.1}/src/optio_codex/types.py +22 -23
  11. {optio_codex-0.3.0 → optio_codex-0.4.1}/src/optio_codex/verify.py +48 -17
  12. {optio_codex-0.3.0 → optio_codex-0.4.1}/src/optio_codex.egg-info/PKG-INFO +4 -4
  13. {optio_codex-0.3.0 → optio_codex-0.4.1}/src/optio_codex.egg-info/SOURCES.txt +4 -0
  14. {optio_codex-0.3.0 → optio_codex-0.4.1}/src/optio_codex.egg-info/requires.txt +3 -3
  15. optio_codex-0.4.1/tests/test_account.py +261 -0
  16. {optio_codex-0.3.0 → optio_codex-0.4.1}/tests/test_codex_cache.py +225 -2
  17. {optio_codex-0.3.0 → optio_codex-0.4.1}/tests/test_conversation_listener.py +112 -0
  18. optio_codex-0.4.1/tests/test_rollout.py +166 -0
  19. {optio_codex-0.3.0 → optio_codex-0.4.1}/tests/test_session_seed.py +87 -1
  20. {optio_codex-0.3.0 → optio_codex-0.4.1}/tests/test_verify.py +38 -16
  21. {optio_codex-0.3.0 → optio_codex-0.4.1}/README.md +0 -0
  22. {optio_codex-0.3.0 → optio_codex-0.4.1}/setup.cfg +0 -0
  23. {optio_codex-0.3.0 → optio_codex-0.4.1}/src/optio_codex/cred_watcher.py +0 -0
  24. {optio_codex-0.3.0 → optio_codex-0.4.1}/src/optio_codex/fs_allowlist.py +0 -0
  25. {optio_codex-0.3.0 → optio_codex-0.4.1}/src/optio_codex/info.py +0 -0
  26. {optio_codex-0.3.0 → optio_codex-0.4.1}/src/optio_codex/models.py +0 -0
  27. {optio_codex-0.3.0 → optio_codex-0.4.1}/src/optio_codex/prompt.py +0 -0
  28. {optio_codex-0.3.0 → optio_codex-0.4.1}/src/optio_codex/seed_manifest.py +0 -0
  29. {optio_codex-0.3.0 → optio_codex-0.4.1}/src/optio_codex/snapshots.py +0 -0
  30. {optio_codex-0.3.0 → optio_codex-0.4.1}/src/optio_codex.egg-info/dependency_links.txt +0 -0
  31. {optio_codex-0.3.0 → optio_codex-0.4.1}/src/optio_codex.egg-info/top_level.txt +0 -0
  32. {optio_codex-0.3.0 → optio_codex-0.4.1}/tests/test_agent_info.py +0 -0
  33. {optio_codex-0.3.0 → optio_codex-0.4.1}/tests/test_await_codex_gone.py +0 -0
  34. {optio_codex-0.3.0 → optio_codex-0.4.1}/tests/test_claustrum.py +0 -0
  35. {optio_codex-0.3.0 → optio_codex-0.4.1}/tests/test_config.py +0 -0
  36. {optio_codex-0.3.0 → optio_codex-0.4.1}/tests/test_config_hooks.py +0 -0
  37. {optio_codex-0.3.0 → optio_codex-0.4.1}/tests/test_conversation.py +0 -0
  38. {optio_codex-0.3.0 → optio_codex-0.4.1}/tests/test_conversation_controls.py +0 -0
  39. {optio_codex-0.3.0 → optio_codex-0.4.1}/tests/test_cred_watcher.py +0 -0
  40. {optio_codex-0.3.0 → optio_codex-0.4.1}/tests/test_file_download.py +0 -0
  41. {optio_codex-0.3.0 → optio_codex-0.4.1}/tests/test_file_upload.py +0 -0
  42. {optio_codex-0.3.0 → optio_codex-0.4.1}/tests/test_fs_allowlist.py +0 -0
  43. {optio_codex-0.3.0 → optio_codex-0.4.1}/tests/test_host_actions.py +0 -0
  44. {optio_codex-0.3.0 → optio_codex-0.4.1}/tests/test_import.py +0 -0
  45. {optio_codex-0.3.0 → optio_codex-0.4.1}/tests/test_input_wiring.py +0 -0
  46. {optio_codex-0.3.0 → optio_codex-0.4.1}/tests/test_kill_ttyd_by_socket.py +0 -0
  47. {optio_codex-0.3.0 → optio_codex-0.4.1}/tests/test_models.py +0 -0
  48. {optio_codex-0.3.0 → optio_codex-0.4.1}/tests/test_prompt.py +0 -0
  49. {optio_codex-0.3.0 → optio_codex-0.4.1}/tests/test_real_codex_conversation.py +0 -0
  50. {optio_codex-0.3.0 → optio_codex-0.4.1}/tests/test_real_codex_seed_resume.py +0 -0
  51. {optio_codex-0.3.0 → optio_codex-0.4.1}/tests/test_real_codex_session.py +0 -0
  52. {optio_codex-0.3.0 → optio_codex-0.4.1}/tests/test_sandbox_enforce.py +0 -0
  53. {optio_codex-0.3.0 → optio_codex-0.4.1}/tests/test_seed_manifest.py +0 -0
  54. {optio_codex-0.3.0 → optio_codex-0.4.1}/tests/test_session_conversation.py +0 -0
  55. {optio_codex-0.3.0 → optio_codex-0.4.1}/tests/test_session_lease.py +0 -0
  56. {optio_codex-0.3.0 → optio_codex-0.4.1}/tests/test_session_local.py +0 -0
  57. {optio_codex-0.3.0 → optio_codex-0.4.1}/tests/test_session_remote.py +0 -0
  58. {optio_codex-0.3.0 → optio_codex-0.4.1}/tests/test_session_resume.py +0 -0
  59. {optio_codex-0.3.0 → optio_codex-0.4.1}/tests/test_session_sandbox.py +0 -0
  60. {optio_codex-0.3.0 → optio_codex-0.4.1}/tests/test_snapshots.py +0 -0
  61. {optio_codex-0.3.0 → optio_codex-0.4.1}/tests/test_teardown_session_tree.py +0 -0
  62. {optio_codex-0.3.0 → optio_codex-0.4.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.3.0
3
+ Version: 0.4.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
@@ -20,9 +20,9 @@ Classifier: Topic :: Software Development :: Code Generators
20
20
  Classifier: Framework :: AsyncIO
21
21
  Requires-Python: >=3.11
22
22
  Description-Content-Type: text/markdown
23
- Requires-Dist: optio-core<0.4,>=0.3
24
- Requires-Dist: optio-host<0.3,>=0.2
25
- Requires-Dist: optio-agents<0.6,>=0.5
23
+ Requires-Dist: optio-core<0.5,>=0.4
24
+ Requires-Dist: optio-host<0.4,>=0.3
25
+ Requires-Dist: optio-agents<0.7,>=0.6
26
26
  Requires-Dist: asyncssh>=2.14
27
27
  Requires-Dist: aiohttp>=3.9
28
28
  Provides-Extra: dev
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "optio-codex"
7
- version = "0.3.0"
7
+ version = "0.4.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"
@@ -26,9 +26,9 @@ classifiers = [
26
26
  "Framework :: AsyncIO",
27
27
  ]
28
28
  dependencies = [
29
- "optio-core>=0.3,<0.4",
30
- "optio-host>=0.2,<0.3",
31
- "optio-agents>=0.5,<0.6",
29
+ "optio-core>=0.4,<0.5",
30
+ "optio-host>=0.3,<0.4",
31
+ "optio-agents>=0.6,<0.7",
32
32
  "asyncssh>=2.14",
33
33
  "aiohttp>=3.9",
34
34
  ]
@@ -9,6 +9,7 @@ from optio_host import (
9
9
  SSHConfig,
10
10
  )
11
11
 
12
+ from optio_codex.account import analyze_account
12
13
  from optio_codex.info import AGENT_INFO
13
14
  from optio_codex.seed_manifest import (
14
15
  CODEX_CRED_MANIFEST,
@@ -40,6 +41,7 @@ _logging.getLogger("asyncssh").setLevel(_logging.WARNING)
40
41
 
41
42
  __all__ = [
42
43
  "AGENT_INFO",
44
+ "analyze_account",
43
45
  "create_codex_task",
44
46
  "run_codex_session",
45
47
  "AllowedDir",
@@ -0,0 +1,312 @@
1
+ """Best-effort Codex (OpenAI/ChatGPT) account summary for seeded logins.
2
+
3
+ The per-engine ``analyze_account`` seam every wrapper implements: live OAuth
4
+ credentials in, a vendor-agnostic ``optio_agents.account.AccountInfo`` out.
5
+ Fail-soft: any error (missing field, HTTP/parse error, malformed token) yields
6
+ ``EMPTY`` -- account analysis is informational (it feeds the ``on_seed_saved``
7
+ 2nd arg + a ``metadata.accounts`` stamp), never load-bearing, so a failure here
8
+ must not disturb seed capture, verify, or launch.
9
+
10
+ Divergence from claudecode: codex ``creds`` is the whole ``auth.json`` **tokens
11
+ dict** (``{id_token, access_token, account_id}``), not a bare access token.
12
+ Rationale --
13
+
14
+ * **Identity is OFFLINE.** ``tokens.id_token`` is a JWT whose middle segment
15
+ b64url-decodes to the identity claims (``name``, ``email``,
16
+ ``chatgpt_plan_type``, ``chatgpt_account_id``). No network is needed for
17
+ ``name``/``email``/``plan``/``account_id``; this is also the fail-soft
18
+ identity fallback when the live usage GET fails.
19
+ * **account_id** = ``tokens.account_id`` (the ChatGPT account uuid), needed
20
+ both for the ``ChatGPT-Account-Id`` request header and for
21
+ ``AccountInfo.account_id``. NB the ``usage.account_id`` field is the *user*
22
+ id string, not the uuid -- do NOT use it.
23
+ * **Usage windows** come from a read-only, non-billable
24
+ ``GET https://chatgpt.com/backend-api/wham/usage`` (the same endpoint the
25
+ codex TUI polls). chatgpt.com serves a Cloudflare challenge to a blank UA,
26
+ so a real ``codex_cli_rs/<ver>`` User-Agent is REQUIRED.
27
+
28
+ See ``docs/2026-07-09-codex-account-research.md`` for the captured payloads and
29
+ the full field mapping.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import asyncio
35
+ import base64
36
+ import binascii
37
+ import json
38
+ import logging
39
+ import urllib.request
40
+ from datetime import datetime, timedelta, timezone
41
+ from urllib.error import HTTPError, URLError
42
+
43
+ from optio_agents.account import EMPTY, AccountInfo, UsageWindow
44
+
45
+ _LOG = logging.getLogger(__name__)
46
+
47
+ # The ChatGPT backend (NOT api.openai.com). Read-only, non-billable.
48
+ _USAGE_URL = "https://chatgpt.com/backend-api/wham/usage"
49
+ # Identity endpoint for the creds-form-agnostic helper: the source of name/email
50
+ # when there is no id_token to decode (opencode's openai-oauth stores only an
51
+ # access token). Read-only.
52
+ _ME_URL = "https://chatgpt.com/backend-api/me"
53
+ # A real UA is REQUIRED -- chatgpt.com serves a Cloudflare HTML challenge to a
54
+ # blank/curl User-Agent. Any real codex_cli_rs version string works.
55
+ _USER_AGENT = "codex_cli_rs/0.142.5"
56
+ _HTTP_TIMEOUT_S = 15
57
+
58
+ # id_token claim namespace carrying the ChatGPT identity block.
59
+ _AUTH_CLAIM = "https://api.openai.com/auth"
60
+
61
+ # Live-workdir credentials (isolated HOME) -- the same file codex itself writes,
62
+ # still on disk at capture time (pre-cleanup). Mirrors verify._AUTH_RELPATH.
63
+ _AUTH_RELPATH = "home/.codex/auth.json"
64
+
65
+ # Prettify the well-known plan tokens; unknown tokens pass through title-cased.
66
+ # Only ``free`` is confirmed against a live seed; paid spellings are from codex
67
+ # source (chatgpt_plan_type) and want a paid-seed confirmation.
68
+ _PLAN_NAMES = {
69
+ "free": "Free",
70
+ "plus": "ChatGPT Plus",
71
+ "pro": "ChatGPT Pro",
72
+ "team": "ChatGPT Team",
73
+ "business": "ChatGPT Business",
74
+ "enterprise": "ChatGPT Enterprise",
75
+ "edu": "ChatGPT Edu",
76
+ }
77
+
78
+
79
+ # --- synchronous HTTP (run in an executor; no host, no codex) ----------------
80
+
81
+
82
+ def _usage_sync(access_token: str, account_id: str) -> "dict | None":
83
+ """GET the wham/usage window source. Fail-soft -> None on any error."""
84
+ req = urllib.request.Request(
85
+ _USAGE_URL,
86
+ headers={
87
+ "Authorization": f"Bearer {access_token}",
88
+ "ChatGPT-Account-Id": account_id,
89
+ "User-Agent": _USER_AGENT,
90
+ "Accept": "application/json",
91
+ },
92
+ method="GET",
93
+ )
94
+ try:
95
+ with urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT_S) as resp:
96
+ return json.loads(resp.read().decode("utf-8"))
97
+ except (HTTPError, URLError, OSError, ValueError):
98
+ return None
99
+
100
+
101
+ async def _fetch_usage(access_token: str, account_id: str) -> "dict | None":
102
+ """Async wrapper around the sync wham/usage fetcher (executor)."""
103
+ return await asyncio.get_event_loop().run_in_executor(
104
+ None, _usage_sync, access_token, account_id
105
+ )
106
+
107
+
108
+ def _me_sync(access_token: str, account_id: "str | None") -> "dict | None":
109
+ """GET /backend-api/me for the ChatGPT identity (name/email). Fail-soft ->
110
+ None on any error. ``ChatGPT-Account-Id`` is sent when known (mirrors the
111
+ wham/usage call)."""
112
+ headers = {
113
+ "Authorization": f"Bearer {access_token}",
114
+ "User-Agent": _USER_AGENT,
115
+ "Accept": "application/json",
116
+ }
117
+ if isinstance(account_id, str) and account_id:
118
+ headers["ChatGPT-Account-Id"] = account_id
119
+ req = urllib.request.Request(_ME_URL, headers=headers, method="GET")
120
+ try:
121
+ with urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT_S) as resp:
122
+ return json.loads(resp.read().decode("utf-8"))
123
+ except (HTTPError, URLError, OSError, ValueError):
124
+ return None
125
+
126
+
127
+ async def _fetch_me(access_token: str, account_id: "str | None") -> "dict | None":
128
+ """Async wrapper around the sync /backend-api/me fetcher (executor)."""
129
+ return await asyncio.get_event_loop().run_in_executor(
130
+ None, _me_sync, access_token, account_id
131
+ )
132
+
133
+
134
+ # --- offline id_token decode -------------------------------------------------
135
+
136
+
137
+ def _decode_id_token(id_token) -> dict:
138
+ """Decode the JWT id_token's claims OFFLINE (b64url the middle segment; no
139
+ signature check, no network). Returns ``{}`` on any malformation -- never
140
+ raises -- so a bad id_token degrades to the usage-only identity path."""
141
+ if not isinstance(id_token, str) or id_token.count(".") < 2:
142
+ return {}
143
+ payload = id_token.split(".")[1]
144
+ payload += "=" * (-len(payload) % 4) # restore b64url padding
145
+ try:
146
+ claims = json.loads(base64.urlsafe_b64decode(payload).decode("utf-8"))
147
+ except (binascii.Error, ValueError, UnicodeDecodeError):
148
+ return {}
149
+ return claims if isinstance(claims, dict) else {}
150
+
151
+
152
+ # --- normalized AccountInfo mapping -----------------------------------------
153
+
154
+
155
+ def _format_plan(plan_type) -> "str | None":
156
+ """``chatgpt_plan_type`` -> display name. ``free`` -> ``"Free"``; unknown
157
+ tokens title-case with ``_`` -> space. None when absent."""
158
+ if not isinstance(plan_type, str) or not plan_type.strip():
159
+ return None
160
+ key = plan_type.strip().lower()
161
+ return _PLAN_NAMES.get(key, plan_type.replace("_", " ").title())
162
+
163
+
164
+ def _reset_at(window: dict) -> "datetime | None":
165
+ """Absolute reset time. Prefer ``reset_at`` (unix epoch seconds); fall back
166
+ to ``now + reset_after_seconds`` (relative). None if neither is present."""
167
+ ra = window.get("reset_at")
168
+ if isinstance(ra, (int, float)):
169
+ try:
170
+ return datetime.fromtimestamp(ra, tz=timezone.utc)
171
+ except (ValueError, OSError, OverflowError):
172
+ pass
173
+ ras = window.get("reset_after_seconds")
174
+ if isinstance(ras, (int, float)):
175
+ return datetime.now(timezone.utc) + timedelta(seconds=ras)
176
+ return None
177
+
178
+
179
+ def _window_from(label: str, window, *, model: "str | None" = None) -> "UsageWindow | None":
180
+ """One vendor window dict -> UsageWindow. None when the dict is null/absent
181
+ or carries no ``used_percent``."""
182
+ if not isinstance(window, dict):
183
+ return None
184
+ pct = window.get("used_percent")
185
+ if not isinstance(pct, (int, float)):
186
+ return None
187
+ return UsageWindow(
188
+ label=label, pct=float(pct), resets_at=_reset_at(window), model=model
189
+ )
190
+
191
+
192
+ def _windows_from_usage(usage: dict) -> list[UsageWindow]:
193
+ """Build windows from ``usage.rate_limit``. Global windows (primary /
194
+ secondary / code_review) carry ``model=None``; ``additional_rate_limits[]``
195
+ are per-model (``model`` set from the entry). Null windows are skipped."""
196
+ rl = usage.get("rate_limit")
197
+ if not isinstance(rl, dict):
198
+ return []
199
+ out: list[UsageWindow] = []
200
+ for label, key in (("primary", "primary_window"), ("secondary", "secondary_window")):
201
+ w = _window_from(label, rl.get(key))
202
+ if w is not None:
203
+ out.append(w)
204
+ crl = _window_from("code_review", rl.get("code_review_rate_limit"))
205
+ if crl is not None:
206
+ out.append(crl)
207
+ additional = rl.get("additional_rate_limits")
208
+ if isinstance(additional, list):
209
+ for entry in additional:
210
+ if not isinstance(entry, dict):
211
+ continue
212
+ label = entry.get("name") or entry.get("id") or entry.get("label") or "additional"
213
+ model = entry.get("model") or entry.get("model_id") or entry.get("name")
214
+ w = _window_from(str(label), entry, model=model)
215
+ if w is not None:
216
+ out.append(w)
217
+ return out
218
+
219
+
220
+ def _info_from(claims: dict, usage: "dict | None", account_id: "str | None") -> AccountInfo:
221
+ auth = claims.get(_AUTH_CLAIM)
222
+ if not isinstance(auth, dict):
223
+ auth = {}
224
+ u = usage or {}
225
+ plan = _format_plan(u.get("plan_type")) or _format_plan(auth.get("chatgpt_plan_type"))
226
+ return AccountInfo(
227
+ name=claims.get("name") or None,
228
+ email=u.get("email") or claims.get("email") or None,
229
+ plan=plan,
230
+ # The account uuid: creds.account_id (== id_token chatgpt_account_id).
231
+ # NOT usage.account_id (that field is the user-id string).
232
+ account_id=account_id or auth.get("chatgpt_account_id") or None,
233
+ windows=tuple(_windows_from_usage(u)),
234
+ raw={"usage": usage, "id_token": claims},
235
+ )
236
+
237
+
238
+ def _info_from_me(me: "dict | None", usage: "dict | None", account_id: "str | None") -> AccountInfo:
239
+ """Map the ``/backend-api/me`` identity + ``wham/usage`` windows into an
240
+ AccountInfo. Identity (name/email) comes from ``me`` (there is no id_token to
241
+ decode); the plan comes from the live usage payload."""
242
+ m = me or {}
243
+ u = usage or {}
244
+ return AccountInfo(
245
+ name=m.get("name") or None,
246
+ email=m.get("email") or u.get("email") or None,
247
+ plan=_format_plan(u.get("plan_type")),
248
+ account_id=account_id or None,
249
+ windows=tuple(_windows_from_usage(u)),
250
+ raw={"me": me, "usage": usage},
251
+ )
252
+
253
+
254
+ async def account_from_openai(access_token: str, account_id: "str | None") -> AccountInfo:
255
+ """Reusable, creds-form-agnostic OpenAI/ChatGPT account map: a bare access
256
+ token (+ optional ChatGPT account uuid) in, a normalized ``AccountInfo`` out.
257
+ Identity is fetched from ``GET /backend-api/me`` (NOT an id_token — opencode's
258
+ openai-oauth stores only the access token); usage windows come from the
259
+ read-only wham/usage GET (needs the account uuid for the required header).
260
+ Never raises -> ``EMPTY`` on any failure."""
261
+ try:
262
+ if not isinstance(access_token, str) or not access_token:
263
+ return EMPTY
264
+ me = await _fetch_me(access_token, account_id)
265
+ usage = None
266
+ if isinstance(account_id, str) and account_id:
267
+ usage = await _fetch_usage(access_token, account_id)
268
+ return _info_from_me(
269
+ me if isinstance(me, dict) else None,
270
+ usage if isinstance(usage, dict) else None,
271
+ account_id,
272
+ )
273
+ except Exception: # noqa: BLE001 -- fail-soft, never disturbs the caller
274
+ return EMPTY
275
+
276
+
277
+ async def analyze_account(creds) -> AccountInfo:
278
+ """Best-effort codex ``AccountInfo`` from the live ``auth.json`` tokens dict
279
+ (``{id_token, access_token, account_id}``). Identity is decoded offline from
280
+ the id_token; usage windows come from a read-only wham/usage GET. Never
281
+ raises -> ``EMPTY`` on any failure."""
282
+ try:
283
+ if not isinstance(creds, dict):
284
+ return EMPTY
285
+ claims = _decode_id_token(creds.get("id_token"))
286
+ access = creds.get("access_token")
287
+ account_id = creds.get("account_id")
288
+ usage = None
289
+ if isinstance(access, str) and access and isinstance(account_id, str) and account_id:
290
+ usage = await _fetch_usage(access, account_id)
291
+ return _info_from(claims, usage if isinstance(usage, dict) else None, account_id)
292
+ except Exception: # noqa: BLE001 -- fail-soft, never disturbs the caller
293
+ return EMPTY
294
+
295
+
296
+ async def resolve_capture_account(host) -> AccountInfo:
297
+ """Live-host capture variant: read the isolated HOME's ``auth.json`` tokens,
298
+ then ``analyze_account``. Fail-soft -> ``EMPTY`` on any failure (no auth
299
+ file, no tokens, analysis error).
300
+
301
+ No token refresh: the operator just authed, so the token is fresh; an
302
+ expired/invalid token simply yields ``EMPTY`` (analysis fail-soft)."""
303
+ path = f"{host.workdir.rstrip('/')}/{_AUTH_RELPATH}"
304
+ try:
305
+ raw = await host.fetch_bytes_from_host(path)
306
+ data = json.loads(raw.decode("utf-8"))
307
+ tokens = data.get("tokens")
308
+ except Exception: # noqa: BLE001 -- missing/unreadable/malformed auth -> EMPTY
309
+ return EMPTY
310
+ if not isinstance(tokens, dict):
311
+ return EMPTY
312
+ return await analyze_account(tokens)
@@ -101,6 +101,7 @@ from optio_agents.conversation import (
101
101
  PermissionRequest,
102
102
  )
103
103
  from optio_codex import models as codex_models
104
+ from optio_codex import rollout as codex_rollout
104
105
  from optio_codex.info import AGENT_INFO
105
106
 
106
107
  _LOG = logging.getLogger(__name__)
@@ -259,6 +260,17 @@ class CodexConversation:
259
260
  # backfill into on_event after the listener subscribes. thread/start has
260
261
  # no prior turns → [].
261
262
  self._resume_turns = thread.get("turns") or []
263
+ if self._resume_thread_id is not None:
264
+ # Diagnostic: how much history thread/resume actually returned. codex
265
+ # compacts context on long conversations, so a resume may hand back
266
+ # TRUNCATED turns — this INFO line lets the next resume confirm
267
+ # whether that happened (the rollout on disk stays the full-history
268
+ # source of truth for the fresh-attach replay).
269
+ total_items = sum(len(t.get("items") or []) for t in self._resume_turns)
270
+ _LOG.info(
271
+ "codex thread/resume: %d turns, %d items for thread %s",
272
+ len(self._resume_turns), total_items, self.thread_id,
273
+ )
262
274
 
263
275
  async def replay_history(self) -> int:
264
276
  """Backfill on_event with the RESUMED thread's prior turns.
@@ -286,15 +298,19 @@ class CodexConversation:
286
298
  for turn in self._resume_turns:
287
299
  turn_id = turn.get("id")
288
300
  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}})
301
+ # Build the wire envelope through the SAME helper the rollout
302
+ # history path uses (optio_codex.rollout) so the two
303
+ # reconstructions cannot drift.
304
+ await self._emit_event(
305
+ codex_rollout.item_completed_event(self.thread_id, turn_id, item)
306
+ )
291
307
  count += 1
292
308
  # Close this historic turn's bubble and open the next's — a live
293
309
  # multi-turn stream ends every turn with turn/completed, so replay
294
310
  # 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"}}})
311
+ await self._emit_event(
312
+ codex_rollout.turn_completed_event(self.thread_id, turn_id)
313
+ )
298
314
  count += 1
299
315
  return count
300
316
 
@@ -36,6 +36,8 @@ from aiohttp import web
36
36
 
37
37
  from optio_agents.conversation import ConversationClosed, PermissionDecision
38
38
 
39
+ from optio_codex import rollout as _rollout
40
+
39
41
  _LOG = logging.getLogger(__name__)
40
42
 
41
43
  BUFFER_MAXLEN = 1000
@@ -48,16 +50,42 @@ SHUTDOWN_TIMEOUT_S = 2.0
48
50
  _STOP = object()
49
51
 
50
52
 
53
+ def _event_turn_id(event: dict) -> str | None:
54
+ """The codex turn id an event belongs to, or None (synthetic/handshake
55
+ events carry no turn). Used to dedup live-buffer events against the rollout
56
+ history on a fresh attach — the rollout records the SAME turn ids the live
57
+ wire uses (verified: response_item.internal_chat_message_metadata_passthrough
58
+ .turn_id == the app-server turn id)."""
59
+ params = event.get("params")
60
+ if not isinstance(params, dict):
61
+ return None
62
+ turn = params.get("turn")
63
+ if isinstance(turn, dict) and turn.get("id"):
64
+ return turn["id"] # turn/started, turn/completed
65
+ return params.get("turnId") # item/*, requestApproval, deltas
66
+
67
+
51
68
  class ConversationListener:
52
69
  def __init__(
53
70
  self, conversation, *, password: str,
54
71
  download_reader: "Callable[[str], Awaitable[tuple[bytes, str]]] | None" = None,
55
72
  max_download_bytes: int = 10_000_000,
73
+ host=None,
74
+ codex_home: str | None = None,
56
75
  ) -> None:
57
76
  self._conversation = conversation
58
77
  self._password = password
59
78
  self._download_reader = download_reader
60
79
  self._max_download_bytes = max_download_bytes
80
+ # The Host (local or SSH worker) the codex session runs on — rollout
81
+ # discovery + reads route through it (host.glob / fetch_bytes_from_host),
82
+ # never a bare local open(), so full-history replay works for remote
83
+ # workers too. None ⇒ no rollout replay (buffer-only, as before).
84
+ self._host = host
85
+ # CODEX_HOME (=<workdir>/home/.codex) — the on-disk rollout store that is
86
+ # the AUTHORITATIVE full history a fresh viewer attach replays before the
87
+ # in-flight live tail. None ⇒ no rollout replay (buffer-only, as before).
88
+ self._codex_home = codex_home
61
89
  self._buffer: deque[tuple[int, dict]] = deque(maxlen=BUFFER_MAXLEN)
62
90
  self._seq = 0
63
91
  self._subscribers: set[asyncio.Queue] = set()
@@ -99,6 +127,32 @@ class ConversationListener:
99
127
  })
100
128
  return decision
101
129
 
130
+ # -- rollout history -----------------------------------------------------
131
+
132
+ async def _load_history(self) -> "tuple[list[dict], set[str]]":
133
+ """Reconstruct the FULL conversation from the on-disk rollout, and the
134
+ set of turn ids it covers (the dedup key for the live buffer).
135
+
136
+ Discovery + read route through the HOST abstraction (host.glob +
137
+ fetch_bytes_from_host) so it works for a remote SSH worker, not just a
138
+ local workdir. Fail-soft by contract: any error (no host/codex_home, no
139
+ rollout, an unreadable/malformed file) yields ``([], set())`` so a fresh
140
+ attach silently falls back to buffer-only replay — NEVER raises into the
141
+ SSE handler."""
142
+ if not self._codex_home or self._host is None:
143
+ return [], set()
144
+ try:
145
+ path = await _rollout.resolve_latest_rollout(self._host, self._codex_home)
146
+ if path is None:
147
+ return [], set()
148
+ data = await self._host.fetch_bytes_from_host(path)
149
+ events = _rollout.parse_rollout_events(data.decode("utf-8", "replace"))
150
+ except Exception: # noqa: BLE001 — attach must never fail on the rollout
151
+ _LOG.exception("rollout history reconstruction failed; buffer only")
152
+ return [], set()
153
+ turn_ids = {t for e in events if (t := _event_turn_id(e)) is not None}
154
+ return events, turn_ids
155
+
102
156
  # -- HTTP handlers -------------------------------------------------------
103
157
 
104
158
  def _authorized(self, request: web.Request) -> bool:
@@ -137,10 +191,38 @@ class ConversationListener:
137
191
  self._subscribers.add(queue)
138
192
  try:
139
193
  sent_through = last_id
140
- for seq, event in list(self._buffer):
141
- if seq > sent_through:
142
- await send_item(seq, event)
143
- sent_through = seq
194
+ if last_id == 0:
195
+ # FRESH attach: replay the FULL history from the on-disk rollout
196
+ # (authoritative — the bounded buffer deque has evicted early
197
+ # turns on a long session), THEN the in-flight turn from the live
198
+ # buffer, deduped against the rollout by turnId.
199
+ #
200
+ # SSE-id scheme: history events get ids -N..-1 (a reserved range
201
+ # strictly below every live seq, which start at 1). They are
202
+ # per-attach ephemeral — NOT stored in self._buffer and they do
203
+ # NOT advance self._seq, so live Last-Event-ID resumption is
204
+ # untouched. A client that saw only history and reconnects sends
205
+ # a negative Last-Event-ID; ``raw_last.isdigit()`` is False for
206
+ # it, so last_id falls back to 0 and the full history replays
207
+ # again (idempotent). A client that reached the live tail sends a
208
+ # positive id, taking the reconnect branch below (no rollout).
209
+ history, hist_turn_ids = await self._load_history()
210
+ hid = -len(history)
211
+ for event in history:
212
+ await send_item(hid, event)
213
+ hid += 1
214
+ for seq, event in list(self._buffer):
215
+ # Skip buffered events for turns the rollout already covers;
216
+ # keep the in-flight turn (and any turn not yet flushed to
217
+ # the rollout) plus synthetic events (no turnId).
218
+ if _event_turn_id(event) not in hist_turn_ids:
219
+ await send_item(seq, event)
220
+ sent_through = seq # buffer is seq-ordered; advance past all
221
+ else:
222
+ for seq, event in list(self._buffer):
223
+ if seq > sent_through:
224
+ await send_item(seq, event)
225
+ sent_through = seq
144
226
  while True:
145
227
  try:
146
228
  item = await asyncio.wait_for(