ccx-native 0.1.0 → 0.2.0
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.
- package/README.md +4 -5
- package/ccx/__init__.py +4 -0
- package/ccx/claude/attach.py +103 -58
- package/ccx/claude/host/pty.py +20 -1
- package/ccx/claude/host/session.py +95 -35
- package/ccx/claude/transcript.py +321 -67
- package/ccx/claude/usage.py +5 -46
- package/ccx/claude/usage_control.py +93 -41
- package/ccx/cli/commands/control.py +1 -1
- package/ccx/cli/commands/models.py +53 -0
- package/ccx/cli/commands/read.py +90 -6
- package/ccx/cli/main.py +3 -2
- package/ccx/cli/parser.py +122 -7
- package/ccx/client.py +8 -4
- package/ccx/codex/client.py +6 -0
- package/ccx/codex/daemon/daemon.py +3 -1
- package/ccx/codex/daemon/worker.py +41 -13
- package/ccx/codex/history_store.py +131 -0
- package/ccx/codex/runtime.py +73 -31
- package/ccx/models.py +225 -0
- package/ccx/ops/cancel.py +18 -11
- package/ccx/ops/conversation.py +67 -14
- package/ccx/ops/conversation_paging.py +15 -14
- package/ccx/ops/creation_steps.py +0 -4
- package/ccx/ops/history.py +1041 -0
- package/ccx/ops/report.py +8 -5
- package/ccx/ops/turn_reports.py +47 -5
- package/ccx/ops/worker_events.py +5 -1
- package/ccx/store/paths.py +0 -4
- package/ccx/store/root.py +6 -21
- package/ccx/store/store.py +2 -2
- package/ccx/system/private_json.py +54 -0
- package/ccx/types.py +104 -0
- package/ccx/workers.py +70 -58
- package/package.json +2 -2
- package/pyproject.toml +2 -2
- package/skills/ccx/SKILL.md +12 -1
- package/skills/ccx/references/history.md +50 -0
|
@@ -23,6 +23,14 @@ if TYPE_CHECKING:
|
|
|
23
23
|
_TERMINAL_STATES = {"dead", "idle-killed", "crashed", "budget-stopped"}
|
|
24
24
|
|
|
25
25
|
|
|
26
|
+
def _system_error_reason(error) -> str:
|
|
27
|
+
"""The crash reason, with Codex's own error message when there is one."""
|
|
28
|
+
message = error.get("message") if isinstance(error, dict) else None
|
|
29
|
+
if isinstance(message, str) and message.strip():
|
|
30
|
+
return "Codex thread system error: " + message.strip()[:500]
|
|
31
|
+
return "Codex thread system error"
|
|
32
|
+
|
|
33
|
+
|
|
26
34
|
_PARENT_NOTIFY_FENCES = 32
|
|
27
35
|
|
|
28
36
|
|
|
@@ -1373,8 +1381,21 @@ class Worker:
|
|
|
1373
1381
|
reason="Codex thread system error",
|
|
1374
1382
|
)
|
|
1375
1383
|
self._abandon_active_compact_locked("Codex thread system error")
|
|
1376
|
-
|
|
1377
|
-
|
|
1384
|
+
# Best effort: name the native cause when it is
|
|
1385
|
+
# already known (Codex may also report it later, on
|
|
1386
|
+
# turn/completed).
|
|
1387
|
+
lifecycle = self.record["lifecycle"]
|
|
1388
|
+
native_error = None
|
|
1389
|
+
active_error = self.record.get("activeTurnError")
|
|
1390
|
+
last_turn = self.record.get("lastTurn")
|
|
1391
|
+
if lifecycle.get("activeTurnId"):
|
|
1392
|
+
if (isinstance(active_error, dict) and active_error.get("turnId")
|
|
1393
|
+
== lifecycle.get("activeTurnId")):
|
|
1394
|
+
native_error = active_error.get("error")
|
|
1395
|
+
elif isinstance(last_turn, dict) and last_turn.get("status") == "failed":
|
|
1396
|
+
native_error = last_turn.get("error")
|
|
1397
|
+
lifecycle["state"] = "crashed"
|
|
1398
|
+
lifecycle["lastError"] = _system_error_reason(native_error)
|
|
1378
1399
|
elif method == "turn/completed":
|
|
1379
1400
|
changed = True
|
|
1380
1401
|
turn = params.get("turn") or {}
|
|
@@ -1527,6 +1548,12 @@ class Worker:
|
|
|
1527
1548
|
# demote a worker whose runtime health is already known to
|
|
1528
1549
|
# be bad.
|
|
1529
1550
|
self.record["lifecycle"]["state"] = "crashed"
|
|
1551
|
+
# Best effort: name the native cause (a 429, a refused
|
|
1552
|
+
# compact) instead of only the generic status.
|
|
1553
|
+
if self.record["lifecycle"].get("lastError") == _system_error_reason(None):
|
|
1554
|
+
self.record["lifecycle"]["lastError"] = (
|
|
1555
|
+
_system_error_reason(turn.get("error"))
|
|
1556
|
+
)
|
|
1530
1557
|
elif isinstance(self.record.get("activeCompact"), dict):
|
|
1531
1558
|
# A foreign/native turn can settle while compaction still
|
|
1532
1559
|
# owns its own operation fence. Do not publish idle until
|
|
@@ -2134,31 +2161,32 @@ class Worker:
|
|
|
2134
2161
|
return failure
|
|
2135
2162
|
|
|
2136
2163
|
if op == "interrupt":
|
|
2164
|
+
# The notification reader publishes a successor turn under the
|
|
2165
|
+
# worker lock, so read state, turn and thread together once and
|
|
2166
|
+
# interrupt exactly the turn that was checked.
|
|
2167
|
+
with self.lock:
|
|
2168
|
+
state = self.record["lifecycle"]["state"]
|
|
2169
|
+
turn_id = self.record["lifecycle"].get("activeTurnId")
|
|
2170
|
+
# Full-UUID records keep the original thread under native.threadId.
|
|
2171
|
+
native = self.record.get("native") or {}
|
|
2172
|
+
thread_id = native.get("threadId") or self.record.get("threadId")
|
|
2137
2173
|
expected_turn_id = message.get("expectedTurnId")
|
|
2138
|
-
|
|
2139
|
-
if (
|
|
2140
|
-
expected_turn_id is not None
|
|
2141
|
-
and expected_turn_id != current_turn_id
|
|
2142
|
-
):
|
|
2174
|
+
if expected_turn_id is not None and expected_turn_id != turn_id:
|
|
2143
2175
|
return {
|
|
2144
2176
|
"ok": False,
|
|
2145
2177
|
"error": "active turn changed",
|
|
2146
2178
|
"state": state,
|
|
2147
|
-
"turnId":
|
|
2179
|
+
"turnId": turn_id,
|
|
2148
2180
|
"definitive": True,
|
|
2149
2181
|
}
|
|
2150
2182
|
if state not in {"busy", "waiting"}:
|
|
2151
2183
|
return {"ok": False, "definitive": True,
|
|
2152
2184
|
"error": "no active turn", "state": state}
|
|
2153
|
-
turn_id = self.record["lifecycle"].get("activeTurnId")
|
|
2154
2185
|
if not turn_id:
|
|
2155
2186
|
return {"ok": False, "definitive": True,
|
|
2156
2187
|
"error": "active turn id is unavailable", "state": state}
|
|
2157
|
-
# Full-UUID records keep the original thread under native.threadId.
|
|
2158
|
-
native = self.record.get("native") or {}
|
|
2159
2188
|
self.client.request("turn/interrupt", {
|
|
2160
|
-
"threadId":
|
|
2161
|
-
"turnId": turn_id,
|
|
2189
|
+
"threadId": thread_id, "turnId": turn_id,
|
|
2162
2190
|
})
|
|
2163
2191
|
return {"ok": True, "state": state, "turnId": turn_id}
|
|
2164
2192
|
if op in {"approve", "reject"}:
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Read one Codex thread's history from Codex's local history database.
|
|
2
|
+
|
|
3
|
+
Codex keeps a SQLite projection of every thread's rollout,
|
|
4
|
+
``$CODEX_HOME/thread_history_1.sqlite``. Its ``thread_items.item_json`` holds
|
|
5
|
+
the same ThreadItem JSON app-server returns, so a thread rebuilt from it feeds
|
|
6
|
+
:func:`ccx.ops.conversation.build_codex` unchanged and yields the same turn
|
|
7
|
+
sequence and item ordinals. Reading it takes milliseconds where app-server
|
|
8
|
+
paging can take minutes, and loads nothing into codexd.
|
|
9
|
+
|
|
10
|
+
It is Codex's private store, so every read is defensive: opened read-only
|
|
11
|
+
(``mode=ro``, never ``immutable=1``, so SQLite still honours the WAL), gated on
|
|
12
|
+
the tables and columns used here, short, and always closed. Any doubt returns
|
|
13
|
+
``None`` and the caller falls back to app-server ``thread/read``.
|
|
14
|
+
|
|
15
|
+
The thread id must come from the worker's verified original source
|
|
16
|
+
(``codex.telemetry._original_source``), never from a record field read here.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import sqlite3
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from urllib.parse import quote
|
|
25
|
+
|
|
26
|
+
HISTORY_DB_NAME = "thread_history_1.sqlite"
|
|
27
|
+
|
|
28
|
+
# The schema this reader was checked against (2026-11, Codex `_sqlx_migrations`
|
|
29
|
+
# version 8). A newer version is still read when the required columns exist;
|
|
30
|
+
# the document then says so in a warning.
|
|
31
|
+
VERIFIED_SCHEMA_VERSION = 8
|
|
32
|
+
|
|
33
|
+
_REQUIRED = {
|
|
34
|
+
"thread_turns": {"thread_id", "turn_id", "rollout_ordinal", "status",
|
|
35
|
+
"error_json", "started_at", "completed_at", "duration_ms"},
|
|
36
|
+
"thread_items": {"thread_id", "turn_id", "item_id", "rollout_ordinal",
|
|
37
|
+
"item_json"},
|
|
38
|
+
"_sqlx_migrations": {"version", "success"},
|
|
39
|
+
}
|
|
40
|
+
_TURN_STATUSES = frozenset({"completed", "failed", "inProgress", "interrupted"})
|
|
41
|
+
_BUSY_TIMEOUT_S = 2.0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def history_db_path() -> Path:
|
|
45
|
+
# Like Codex itself, an empty CODEX_HOME means unset.
|
|
46
|
+
return Path(os.environ.get("CODEX_HOME") or "~/.codex").expanduser() / HISTORY_DB_NAME
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _schema_ok(conn: sqlite3.Connection) -> bool:
|
|
50
|
+
for table, columns in _REQUIRED.items():
|
|
51
|
+
found = {row[1] for row in conn.execute(f"PRAGMA table_info({table})")}
|
|
52
|
+
if not columns <= found:
|
|
53
|
+
return False
|
|
54
|
+
return True
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def read_thread_db(thread_id: str, *, path: Path | None = None) -> dict | None:
|
|
58
|
+
"""Return the ``thread/read(includeTurns)`` shape for one thread, or ``None``.
|
|
59
|
+
|
|
60
|
+
Turns come in ``rollout_ordinal`` order, including turns with no items;
|
|
61
|
+
each turn's items come in their own ``rollout_ordinal`` order. The result
|
|
62
|
+
carries ``historySource: "codex-history-db"`` and ``historyDbVersion``.
|
|
63
|
+
"""
|
|
64
|
+
if not isinstance(thread_id, str) or not thread_id:
|
|
65
|
+
return None
|
|
66
|
+
db = path or history_db_path()
|
|
67
|
+
if not db.is_file():
|
|
68
|
+
return None
|
|
69
|
+
try:
|
|
70
|
+
conn = sqlite3.connect(f"file:{quote(str(db))}?mode=ro", uri=True,
|
|
71
|
+
timeout=_BUSY_TIMEOUT_S)
|
|
72
|
+
except sqlite3.Error:
|
|
73
|
+
return None
|
|
74
|
+
try:
|
|
75
|
+
if not _schema_ok(conn):
|
|
76
|
+
return None
|
|
77
|
+
row = conn.execute(
|
|
78
|
+
"SELECT max(version) FROM _sqlx_migrations WHERE success").fetchone()
|
|
79
|
+
version = row[0] if row else None
|
|
80
|
+
# One read transaction: turns and items come from the same snapshot.
|
|
81
|
+
conn.execute("BEGIN")
|
|
82
|
+
turn_rows = conn.execute(
|
|
83
|
+
"SELECT turn_id, status, error_json, started_at, completed_at, duration_ms"
|
|
84
|
+
" FROM thread_turns WHERE thread_id = ? ORDER BY rollout_ordinal",
|
|
85
|
+
(thread_id,)).fetchall()
|
|
86
|
+
item_rows = conn.execute(
|
|
87
|
+
"SELECT turn_id, item_json FROM thread_items"
|
|
88
|
+
" WHERE thread_id = ? ORDER BY rollout_ordinal",
|
|
89
|
+
(thread_id,)).fetchall()
|
|
90
|
+
conn.execute("COMMIT")
|
|
91
|
+
except sqlite3.Error:
|
|
92
|
+
return None
|
|
93
|
+
finally:
|
|
94
|
+
conn.close()
|
|
95
|
+
|
|
96
|
+
if not turn_rows:
|
|
97
|
+
return None
|
|
98
|
+
items: dict[str, list[dict]] = {}
|
|
99
|
+
for turn_id, item_json in item_rows:
|
|
100
|
+
try:
|
|
101
|
+
item = json.loads(item_json)
|
|
102
|
+
except (TypeError, ValueError):
|
|
103
|
+
return None
|
|
104
|
+
if not isinstance(item, dict):
|
|
105
|
+
return None
|
|
106
|
+
items.setdefault(turn_id, []).append(item)
|
|
107
|
+
known = {r[0] for r in turn_rows}
|
|
108
|
+
if set(items) - known:
|
|
109
|
+
return None # items of a turn the projection doesn't list: not coherent
|
|
110
|
+
|
|
111
|
+
turns = []
|
|
112
|
+
for turn_id, status, error_json, started, completed, duration in turn_rows:
|
|
113
|
+
if status not in _TURN_STATUSES:
|
|
114
|
+
return None
|
|
115
|
+
error = None
|
|
116
|
+
if error_json:
|
|
117
|
+
try:
|
|
118
|
+
error = json.loads(error_json)
|
|
119
|
+
except ValueError:
|
|
120
|
+
return None
|
|
121
|
+
turns.append({
|
|
122
|
+
"id": turn_id, "status": status, "error": error,
|
|
123
|
+
"startedAt": started, "completedAt": completed, "durationMs": duration,
|
|
124
|
+
"items": items.get(turn_id, []), "itemsView": "full",
|
|
125
|
+
})
|
|
126
|
+
return {"id": thread_id, "turns": turns, "historySource": "codex-history-db",
|
|
127
|
+
"historyDbVersion": version}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
__all__ = ["HISTORY_DB_NAME", "VERIFIED_SCHEMA_VERSION", "history_db_path",
|
|
131
|
+
"read_thread_db"]
|
package/ccx/codex/runtime.py
CHANGED
|
@@ -54,6 +54,8 @@ CODEXD_BOOT_TIMEOUT_S = 60
|
|
|
54
54
|
# How long a caller without a deadline waits for a stopping codexd to exit. Its
|
|
55
55
|
# shutdown closes the app-server (up to three 3s waits) before it lets go.
|
|
56
56
|
CODEXD_STOP_WAIT_S = 15
|
|
57
|
+
# One presence probe's reply budget; a slower one is retried, not refused.
|
|
58
|
+
PRESENCE_PROBE_S = 5
|
|
57
59
|
|
|
58
60
|
|
|
59
61
|
@contextmanager
|
|
@@ -104,8 +106,8 @@ def _short_lived_app_server(
|
|
|
104
106
|
client.close()
|
|
105
107
|
|
|
106
108
|
|
|
107
|
-
def
|
|
108
|
-
"""Return
|
|
109
|
+
def list_model_entries(*, cwd: str, timeout: float = 30) -> list[dict]:
|
|
110
|
+
"""Return model/list's item dictionaries (default visibility, every page)."""
|
|
109
111
|
try:
|
|
110
112
|
with _short_lived_app_server(
|
|
111
113
|
cwd=cwd,
|
|
@@ -113,18 +115,15 @@ def list_models(*, cwd: str, timeout: float = 30) -> list[str]:
|
|
|
113
115
|
timeout=timeout,
|
|
114
116
|
rejection_prefix="unsupported request during model list read",
|
|
115
117
|
) as client:
|
|
116
|
-
|
|
118
|
+
entries: list[dict] = []
|
|
117
119
|
cursor: str | None = None
|
|
118
120
|
seen_cursors: set[str] = set()
|
|
119
121
|
while True:
|
|
120
122
|
params = {"cursor": cursor} if cursor is not None else {}
|
|
121
123
|
page = client.request("model/list", params, timeout=timeout)
|
|
122
124
|
for item in page.get("data") or []:
|
|
123
|
-
if
|
|
124
|
-
|
|
125
|
-
model_id = item.get("model") or item.get("id")
|
|
126
|
-
if isinstance(model_id, str) and model_id and model_id not in models:
|
|
127
|
-
models.append(model_id)
|
|
125
|
+
if isinstance(item, dict):
|
|
126
|
+
entries.append(item)
|
|
128
127
|
next_cursor = page.get("nextCursor")
|
|
129
128
|
if not isinstance(next_cursor, str) or not next_cursor:
|
|
130
129
|
break
|
|
@@ -132,15 +131,27 @@ def list_models(*, cwd: str, timeout: float = 30) -> list[str]:
|
|
|
132
131
|
raise RuntimeError("model/list returned a repeated cursor")
|
|
133
132
|
seen_cursors.add(next_cursor)
|
|
134
133
|
cursor = next_cursor
|
|
135
|
-
if not
|
|
136
|
-
raise RuntimeError("model/list returned no model
|
|
137
|
-
return
|
|
134
|
+
if not entries:
|
|
135
|
+
raise RuntimeError("model/list returned no model entries")
|
|
136
|
+
return entries
|
|
138
137
|
except (OSError, RuntimeError) as error:
|
|
139
138
|
raise SessionUnavailable(
|
|
140
139
|
f"Codex model list unavailable: {error}"
|
|
141
140
|
) from error
|
|
142
141
|
|
|
143
142
|
|
|
143
|
+
def list_models(*, cwd: str, timeout: float = 30) -> list[str]:
|
|
144
|
+
"""Return every model ID advertised by Codex's stable model/list API."""
|
|
145
|
+
models: list[str] = []
|
|
146
|
+
for item in list_model_entries(cwd=cwd, timeout=timeout):
|
|
147
|
+
model_id = item.get("model") or item.get("id")
|
|
148
|
+
if isinstance(model_id, str) and model_id and model_id not in models:
|
|
149
|
+
models.append(model_id)
|
|
150
|
+
if not models:
|
|
151
|
+
raise SessionUnavailable("Codex model list unavailable: model/list returned no model IDs")
|
|
152
|
+
return models
|
|
153
|
+
|
|
154
|
+
|
|
144
155
|
def validate_model(model: str, *, cwd: str, timeout: float = 30) -> list[str]:
|
|
145
156
|
"""Validate one explicit model and return the complete advertised ID list."""
|
|
146
157
|
if not isinstance(model, str) or not model:
|
|
@@ -233,7 +244,7 @@ def observation_daemon(
|
|
|
233
244
|
return False
|
|
234
245
|
if transport != RUNTIME_SERVED:
|
|
235
246
|
raise SessionUnavailable(
|
|
236
|
-
"
|
|
247
|
+
inconclusive_presence(probe, "no second app-server was started"),
|
|
237
248
|
short=short or "codexd",
|
|
238
249
|
)
|
|
239
250
|
require_compatible_daemon(probe, short=short)
|
|
@@ -292,8 +303,15 @@ def _lifetime_lock_held() -> bool | None:
|
|
|
292
303
|
|
|
293
304
|
def _transport_failure(error: BaseException) -> dict[str, Any]:
|
|
294
305
|
"""Classify a failed socket observation without confusing timeout with absence."""
|
|
295
|
-
if isinstance(error, (TimeoutError, socket.timeout)):
|
|
296
|
-
|
|
306
|
+
if isinstance(error, (TimeoutError, socket.timeout, EOFError)):
|
|
307
|
+
# A busy machine or a daemon mid-exit: unknown, but worth asking again
|
|
308
|
+
# inside the caller's budget. Never evidence of absence.
|
|
309
|
+
return {
|
|
310
|
+
"ok": False,
|
|
311
|
+
"error": f"codexd unavailable: {error.__class__.__name__}",
|
|
312
|
+
"transport": RUNTIME_INCONCLUSIVE,
|
|
313
|
+
"retry": True,
|
|
314
|
+
}
|
|
297
315
|
elif isinstance(error, OSError) and getattr(error, "errno", None) in {
|
|
298
316
|
2, # ENOENT: no socket file
|
|
299
317
|
32, # EPIPE: the listener closed under the request
|
|
@@ -536,7 +554,8 @@ def preflight_daemon(
|
|
|
536
554
|
return probe
|
|
537
555
|
if probe.get("transport") != RUNTIME_SERVED:
|
|
538
556
|
raise SessionUnavailable(
|
|
539
|
-
"
|
|
557
|
+
inconclusive_presence(probe, "nothing was sent"),
|
|
558
|
+
short=short or "codexd",
|
|
540
559
|
)
|
|
541
560
|
require_compatible_daemon(probe, short=short)
|
|
542
561
|
return None
|
|
@@ -569,9 +588,16 @@ def rpc(
|
|
|
569
588
|
)
|
|
570
589
|
|
|
571
590
|
|
|
572
|
-
def daemon_status(timeout: float = 2) -> dict[str, Any]:
|
|
573
|
-
"""Probe the daemon. Deliberately unchecked: status is a recovery tool.
|
|
574
|
-
|
|
591
|
+
def daemon_status(timeout: float = 2, *, presence_only: bool = False) -> dict[str, Any]:
|
|
592
|
+
"""Probe the daemon. Deliberately unchecked: status is a recovery tool.
|
|
593
|
+
|
|
594
|
+
``presence_only`` skips the per-worker states, which take every worker's
|
|
595
|
+
lock; a daemon that predates the flag ignores it and lists them anyway.
|
|
596
|
+
"""
|
|
597
|
+
message: dict[str, Any] = {"op": "daemon-status"}
|
|
598
|
+
if presence_only:
|
|
599
|
+
message["workers"] = False
|
|
600
|
+
return daemon_rpc(message, timeout=timeout)
|
|
575
601
|
|
|
576
602
|
|
|
577
603
|
def _stopping(probe: dict[str, Any]) -> bool:
|
|
@@ -584,22 +610,37 @@ def _stopping(probe: dict[str, Any]) -> bool:
|
|
|
584
610
|
|
|
585
611
|
|
|
586
612
|
def settled_daemon_status(deadline: float | None) -> dict[str, Any]:
|
|
587
|
-
"""Probe the daemon, waiting out one that is binding or shutting down.
|
|
613
|
+
"""Probe the daemon, waiting out one that is slow, binding or shutting down.
|
|
588
614
|
|
|
589
615
|
A stopping codexd refuses new loads and is about to release the runtime,
|
|
590
|
-
so neither "use it" nor "start another" is right yet
|
|
591
|
-
|
|
592
|
-
|
|
616
|
+
so neither "use it" nor "start another" is right yet; a probe that timed
|
|
617
|
+
out says nothing either way. Both are asked again until the caller's
|
|
618
|
+
deadline (or ``CODEXD_STOP_WAIT_S``); what is still unknown then is
|
|
619
|
+
returned as the inconclusive probe it is, with how long it was waited for.
|
|
593
620
|
"""
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
)
|
|
621
|
+
started = time.monotonic()
|
|
622
|
+
limit = deadline if deadline is not None else started + CODEXD_STOP_WAIT_S
|
|
623
|
+
attempts = 0
|
|
598
624
|
while True:
|
|
599
|
-
|
|
600
|
-
|
|
625
|
+
attempts += 1
|
|
626
|
+
probe = daemon_status(timeout=remaining(limit, PRESENCE_PROBE_S),
|
|
627
|
+
presence_only=True)
|
|
628
|
+
now = time.monotonic()
|
|
629
|
+
if not (_stopping(probe) or probe.get("retry")) or now >= limit:
|
|
630
|
+
if probe.get("transport") == RUNTIME_INCONCLUSIVE:
|
|
631
|
+
probe = {**probe, "attempts": attempts,
|
|
632
|
+
"waitedSeconds": round(now - started, 1)}
|
|
601
633
|
return probe
|
|
602
|
-
time.sleep(min(0.
|
|
634
|
+
time.sleep(min(0.1, max(0.0, limit - now)))
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def inconclusive_presence(probe: dict[str, Any], outcome: str) -> str:
|
|
638
|
+
"""The refusal for a presence probe that never settled, with its evidence."""
|
|
639
|
+
error = str(probe.get("error") or "no reply").removeprefix("codexd unavailable: ")
|
|
640
|
+
attempts = probe.get("attempts") or 1
|
|
641
|
+
tried = (f" after {attempts} probes over {probe.get('waitedSeconds')}s"
|
|
642
|
+
if attempts > 1 else "")
|
|
643
|
+
return f"codexd presence is inconclusive ({error}{tried}); {outcome}"
|
|
603
644
|
|
|
604
645
|
|
|
605
646
|
def observe_worker(
|
|
@@ -1198,7 +1239,7 @@ def ensure_daemon(deadline: float | None = None) -> dict[str, Any]:
|
|
|
1198
1239
|
probe.get("transport") == RUNTIME_INCONCLUSIVE and probe.get("transitional")
|
|
1199
1240
|
):
|
|
1200
1241
|
raise SessionUnavailable(
|
|
1201
|
-
"
|
|
1242
|
+
inconclusive_presence(probe, "refusing to start a second daemon"),
|
|
1202
1243
|
short="codexd",
|
|
1203
1244
|
)
|
|
1204
1245
|
with daemon_spawn_lock(deadline):
|
|
@@ -1211,7 +1252,7 @@ def ensure_daemon(deadline: float | None = None) -> dict[str, Any]:
|
|
|
1211
1252
|
"transitional"
|
|
1212
1253
|
):
|
|
1213
1254
|
raise SessionUnavailable(
|
|
1214
|
-
"
|
|
1255
|
+
inconclusive_presence(probe, "refusing to start a second daemon"),
|
|
1215
1256
|
short="codexd",
|
|
1216
1257
|
)
|
|
1217
1258
|
process = None
|
|
@@ -1389,6 +1430,7 @@ __all__ = [
|
|
|
1389
1430
|
"daemon_stop",
|
|
1390
1431
|
"ensure_daemon",
|
|
1391
1432
|
"identity",
|
|
1433
|
+
"list_model_entries",
|
|
1392
1434
|
"list_models",
|
|
1393
1435
|
"daemon_generation",
|
|
1394
1436
|
"daemon_spawn_lock",
|
package/ccx/models.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Read-only, engine-neutral model catalogs with private per-engine caching."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
import hashlib
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
from ccx import constants
|
|
12
|
+
from ccx.claude.oauth import oauth_organization_uuid
|
|
13
|
+
from ccx.claude.usage_control import request_models
|
|
14
|
+
from ccx.codex.runtime import list_model_entries
|
|
15
|
+
from ccx.errors import InvalidRequest
|
|
16
|
+
from ccx.system.private_json import _atomic_private_json, _read_private_json
|
|
17
|
+
from ccx.types import ModelCatalog, ModelInfo
|
|
18
|
+
|
|
19
|
+
_CACHE_TTL_S = 24 * 60 * 60
|
|
20
|
+
_CACHE_VERSION = 1
|
|
21
|
+
_ENGINES = ("claude", "codex")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _text(value: object) -> str | None:
|
|
25
|
+
return value if isinstance(value, str) and value else None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _efforts(value: object) -> list[str]:
|
|
29
|
+
if not isinstance(value, list):
|
|
30
|
+
return []
|
|
31
|
+
return list(dict.fromkeys(entry for entry in value
|
|
32
|
+
if isinstance(entry, str) and entry))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def normalize_claude_models(entries: object) -> list[ModelInfo]:
|
|
36
|
+
"""Project Claude initialize's models only, with per-model capabilities."""
|
|
37
|
+
if not isinstance(entries, list):
|
|
38
|
+
return []
|
|
39
|
+
models = []
|
|
40
|
+
for item in entries:
|
|
41
|
+
if not isinstance(item, dict):
|
|
42
|
+
continue
|
|
43
|
+
ident = _text(item.get("value"))
|
|
44
|
+
resolved = _text(item.get("resolvedModel"))
|
|
45
|
+
name = _text(item.get("displayName"))
|
|
46
|
+
if not (ident and resolved and name):
|
|
47
|
+
continue
|
|
48
|
+
models.append(ModelInfo(
|
|
49
|
+
id=ident,
|
|
50
|
+
resolved=resolved,
|
|
51
|
+
display_name=name,
|
|
52
|
+
description=(item.get("description")
|
|
53
|
+
if isinstance(item.get("description"), str) else None),
|
|
54
|
+
alias=ident != resolved,
|
|
55
|
+
default=ident == "default",
|
|
56
|
+
efforts=(_efforts(item.get("supportedEffortLevels"))
|
|
57
|
+
if item.get("supportsEffort") is True else []),
|
|
58
|
+
default_effort=None,
|
|
59
|
+
features={
|
|
60
|
+
"fast": item.get("supportsFastMode") is True,
|
|
61
|
+
"auto": item.get("supportsAutoMode") is True,
|
|
62
|
+
"thinkingOff": item.get("supportsThinkingOff") is True,
|
|
63
|
+
},
|
|
64
|
+
))
|
|
65
|
+
return models
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def normalize_codex_models(entries: object) -> list[ModelInfo]:
|
|
69
|
+
"""Project model/list items; Codex's model ID is the --model spelling."""
|
|
70
|
+
if not isinstance(entries, list):
|
|
71
|
+
return []
|
|
72
|
+
models = []
|
|
73
|
+
seen = set()
|
|
74
|
+
for item in entries:
|
|
75
|
+
if not isinstance(item, dict) or item.get("hidden") is True:
|
|
76
|
+
continue
|
|
77
|
+
ident = _text(item.get("model")) or _text(item.get("id"))
|
|
78
|
+
if not ident or ident in seen:
|
|
79
|
+
continue
|
|
80
|
+
seen.add(ident)
|
|
81
|
+
efforts = item.get("supportedReasoningEfforts")
|
|
82
|
+
levels = (_efforts([entry.get("reasoningEffort") for entry in efforts
|
|
83
|
+
if isinstance(entry, dict)])
|
|
84
|
+
if isinstance(efforts, list) else [])
|
|
85
|
+
models.append(ModelInfo(
|
|
86
|
+
id=ident,
|
|
87
|
+
resolved=ident,
|
|
88
|
+
display_name=_text(item.get("displayName")) or ident,
|
|
89
|
+
description=(item.get("description")
|
|
90
|
+
if isinstance(item.get("description"), str) else None),
|
|
91
|
+
alias=False,
|
|
92
|
+
default=item.get("isDefault") is True,
|
|
93
|
+
efforts=levels,
|
|
94
|
+
default_effort=_text(item.get("defaultReasoningEffort")),
|
|
95
|
+
features={},
|
|
96
|
+
))
|
|
97
|
+
return models
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _cache_path(engine: str) -> Path:
|
|
101
|
+
return constants.CCX_ROOT / f"models-{engine}.json"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _local_claude_version() -> str | None:
|
|
105
|
+
# Bypass oauth.py's process-wide lru_cache: a live CcxClient must notice
|
|
106
|
+
# when the installed Claude Code binary changes beneath it.
|
|
107
|
+
from ccx.claude.oauth import claude_code_version
|
|
108
|
+
return claude_code_version.__wrapped__()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _cache_key(engine: str) -> dict:
|
|
112
|
+
if engine == "codex":
|
|
113
|
+
# No cheap native Codex version helper; this engine uses the TTL only.
|
|
114
|
+
return {}
|
|
115
|
+
org = oauth_organization_uuid()
|
|
116
|
+
return {
|
|
117
|
+
"version": _local_claude_version(),
|
|
118
|
+
"orgDigest": hashlib.sha256(org.encode()).hexdigest() if org else None,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _read_cache(engine: str) -> tuple[ModelCatalog | None, dict]:
|
|
123
|
+
try:
|
|
124
|
+
data = _read_private_json(_cache_path(engine))
|
|
125
|
+
if not isinstance(data, dict) or data.get("version") != _CACHE_VERSION:
|
|
126
|
+
return None, {}
|
|
127
|
+
catalog = ModelCatalog.from_dict(data["catalog"])
|
|
128
|
+
if catalog.engine != engine:
|
|
129
|
+
return None, {}
|
|
130
|
+
key = data.get("key")
|
|
131
|
+
return catalog, key if isinstance(key, dict) else {}
|
|
132
|
+
except (OSError, ValueError, KeyError, TypeError, UnicodeError):
|
|
133
|
+
return None, {}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _fresh(catalog: ModelCatalog, saved_key: dict, current_key: dict) -> bool:
|
|
137
|
+
if catalog.engine == "claude":
|
|
138
|
+
if saved_key.get("orgDigest") != current_key.get("orgDigest"):
|
|
139
|
+
return False
|
|
140
|
+
# If the installed version cannot be read, only TTL/org can be checked.
|
|
141
|
+
local_version = current_key.get("version")
|
|
142
|
+
if local_version is not None and catalog.engine_version != local_version:
|
|
143
|
+
return False
|
|
144
|
+
try:
|
|
145
|
+
stamped = datetime.fromisoformat(catalog.fetched_at.replace("Z", "+00:00"))
|
|
146
|
+
if stamped.tzinfo is None:
|
|
147
|
+
return False
|
|
148
|
+
age = time.time() - stamped.timestamp()
|
|
149
|
+
except (ValueError, OverflowError):
|
|
150
|
+
return False
|
|
151
|
+
return 0 <= age < _CACHE_TTL_S
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _probe(engine: str) -> tuple[str | None, list[ModelInfo]]:
|
|
155
|
+
if engine == "claude":
|
|
156
|
+
reply = request_models()
|
|
157
|
+
if reply.get("kind") != "success":
|
|
158
|
+
raise RuntimeError(reply.get("error") or "initialize unavailable")
|
|
159
|
+
body = reply["body"]
|
|
160
|
+
return _text(body.get("claude_code_version")), normalize_claude_models(body.get("models"))
|
|
161
|
+
# Account-level catalog, independent of the calling project's cwd.
|
|
162
|
+
return None, normalize_codex_models(list_model_entries(cwd=str(Path.home())))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _builtin(engine: str, note: str) -> ModelCatalog:
|
|
166
|
+
# Only Claude has known aliases; inventing Codex model names would be wrong.
|
|
167
|
+
models = ([ModelInfo(
|
|
168
|
+
id=alias, resolved=None, display_name=alias.title(),
|
|
169
|
+
description=None, alias=True, default=False, efforts=[],
|
|
170
|
+
default_effort=None, features={},
|
|
171
|
+
) for alias in constants.MODEL_ALIASES] if engine == "claude" else [])
|
|
172
|
+
return ModelCatalog(
|
|
173
|
+
engine=engine, engine_version=None, source="builtin",
|
|
174
|
+
fetched_at=_now(), note=note, models=models,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _now() -> str:
|
|
179
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _catalog(engine: str, *, refresh: bool) -> ModelCatalog:
|
|
183
|
+
cached, saved_key = _read_cache(engine)
|
|
184
|
+
try:
|
|
185
|
+
current_key = _cache_key(engine)
|
|
186
|
+
except Exception:
|
|
187
|
+
# Broken or unavailable local identity metadata cannot block the other
|
|
188
|
+
# engine's catalog (or the provider read for this engine).
|
|
189
|
+
current_key = {}
|
|
190
|
+
if not refresh and cached is not None and _fresh(cached, saved_key, current_key):
|
|
191
|
+
return replace(cached, source="cache")
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
version, models = _probe(engine)
|
|
195
|
+
if not models:
|
|
196
|
+
raise RuntimeError("no valid model entries")
|
|
197
|
+
except Exception as error:
|
|
198
|
+
note = f"{engine.capitalize()} model list unavailable: {error}"
|
|
199
|
+
if cached is not None:
|
|
200
|
+
return replace(cached, source="stale", note=note)
|
|
201
|
+
return _builtin(engine, note)
|
|
202
|
+
|
|
203
|
+
result = ModelCatalog(
|
|
204
|
+
engine=engine, engine_version=version, source="live",
|
|
205
|
+
fetched_at=_now(), note=None, models=models,
|
|
206
|
+
)
|
|
207
|
+
try:
|
|
208
|
+
_atomic_private_json(_cache_path(engine), {
|
|
209
|
+
"version": _CACHE_VERSION,
|
|
210
|
+
"key": ({"version": version, "orgDigest": current_key.get("orgDigest")}
|
|
211
|
+
if engine == "claude" else {}),
|
|
212
|
+
"catalog": result.as_dict(),
|
|
213
|
+
})
|
|
214
|
+
except OSError:
|
|
215
|
+
# A live listing remains useful if the private cache cannot be saved.
|
|
216
|
+
pass
|
|
217
|
+
return result
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def read_models(*, engine: str | None = None, refresh: bool = False) -> list[ModelCatalog]:
|
|
221
|
+
"""Return one or both model catalogs; probe failures degrade per engine."""
|
|
222
|
+
if engine is not None and engine not in _ENGINES:
|
|
223
|
+
raise InvalidRequest("engine must be claude or codex")
|
|
224
|
+
return [_catalog(name, refresh=refresh)
|
|
225
|
+
for name in ((engine,) if engine else _ENGINES)]
|