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.
@@ -1,4 +1,4 @@
1
- """Promptless Claude Code usage control protocol; no credential or transcript reads."""
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 request_usage(timeout: float = 8.0) -> dict:
26
- """Return {kind, body?, raw?, error?} from one bounded get_usage exchange.
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
- The native CLI owns auth. The only input consists of initialize/get_usage
29
- control requests; no model prompt, hooks, MCP server or transcript scan.
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
- start = time.monotonic()
33
- deadline = start + timeout
34
- init_id, usage_id = uuid.uuid4().hex, uuid.uuid4().hex
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
- reply = None
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 reply is None and problem is None:
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
- if response.get("request_id") == init_id and response.get("subtype") == "error":
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 response.get("request_id") == usage_id:
92
- reply = message
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 reply is None:
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
- response = reply["response"]
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 outcome unknown until Claude settles"
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)
@@ -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
- doc = WorkerClient().conversation(
661
- owner.worker, max_tool_result_bytes=None if args.full else 4096,
662
- **options,
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. The CCX status "
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 Claude numbers them from 1 and Codex leaves them unnumbered.")
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: per-turn "
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 = render_transcript_json(owner.worker, store=self._uuid_store)
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
@@ -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
- workers = {
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
  }