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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Promptless Claude Code usage control protocol; no credential
|
|
1
|
+
"""Promptless Claude Code usage and model control protocol; no credential reads."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -22,18 +22,20 @@ _MAX_OUTPUT_BYTES = 2 * 1024 * 1024
|
|
|
22
22
|
_MAX_LINE_BYTES = 1 * 1024 * 1024
|
|
23
23
|
|
|
24
24
|
|
|
25
|
-
def
|
|
26
|
-
"""
|
|
25
|
+
def _control_exchange(requests: list[dict], timeout: float, cwd: str | None = None) -> dict:
|
|
26
|
+
"""Send a list of requests through one bounded promptless child.
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
Initialize replies are projected immediately: the full response can also
|
|
29
|
+
contain personal account data, which must never leave this transport.
|
|
30
30
|
"""
|
|
31
31
|
timeout = max(0.1, float(timeout))
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
deadline = time.monotonic() + timeout
|
|
33
|
+
ids = [(uuid.uuid4().hex, request) for request in requests]
|
|
34
|
+
target_id = ids[-1][0]
|
|
35
|
+
subtypes = {rid: request["subtype"] for rid, request in ids}
|
|
35
36
|
child = None
|
|
36
|
-
|
|
37
|
+
replies: dict[str, dict] = {}
|
|
38
|
+
seen_target = False
|
|
37
39
|
invalid_line = None
|
|
38
40
|
total_bytes = 0
|
|
39
41
|
buffer = b""
|
|
@@ -41,17 +43,14 @@ def request_usage(timeout: float = 8.0) -> dict:
|
|
|
41
43
|
try:
|
|
42
44
|
child = subprocess.Popen(
|
|
43
45
|
[CLAUDE_BIN, *_PROBE_ARGS], stdin=subprocess.PIPE,
|
|
44
|
-
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
|
46
|
+
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, cwd=cwd,
|
|
45
47
|
)
|
|
46
|
-
for rid, request in
|
|
47
|
-
(init_id, {"subtype": "initialize"}),
|
|
48
|
-
(usage_id, {"subtype": "get_usage", "skip_behaviors": True}),
|
|
49
|
-
):
|
|
48
|
+
for rid, request in ids:
|
|
50
49
|
message = {"type": "control_request", "request_id": rid, "request": request}
|
|
51
50
|
child.stdin.write((json.dumps(message, separators=(",", ":")) + "\n").encode())
|
|
52
51
|
child.stdin.flush()
|
|
53
52
|
|
|
54
|
-
while time.monotonic() < deadline and
|
|
53
|
+
while time.monotonic() < deadline and not seen_target and problem is None:
|
|
55
54
|
readable, _, _ = select.select(
|
|
56
55
|
[child.stdout], [], [], min(0.2, max(0, deadline - time.monotonic()))
|
|
57
56
|
)
|
|
@@ -85,11 +84,31 @@ def request_usage(timeout: float = 8.0) -> dict:
|
|
|
85
84
|
response = message.get("response")
|
|
86
85
|
if not isinstance(response, dict):
|
|
87
86
|
continue
|
|
88
|
-
|
|
87
|
+
rid = response.get("request_id")
|
|
88
|
+
if rid not in subtypes:
|
|
89
|
+
continue
|
|
90
|
+
subtype = subtypes[rid]
|
|
91
|
+
if subtype == "initialize" and response.get("subtype") == "error":
|
|
89
92
|
problem = "Claude usage initialize control request failed"
|
|
90
93
|
break
|
|
91
|
-
if
|
|
92
|
-
|
|
94
|
+
if subtype == "initialize":
|
|
95
|
+
body = response.get("response")
|
|
96
|
+
# Do not retain the rest of initialize (notably 'account').
|
|
97
|
+
safe_body = ({
|
|
98
|
+
"models": body.get("models"),
|
|
99
|
+
"claude_code_version": body.get("claude_code_version"),
|
|
100
|
+
} if isinstance(body, dict) else None)
|
|
101
|
+
message = {
|
|
102
|
+
"type": "control_response",
|
|
103
|
+
"response": {
|
|
104
|
+
"request_id": rid,
|
|
105
|
+
"subtype": response.get("subtype"),
|
|
106
|
+
"response": safe_body,
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
replies[subtype] = message
|
|
110
|
+
if rid == target_id:
|
|
111
|
+
seen_target = True
|
|
93
112
|
break
|
|
94
113
|
# Always close stdin after the matching response, or on a bounded failure.
|
|
95
114
|
child.stdin.close()
|
|
@@ -100,7 +119,7 @@ def request_usage(timeout: float = 8.0) -> dict:
|
|
|
100
119
|
except subprocess.TimeoutExpired:
|
|
101
120
|
problem = problem or "Claude usage control timed out"
|
|
102
121
|
exit_code = None
|
|
103
|
-
if
|
|
122
|
+
if not seen_target:
|
|
104
123
|
if problem is None:
|
|
105
124
|
problem = "Claude usage control returned no matching response"
|
|
106
125
|
if invalid_line is not None:
|
|
@@ -109,28 +128,7 @@ def request_usage(timeout: float = 8.0) -> dict:
|
|
|
109
128
|
return {"kind": "error", "error": problem}
|
|
110
129
|
if problem is not None or exit_code not in (0, None):
|
|
111
130
|
return {"kind": "error", "error": problem or "Claude usage control exited unsuccessfully"}
|
|
112
|
-
|
|
113
|
-
if response.get("subtype") == "error":
|
|
114
|
-
return {"kind": "error", "error": "Claude usage control response was an error"}
|
|
115
|
-
if response.get("subtype") != "success":
|
|
116
|
-
return {"kind": "unparsed", "raw": reply,
|
|
117
|
-
"error": "Claude usage control returned an unknown response subtype"}
|
|
118
|
-
body = response.get("response")
|
|
119
|
-
if not isinstance(body, dict):
|
|
120
|
-
return {"kind": "unparsed", "raw": reply,
|
|
121
|
-
"error": "Claude usage control returned an unknown JSON body"}
|
|
122
|
-
if body.get("rate_limits_available") is False and body.get("rate_limits") is None:
|
|
123
|
-
return {"kind": "unavailable", "error": "Claude plan rate limits unavailable"}
|
|
124
|
-
if body.get("rate_limits_available") is True and body.get("rate_limits") is None:
|
|
125
|
-
return {"kind": "held", "error": "Claude plan usage is held by the provider"}
|
|
126
|
-
rate_limits = body.get("rate_limits")
|
|
127
|
-
if isinstance(rate_limits, dict) and any(k in rate_limits for k in (
|
|
128
|
-
"five_hour", "seven_day", "limits", "model_scoped", "extra_usage",
|
|
129
|
-
"seven_day_opus", "seven_day_sonnet",
|
|
130
|
-
)):
|
|
131
|
-
return {"kind": "success", "body": rate_limits}
|
|
132
|
-
return {"kind": "unparsed", "raw": reply,
|
|
133
|
-
"error": "Claude usage control returned an unrecognized rate_limits field"}
|
|
131
|
+
return {"kind": "success", "responses": replies}
|
|
134
132
|
except (OSError, ValueError, BrokenPipeError) as exc:
|
|
135
133
|
return {"kind": "error", "error": f"Claude usage control unavailable: {type(exc).__name__}"}
|
|
136
134
|
finally:
|
|
@@ -150,3 +148,57 @@ def request_usage(timeout: float = 8.0) -> dict:
|
|
|
150
148
|
child.wait(timeout=1.0)
|
|
151
149
|
if child.stdout is not None:
|
|
152
150
|
child.stdout.close()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def request_usage(timeout: float = 8.0) -> dict:
|
|
154
|
+
"""Return {kind, body?, raw?, error?} from one bounded get_usage exchange.
|
|
155
|
+
|
|
156
|
+
The native CLI owns auth. The only input consists of initialize/get_usage
|
|
157
|
+
control requests; no model prompt, hooks, MCP server or transcript scan.
|
|
158
|
+
"""
|
|
159
|
+
result = _control_exchange([
|
|
160
|
+
{"subtype": "initialize"},
|
|
161
|
+
{"subtype": "get_usage", "skip_behaviors": True},
|
|
162
|
+
], timeout)
|
|
163
|
+
if result["kind"] != "success":
|
|
164
|
+
return result
|
|
165
|
+
reply = result["responses"]["get_usage"]
|
|
166
|
+
response = reply["response"]
|
|
167
|
+
if response.get("subtype") == "error":
|
|
168
|
+
return {"kind": "error", "error": "Claude usage control response was an error"}
|
|
169
|
+
if response.get("subtype") != "success":
|
|
170
|
+
return {"kind": "unparsed", "raw": reply,
|
|
171
|
+
"error": "Claude usage control returned an unknown response subtype"}
|
|
172
|
+
body = response.get("response")
|
|
173
|
+
if not isinstance(body, dict):
|
|
174
|
+
return {"kind": "unparsed", "raw": reply,
|
|
175
|
+
"error": "Claude usage control returned an unknown JSON body"}
|
|
176
|
+
if body.get("rate_limits_available") is False and body.get("rate_limits") is None:
|
|
177
|
+
return {"kind": "unavailable", "error": "Claude plan rate limits unavailable"}
|
|
178
|
+
if body.get("rate_limits_available") is True and body.get("rate_limits") is None:
|
|
179
|
+
return {"kind": "held", "error": "Claude plan usage is held by the provider"}
|
|
180
|
+
rate_limits = body.get("rate_limits")
|
|
181
|
+
if isinstance(rate_limits, dict) and any(k in rate_limits for k in (
|
|
182
|
+
"five_hour", "seven_day", "limits", "model_scoped", "extra_usage",
|
|
183
|
+
"seven_day_opus", "seven_day_sonnet",
|
|
184
|
+
)):
|
|
185
|
+
return {"kind": "success", "body": rate_limits}
|
|
186
|
+
return {"kind": "unparsed", "raw": reply,
|
|
187
|
+
"error": "Claude usage control returned an unrecognized rate_limits field"}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def request_models(timeout: float = 8.0) -> dict:
|
|
191
|
+
"""Return only Claude's model entries and version from initialize."""
|
|
192
|
+
# From home, so one project's settings can't shape the shared catalog.
|
|
193
|
+
result = _control_exchange([{"subtype": "initialize"}], timeout,
|
|
194
|
+
cwd=os.path.expanduser("~"))
|
|
195
|
+
if result["kind"] != "success":
|
|
196
|
+
# Do not return raw stdout; initialize may include private account data.
|
|
197
|
+
return {"kind": "error", "error": result.get("error") or "Claude model probe failed"}
|
|
198
|
+
response = result["responses"]["initialize"]["response"]
|
|
199
|
+
if response.get("subtype") != "success":
|
|
200
|
+
return {"kind": "error", "error": "Claude model initialize failed"}
|
|
201
|
+
body = response.get("response")
|
|
202
|
+
if not isinstance(body, dict) or not isinstance(body.get("models"), list):
|
|
203
|
+
return {"kind": "error", "error": "Claude model initialize returned no models"}
|
|
204
|
+
return {"kind": "success", "body": body}
|
|
@@ -51,7 +51,7 @@ def cmd_stop(args):
|
|
|
51
51
|
"cancelled": result["confirmed"] and result["outcome"] == "interrupted",
|
|
52
52
|
}
|
|
53
53
|
print(json.dumps(value) if getattr(args, "json", False)
|
|
54
|
-
else (f"{result['worker']['worker']}: stop confirmed; turn
|
|
54
|
+
else (f"{result['worker']['worker']}: stop confirmed; the turn is no longer running, outcome unknown"
|
|
55
55
|
if result["confirmed"] and result["outcome"] == "unknown"
|
|
56
56
|
else f"{result['worker']['worker']}: stop {result['status']}; turn outcome {result['outcome']}"))
|
|
57
57
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Read-only model catalog CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from ccx.constants import EXIT_UNAVAILABLE
|
|
9
|
+
from ccx.models import read_models
|
|
10
|
+
from ccx.types import ModelCatalog
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _table(catalog: ModelCatalog) -> None:
|
|
14
|
+
label = catalog.engine.capitalize()
|
|
15
|
+
if catalog.engine_version:
|
|
16
|
+
label += f" {catalog.engine_version}"
|
|
17
|
+
print(f"{label} models ({catalog.source}):")
|
|
18
|
+
if catalog.note:
|
|
19
|
+
print(f" {catalog.note}")
|
|
20
|
+
models = sorted(catalog.models, key=lambda entry: (not entry.default, not entry.alias))
|
|
21
|
+
if not models:
|
|
22
|
+
print(" No models available")
|
|
23
|
+
return
|
|
24
|
+
rows = [
|
|
25
|
+
(entry.id, entry.display_name,
|
|
26
|
+
entry.resolved if entry.resolved and entry.resolved != entry.id else "",
|
|
27
|
+
", ".join(entry.efforts))
|
|
28
|
+
for entry in models
|
|
29
|
+
]
|
|
30
|
+
headers = ("--model", "Display name", "Resolved", "Efforts")
|
|
31
|
+
widths = [max(len(row[i]) for row in [headers, *rows]) for i in range(3)]
|
|
32
|
+
for row in [headers, *rows]:
|
|
33
|
+
print(" " + " ".join(
|
|
34
|
+
[*(row[i].ljust(widths[i]) for i in range(3)), row[3]]
|
|
35
|
+
).rstrip())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def cmd_models(args) -> None:
|
|
39
|
+
"""Print one or both available model catalogs."""
|
|
40
|
+
catalogs = read_models(engine=args.engine, refresh=args.refresh)
|
|
41
|
+
if args.json:
|
|
42
|
+
print(json.dumps([catalog.as_dict() for catalog in catalogs], indent=2))
|
|
43
|
+
else:
|
|
44
|
+
for i, catalog in enumerate(catalogs):
|
|
45
|
+
if i:
|
|
46
|
+
print()
|
|
47
|
+
_table(catalog)
|
|
48
|
+
if args.refresh:
|
|
49
|
+
failed = [catalog for catalog in catalogs if catalog.source in ("stale", "builtin")]
|
|
50
|
+
if failed:
|
|
51
|
+
for catalog in failed:
|
|
52
|
+
sys.stderr.write(f"ccx models: {catalog.engine} refresh failed: {catalog.note}\n")
|
|
53
|
+
raise SystemExit(EXIT_UNAVAILABLE)
|
package/ccx/cli/commands/read.py
CHANGED
|
@@ -293,6 +293,11 @@ def cmd_logs(args):
|
|
|
293
293
|
sys.stdout.write(client._claude.logs(owner.worker, jsonl=True))
|
|
294
294
|
return
|
|
295
295
|
if args.format == "rendered":
|
|
296
|
+
sys.stderr.write(
|
|
297
|
+
"[ccx] logs --format rendered is deprecated and will be removed; "
|
|
298
|
+
"read a conversation as text with `ccx history TARGET --view trace`, "
|
|
299
|
+
"or use the default JSON in programs\n"
|
|
300
|
+
)
|
|
296
301
|
doc = client._claude.logs(owner.worker, tail=args.tail)
|
|
297
302
|
json.dump(doc, sys.stdout, indent=2)
|
|
298
303
|
sys.stdout.write("\n")
|
|
@@ -317,6 +322,57 @@ def cmd_logs(args):
|
|
|
317
322
|
raise SystemExit(error.exit_code) from error
|
|
318
323
|
|
|
319
324
|
|
|
325
|
+
def cmd_history(args):
|
|
326
|
+
"""Print the named workers' past conversations as text, or search them.
|
|
327
|
+
|
|
328
|
+
Read-only: each worker's normalized document is read from its original
|
|
329
|
+
source (Codex from its local history database when it can be), projected
|
|
330
|
+
by ``ccx.ops.history`` and printed. A worker that can't be read is reported
|
|
331
|
+
on stderr while the others still print.
|
|
332
|
+
"""
|
|
333
|
+
from ccx import constants
|
|
334
|
+
from ccx.ops import history as hist
|
|
335
|
+
from ccx.store.identity import IdentityError
|
|
336
|
+
from ccx.store.root import GenerationError, require_ccx_active
|
|
337
|
+
from ccx.workers import WorkerClient
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
opts = hist.options(
|
|
341
|
+
view=args.view, turns=args.turns, grep=args.grep, cmd=args.command_regex,
|
|
342
|
+
errors=args.errors, role=args.role, since=args.since, until=args.until,
|
|
343
|
+
context=args.context, limit=args.limit, max_chars=args.max_chars,
|
|
344
|
+
full=args.full)
|
|
345
|
+
except InvalidRequest as error:
|
|
346
|
+
print(f"ccx history: {error}", file=sys.stderr)
|
|
347
|
+
raise SystemExit(error.exit_code) from None
|
|
348
|
+
store = require_ccx_active(constants.CCX_ROOT)
|
|
349
|
+
client = WorkerClient()
|
|
350
|
+
zone = hist.zone_name()
|
|
351
|
+
many = len(args.targets) > 1
|
|
352
|
+
hint = None
|
|
353
|
+
failed = 0
|
|
354
|
+
for target in args.targets:
|
|
355
|
+
try:
|
|
356
|
+
owner = store.resolve(target)
|
|
357
|
+
name = store.record(owner.worker).get("name")
|
|
358
|
+
doc = client.conversation(
|
|
359
|
+
owner.worker,
|
|
360
|
+
max_tool_result_bytes=None if opts.needs_full_results() else 4096,
|
|
361
|
+
_codex_history_db=True)
|
|
362
|
+
store.revalidate()
|
|
363
|
+
except (CcxError, GenerationError, IdentityError, OSError) as exc:
|
|
364
|
+
failed += 1
|
|
365
|
+
print(f"ccx history {target}: cannot read the conversation: {exc}", file=sys.stderr)
|
|
366
|
+
continue
|
|
367
|
+
hint = hist.render_history(hist.project(doc, name=name), opts, sys.stdout,
|
|
368
|
+
many=many, zone=zone) or hint
|
|
369
|
+
sys.stdout.flush()
|
|
370
|
+
if hint is not None:
|
|
371
|
+
print("\n" + hist.hint_line(hint))
|
|
372
|
+
if failed:
|
|
373
|
+
raise SystemExit(EXIT_UNAVAILABLE)
|
|
374
|
+
|
|
375
|
+
|
|
320
376
|
class _ReportFailure(Exception):
|
|
321
377
|
"""One target that cannot be reported: message plus the CLI exit code."""
|
|
322
378
|
|
|
@@ -644,6 +700,26 @@ def _background_summary_line(summary: dict | None) -> str | None:
|
|
|
644
700
|
f"subagent={kinds.get('subagent', 0)}")
|
|
645
701
|
|
|
646
702
|
|
|
703
|
+
def _peek_window_enough(doc, args) -> bool:
|
|
704
|
+
"""A recent window holds what this peek shows, or there is no older history.
|
|
705
|
+
|
|
706
|
+
Tools are counted on their own: the activity view also shows errors, but
|
|
707
|
+
the tool list and --tools-only show only tools, and both use --tail.
|
|
708
|
+
"""
|
|
709
|
+
if not isinstance(doc, dict) or "peekHistoryWindow" not in doc:
|
|
710
|
+
return True
|
|
711
|
+
kinds = [item.get("kind") for turn in doc.get("turns") or []
|
|
712
|
+
for item in [*turn.get("input", []), *turn.get("output", [])]
|
|
713
|
+
if isinstance(item, dict)]
|
|
714
|
+
texts = "user-text" in kinds and "assistant-text" in kinds
|
|
715
|
+
tools = sum(kind in {"tool-call", "tool-result"} for kind in kinds) >= args.tail
|
|
716
|
+
if args.text_only:
|
|
717
|
+
return texts
|
|
718
|
+
if args.tools_only:
|
|
719
|
+
return tools
|
|
720
|
+
return texts and tools
|
|
721
|
+
|
|
722
|
+
|
|
647
723
|
def _peek_conversation(args, store):
|
|
648
724
|
"""Project one original schema-5 snapshot for the CLI, without native writes."""
|
|
649
725
|
from ccx.store.root import GenerationError
|
|
@@ -653,14 +729,22 @@ def _peek_conversation(args, store):
|
|
|
653
729
|
try:
|
|
654
730
|
owner = store.resolve(args.target)
|
|
655
731
|
options = {}
|
|
732
|
+
# Peek shows only recent items, so it reads a recent tail. An explicit
|
|
733
|
+
# --since-turn keeps the complete history path for original turn numbers.
|
|
656
734
|
if owner.engine == "codex" and args.since_turn is None:
|
|
657
|
-
# Only peek needs a recent tail. An explicit --since-turn keeps
|
|
658
|
-
# the complete history path for unambiguous original ordinals.
|
|
659
735
|
options["_recent_codex_turns"] = min(64, max(12, args.tail * 2 + 4))
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
736
|
+
elif args.since_turn is None:
|
|
737
|
+
# Claude prompts can each span many MB of tool work, so start from
|
|
738
|
+
# the last two and widen only until peek has everything it shows.
|
|
739
|
+
options["_recent_claude_turns"] = 2
|
|
740
|
+
while True:
|
|
741
|
+
doc = WorkerClient().conversation(
|
|
742
|
+
owner.worker, max_tool_result_bytes=None if args.full else 4096,
|
|
743
|
+
**options,
|
|
744
|
+
)
|
|
745
|
+
if "_recent_claude_turns" not in options or _peek_window_enough(doc, args):
|
|
746
|
+
break
|
|
747
|
+
options["_recent_claude_turns"] *= 4
|
|
664
748
|
if (not isinstance(doc, dict) or doc.get("schemaVersion") != 5
|
|
665
749
|
or not isinstance(doc.get("worker"), dict)
|
|
666
750
|
or doc["worker"].get("handle") != owner.worker
|
package/ccx/cli/main.py
CHANGED
|
@@ -23,6 +23,7 @@ _USER_WORKER_FIELDS = {
|
|
|
23
23
|
"vars": ("target",),
|
|
24
24
|
"events": ("target",),
|
|
25
25
|
"logs": ("target",),
|
|
26
|
+
"history": ("targets",),
|
|
26
27
|
"wait": ("target",),
|
|
27
28
|
"answer": ("target",),
|
|
28
29
|
"approve": ("target",),
|
|
@@ -195,8 +196,8 @@ _AUTO_INIT_COMMANDS = frozenset({
|
|
|
195
196
|
"new", "import", "fork", "resume", "send", "steer", "reconcile",
|
|
196
197
|
"wait", "notify", "answer", "approve", "reject", "respond",
|
|
197
198
|
"set", "compact", "stop", "kill", "attach", "shutdown", "untrust",
|
|
198
|
-
"vars", "list", "logs", "report", "context", "peek", "events",
|
|
199
|
-
"usage", "controller",
|
|
199
|
+
"vars", "list", "logs", "history", "report", "context", "peek", "events",
|
|
200
|
+
"usage", "models", "controller",
|
|
200
201
|
})
|
|
201
202
|
|
|
202
203
|
|
package/ccx/cli/parser.py
CHANGED
|
@@ -8,9 +8,10 @@ import textwrap
|
|
|
8
8
|
from ccx.cli.commands.control import cmd_attach, cmd_kill, cmd_shutdown, cmd_stop, cmd_untrust
|
|
9
9
|
from ccx.cli.commands.create import cmd_fork, cmd_import, cmd_new, cmd_resume
|
|
10
10
|
from ccx.cli.commands.read import (
|
|
11
|
-
cmd_context, cmd_events, cmd_list, cmd_logs, cmd_peek,
|
|
11
|
+
cmd_context, cmd_events, cmd_history, cmd_list, cmd_logs, cmd_peek,
|
|
12
12
|
cmd_report, cmd_usage,
|
|
13
13
|
)
|
|
14
|
+
from ccx.cli.commands.models import cmd_models
|
|
14
15
|
from ccx.cli.commands.turns import (
|
|
15
16
|
cmd_answer, cmd_approve, cmd_compact, cmd_notify, cmd_reconcile, cmd_reject,
|
|
16
17
|
cmd_respond, cmd_send, cmd_set, cmd_steer, cmd_vars, cmd_wait,
|
|
@@ -140,13 +141,71 @@ From Codex, run ccx outside the sandbox (or with full access): it starts
|
|
|
140
141
|
processes and writes outside the workspace. Codex must be on PATH or
|
|
141
142
|
CCX_CODEX_BIN must point to its executable."""
|
|
142
143
|
|
|
144
|
+
HISTORY_DESCRIPTION = """\
|
|
145
|
+
Read past conversations of the workers you name, or search them. Nothing is
|
|
146
|
+
sent; it works on idle, busy and stopped workers. Reasoning is never shown.
|
|
147
|
+
|
|
148
|
+
Each message is labelled: U a user message, U+ a user message sent while the
|
|
149
|
+
turn ran (a steer), N[kind] a notification or event (bg, monitor, agent, cron,
|
|
150
|
+
system, ccx, ...), / a slash command, ! an interrupt, A assistant text, A* the
|
|
151
|
+
turn's final answer, A… the latest text of a turn still running, T a tool call.
|
|
152
|
+
|
|
153
|
+
Every message has an address #TURN.N, the same numbers as `ccx logs` and
|
|
154
|
+
`ccx peek --since-turn`; --turns TURN.N prints that message whole. A compaction
|
|
155
|
+
is one line (══ #TURN.N compaction); its address prints the summary when one is
|
|
156
|
+
stored (Codex encrypts its summaries).
|
|
157
|
+
|
|
158
|
+
What you get:
|
|
159
|
+
no options outline of the last 30 turns (10 per worker if several)
|
|
160
|
+
--since/--until outline of the turns in that time range
|
|
161
|
+
--turns SPEC those turns as chat (--turns 1- for all)
|
|
162
|
+
--role ROLES recent turns as chat, showing only those roles
|
|
163
|
+
--grep/--cmd/--errors one line per matching message, newest 20 per worker;
|
|
164
|
+
add -C N (or --view) to read the matching turns instead
|
|
165
|
+
|
|
166
|
+
Views (--view):
|
|
167
|
+
outline one block per turn: prompt, start of the final answer, tool summary
|
|
168
|
+
chat prompts and final answers, each cut to --max-chars
|
|
169
|
+
trace prompts, all assistant text, and one line per tool call
|
|
170
|
+
map the whole history in segments split at compactions and idle gaps
|
|
171
|
+
over 6 hours, one title each"""
|
|
172
|
+
|
|
173
|
+
HISTORY_EPILOG = """\
|
|
174
|
+
Roles (--role picks what is searched, and what chat and trace show; search
|
|
175
|
+
default: user,assistant,notify,tool):
|
|
176
|
+
user user messages, steers, slash commands, interrupts
|
|
177
|
+
notify notifications and events
|
|
178
|
+
assistant all assistant text; final: final answers only (A*, A…)
|
|
179
|
+
tool tool calls, shown as commands; output: tool results
|
|
180
|
+
compact compaction summaries (search only)
|
|
181
|
+
Tool output is searched and shown only when output is in --role.
|
|
182
|
+
|
|
183
|
+
--grep is a regular expression; all lowercase means case-insensitive. --cmd
|
|
184
|
+
matches whole command names, in any case: `cd x && timeout 30 .venv/bin/python
|
|
185
|
+
-m pytest | tail` runs cd, python, pytest and tail. --since/--until take
|
|
186
|
+
2026-11-02, '11-02 14:00', 14:00 (today) or 90m/6h/2d (ago), in local time; a
|
|
187
|
+
bare --until date includes that day.
|
|
188
|
+
|
|
189
|
+
Examples:
|
|
190
|
+
ccx history W recent turns
|
|
191
|
+
ccx history W --view map a long history at a glance
|
|
192
|
+
ccx history A B C --grep 'apple silicon' --role user
|
|
193
|
+
ccx history W --turns 184.0 one message, whole
|
|
194
|
+
ccx history W --turns 180-186 the exchange around it
|
|
195
|
+
ccx history A B C --turns -1 each worker's last turn
|
|
196
|
+
ccx history W --grep 'deploy|rollback' -C 1 the turns about a topic
|
|
197
|
+
ccx history W --role notify recent notifications
|
|
198
|
+
ccx history W --cmd pytest --role tool,output test runs and how each ended
|
|
199
|
+
ccx history A B --since '11-07 21:00' --until '11-07 22:00'"""
|
|
200
|
+
|
|
201
|
+
|
|
143
202
|
COMMAND_GROUPS = (
|
|
144
203
|
("Create", ("new", "fork", "resume", "import")),
|
|
145
204
|
("Turns", ("send", "steer", "stop", "wait", "notify", "reconcile")),
|
|
146
205
|
("Input requests", ("answer", "approve", "reject", "respond")),
|
|
147
|
-
("Inspect", ("list", "peek", "report", "logs", "context", "events", "vars")),
|
|
206
|
+
("Inspect", ("list", "peek", "report", "history", "logs", "context", "events", "vars")),
|
|
148
207
|
("Manage", ("attach", "compact", "set", "kill", "shutdown")),
|
|
149
|
-
("Account", ("usage",)),
|
|
208
|
+
("Account", ("usage", "models")),
|
|
150
209
|
("Setup and automation", ("init", "skills", "untrust", "controller", "rpc")),
|
|
151
210
|
)
|
|
152
211
|
|
|
@@ -872,7 +931,8 @@ def _add_inventory_parsers(sub) -> None:
|
|
|
872
931
|
description=(
|
|
873
932
|
"Needs a terminal. A stopped worker is revived; CCX commands remain "
|
|
874
933
|
"available while attached. For both engines, Ctrl-Z detaches and "
|
|
875
|
-
"leaves the worker running.
|
|
934
|
+
"leaves the worker running. Several terminals can attach at once; "
|
|
935
|
+
"a Claude worker then uses the smallest one's size. The CCX status "
|
|
876
936
|
"bar is shown unless --no-status. Codex runs its real native UI; "
|
|
877
937
|
"/quit also exits. Codex may first show its own startup prompts. "
|
|
878
938
|
"Model/permission changes made there do not "
|
|
@@ -923,10 +983,11 @@ def _add_inventory_parsers(sub) -> None:
|
|
|
923
983
|
),
|
|
924
984
|
)
|
|
925
985
|
pg.add_argument("target", help=_WORKER_QUERY_HELP)
|
|
926
|
-
pg.add_argument("--tail", type=int, default=None, help="Only the last N turns. Only those turns are read, so
|
|
986
|
+
pg.add_argument("--tail", type=int, default=None, help="Only the last N turns. Only those turns are read, so they are unnumbered unless they are the whole conversation.")
|
|
927
987
|
pg.add_argument("--format", choices=("conversation", "rendered"), default="conversation",
|
|
928
|
-
help="conversation (default) or rendered (Claude only:
|
|
929
|
-
"transcript with tool calls paired to their results)."
|
|
988
|
+
help="conversation (default) or rendered (deprecated; Claude only: "
|
|
989
|
+
"per-turn transcript with tool calls paired to their results). "
|
|
990
|
+
"To read a conversation as text, use `ccx history`.")
|
|
930
991
|
pg.add_argument("--max-tool-result-bytes", dest="max_tool_result_bytes", type=int,
|
|
931
992
|
default=4096,
|
|
932
993
|
help="Cut each tool result to N bytes (default 4096).")
|
|
@@ -936,6 +997,42 @@ def _add_inventory_parsers(sub) -> None:
|
|
|
936
997
|
help="Claude only: print Claude's raw JSONL transcript.")
|
|
937
998
|
pg.set_defaults(func=cmd_logs)
|
|
938
999
|
|
|
1000
|
+
ph = sub.add_parser(
|
|
1001
|
+
"history",
|
|
1002
|
+
help="Read or search workers' past conversations as text.",
|
|
1003
|
+
description=HISTORY_DESCRIPTION,
|
|
1004
|
+
epilog=HISTORY_EPILOG,
|
|
1005
|
+
formatter_class=_HelpFormatter,
|
|
1006
|
+
)
|
|
1007
|
+
ph.add_argument("targets", nargs="+", metavar="target",
|
|
1008
|
+
help="One or more workers (find them with `ccx list`).")
|
|
1009
|
+
ph.add_argument("--turns", metavar="SPEC",
|
|
1010
|
+
help="12, 10-14, 120-, -5 (the last five) or 184.0 (one message); "
|
|
1011
|
+
"comma-separated.")
|
|
1012
|
+
ph.add_argument("--grep", metavar="REGEX",
|
|
1013
|
+
help="Messages matching REGEX in the searched roles.")
|
|
1014
|
+
ph.add_argument("--cmd", dest="command_regex", metavar="REGEX",
|
|
1015
|
+
help="Tool calls running a command whose name matches REGEX.")
|
|
1016
|
+
ph.add_argument("--errors", action="store_true", help="Failed tool calls only.")
|
|
1017
|
+
ph.add_argument("--role", metavar="ROLES",
|
|
1018
|
+
help="Comma-separated roles to search and show; see below.")
|
|
1019
|
+
ph.add_argument("--since", metavar="TIME", help="From this time; see below.")
|
|
1020
|
+
ph.add_argument("--until", metavar="TIME",
|
|
1021
|
+
help="Before this time; a date includes that whole day.")
|
|
1022
|
+
ph.add_argument("-C", "--context", type=int, metavar="N",
|
|
1023
|
+
help="Read the matching turns instead of hit lines, with N turns around each.")
|
|
1024
|
+
ph.add_argument("--view", choices=("outline", "chat", "trace", "map"),
|
|
1025
|
+
help="How to show turns; see above.")
|
|
1026
|
+
ph.add_argument("--limit", type=int, default=20, metavar="N",
|
|
1027
|
+
help="Hit lines per worker, newest (default 20).")
|
|
1028
|
+
ph.add_argument("--max-chars", type=int, metavar="N",
|
|
1029
|
+
help="Cut each message to N characters (default 3000; "
|
|
1030
|
+
"one addressed message prints whole).")
|
|
1031
|
+
ph.add_argument("--full", action="store_true",
|
|
1032
|
+
help="Don't cut messages, commands or tool output "
|
|
1033
|
+
"(hit lines stay short).")
|
|
1034
|
+
ph.set_defaults(func=cmd_history)
|
|
1035
|
+
|
|
939
1036
|
prep = sub.add_parser(
|
|
940
1037
|
"report",
|
|
941
1038
|
help="Print the last reply of one or more workers.",
|
|
@@ -1133,6 +1230,24 @@ def _add_control_parsers(sub) -> None:
|
|
|
1133
1230
|
|
|
1134
1231
|
def _add_observability_parsers(sub) -> None:
|
|
1135
1232
|
"""Quota and session observation: usage, peek, events."""
|
|
1233
|
+
pm = sub.add_parser(
|
|
1234
|
+
"models",
|
|
1235
|
+
help="List the models Claude and Codex offer.",
|
|
1236
|
+
description=(
|
|
1237
|
+
"Show the models each engine offers: the id to pass to --model, the "
|
|
1238
|
+
"display name, the model a Claude alias points to now, and the efforts "
|
|
1239
|
+
"it supports. Lists are kept for 24 hours; Claude's is fetched again "
|
|
1240
|
+
"after a Claude Code update. If a list can't be fetched, the last one "
|
|
1241
|
+
"is shown, marked stale."
|
|
1242
|
+
),
|
|
1243
|
+
)
|
|
1244
|
+
pm.add_argument("--engine", choices=("claude", "codex"),
|
|
1245
|
+
help="Only this engine (default: both).")
|
|
1246
|
+
pm.add_argument("--json", action="store_true", help="Print the lists as JSON.")
|
|
1247
|
+
pm.add_argument("--refresh", action="store_true",
|
|
1248
|
+
help="Fetch the lists now; exit 3 if one can't be fetched.")
|
|
1249
|
+
pm.set_defaults(func=cmd_models)
|
|
1250
|
+
|
|
1136
1251
|
pu = sub.add_parser(
|
|
1137
1252
|
"usage",
|
|
1138
1253
|
help="Show your Claude or Codex account usage.",
|
package/ccx/client.py
CHANGED
|
@@ -82,15 +82,14 @@ from ccx.ops.semantics import (
|
|
|
82
82
|
question_answer_map,
|
|
83
83
|
question_answer_message,
|
|
84
84
|
)
|
|
85
|
-
from ccx.claude.transcript import
|
|
86
|
-
render_transcript_json,
|
|
87
|
-
)
|
|
85
|
+
from ccx.claude.transcript import render_transcript
|
|
88
86
|
from .types import (
|
|
89
87
|
ClaudeContext,
|
|
90
88
|
ClaudeTask,
|
|
91
89
|
CompactOperationResult,
|
|
92
90
|
Event,
|
|
93
91
|
FiveHourReset,
|
|
92
|
+
ModelCatalog,
|
|
94
93
|
Report,
|
|
95
94
|
PendingInput,
|
|
96
95
|
SemanticDeliveryResult,
|
|
@@ -1659,7 +1658,7 @@ class CcxClient:
|
|
|
1659
1658
|
with source[0].open_reader() as stream:
|
|
1660
1659
|
result = stream.read().decode("utf-8", "replace")
|
|
1661
1660
|
else:
|
|
1662
|
-
result =
|
|
1661
|
+
result = render_transcript(owner.worker, store=self._uuid_store)
|
|
1663
1662
|
if result is None:
|
|
1664
1663
|
raise SessionNotFound(
|
|
1665
1664
|
f"transcript not found for {owner.worker}", short=owner.short,
|
|
@@ -1849,6 +1848,11 @@ class CcxClient:
|
|
|
1849
1848
|
f"subscribe: original source unavailable: {exc}"
|
|
1850
1849
|
) from exc
|
|
1851
1850
|
|
|
1851
|
+
def models(self, *, refresh: bool = False) -> ModelCatalog:
|
|
1852
|
+
"""The models Claude Code offers, as ``ccx models --engine claude``."""
|
|
1853
|
+
from ccx.models import read_models
|
|
1854
|
+
return read_models(engine="claude", refresh=refresh)[0]
|
|
1855
|
+
|
|
1852
1856
|
def usage(self, *, cached: bool = False) -> Usage:
|
|
1853
1857
|
"""Live quota utilization (the signal usage caps enforce). With
|
|
1854
1858
|
``cached=True`` serve a recent cached value instead of forcing a fresh
|
package/ccx/codex/client.py
CHANGED
|
@@ -44,6 +44,7 @@ from ccx.constants import (
|
|
|
44
44
|
COMPACT_WAIT_DEFAULT_S,
|
|
45
45
|
DEFAULT_IDLE_TIMEOUT_S,
|
|
46
46
|
)
|
|
47
|
+
from ccx.types import ModelCatalog
|
|
47
48
|
from ccx.errors import (
|
|
48
49
|
DeliveryRejected,
|
|
49
50
|
InvalidRequest,
|
|
@@ -713,6 +714,11 @@ class CodexClient:
|
|
|
713
714
|
_worker, rec = self._owned_codex(target)
|
|
714
715
|
return CodexContext.from_record(rec)
|
|
715
716
|
|
|
717
|
+
def models(self, *, refresh: bool = False) -> ModelCatalog:
|
|
718
|
+
"""The models Codex offers, as ``ccx models --engine codex``."""
|
|
719
|
+
from ccx.models import read_models
|
|
720
|
+
return read_models(engine="codex", refresh=refresh)[0]
|
|
721
|
+
|
|
716
722
|
def usage(
|
|
717
723
|
self,
|
|
718
724
|
*,
|
|
@@ -780,7 +780,9 @@ class CodexDaemon:
|
|
|
780
780
|
op = message.get("op")
|
|
781
781
|
if op == "daemon-status":
|
|
782
782
|
with self.lock:
|
|
783
|
-
|
|
783
|
+
# A presence probe needs no worker states, and each takes
|
|
784
|
+
# that worker's lock, which a slow disk write can hold.
|
|
785
|
+
workers = None if message.get("workers") is False else {
|
|
784
786
|
short: worker.state()
|
|
785
787
|
for short, worker in self.workers.items()
|
|
786
788
|
}
|