ccx-native 0.0.0-stage → 0.1.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.
Files changed (150) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +241 -2
  3. package/bin/ccx +56 -0
  4. package/ccx/__init__.py +163 -0
  5. package/ccx/__main__.py +28 -0
  6. package/ccx/claude/__init__.py +0 -0
  7. package/ccx/claude/analytics.py +466 -0
  8. package/ccx/claude/attach.py +762 -0
  9. package/ccx/claude/context.py +465 -0
  10. package/ccx/claude/delivery.py +806 -0
  11. package/ccx/claude/external.py +253 -0
  12. package/ccx/claude/host/__init__.py +0 -0
  13. package/ccx/claude/host/events.py +297 -0
  14. package/ccx/claude/host/hooks.py +438 -0
  15. package/ccx/claude/host/pty.py +250 -0
  16. package/ccx/claude/host/session.py +5184 -0
  17. package/ccx/claude/host/subagents.py +169 -0
  18. package/ccx/claude/host/successor.py +267 -0
  19. package/ccx/claude/managed_turns.py +240 -0
  20. package/ccx/claude/oauth.py +176 -0
  21. package/ccx/claude/settings.py +339 -0
  22. package/ccx/claude/spawn.py +557 -0
  23. package/ccx/claude/tasks.py +72 -0
  24. package/ccx/claude/telemetry.py +282 -0
  25. package/ccx/claude/transcript.py +2281 -0
  26. package/ccx/claude/usage.py +837 -0
  27. package/ccx/claude/usage_control.py +152 -0
  28. package/ccx/cli/__init__.py +0 -0
  29. package/ccx/cli/codex.py +1009 -0
  30. package/ccx/cli/commands/__init__.py +0 -0
  31. package/ccx/cli/commands/_common.py +407 -0
  32. package/ccx/cli/commands/control.py +161 -0
  33. package/ccx/cli/commands/create.py +669 -0
  34. package/ccx/cli/commands/read.py +1020 -0
  35. package/ccx/cli/commands/turns.py +1081 -0
  36. package/ccx/cli/controller.py +757 -0
  37. package/ccx/cli/main.py +301 -0
  38. package/ccx/cli/parser.py +1324 -0
  39. package/ccx/cli/skills.py +215 -0
  40. package/ccx/cli/statusline.py +160 -0
  41. package/ccx/client.py +1900 -0
  42. package/ccx/codex/__init__.py +0 -0
  43. package/ccx/codex/attach_tty.py +267 -0
  44. package/ccx/codex/client.py +732 -0
  45. package/ccx/codex/completion.py +121 -0
  46. package/ccx/codex/control.py +288 -0
  47. package/ccx/codex/daemon/__init__.py +0 -0
  48. package/ccx/codex/daemon/__main__.py +4 -0
  49. package/ccx/codex/daemon/daemon.py +1556 -0
  50. package/ccx/codex/daemon/models.py +140 -0
  51. package/ccx/codex/daemon/native.py +1590 -0
  52. package/ccx/codex/daemon/release.py +367 -0
  53. package/ccx/codex/daemon/worker.py +2396 -0
  54. package/ccx/codex/delivery.py +758 -0
  55. package/ccx/codex/delivery_recovery.py +232 -0
  56. package/ccx/codex/evidence.py +827 -0
  57. package/ccx/codex/history.py +158 -0
  58. package/ccx/codex/notify_history.py +140 -0
  59. package/ccx/codex/ops.py +666 -0
  60. package/ccx/codex/paths.py +70 -0
  61. package/ccx/codex/protocol.py +805 -0
  62. package/ccx/codex/provenance.py +105 -0
  63. package/ccx/codex/read.py +29 -0
  64. package/ccx/codex/records.py +51 -0
  65. package/ccx/codex/reports.py +177 -0
  66. package/ccx/codex/runtime.py +1412 -0
  67. package/ccx/codex/settle.py +363 -0
  68. package/ccx/codex/telemetry.py +792 -0
  69. package/ccx/codex/types.py +659 -0
  70. package/ccx/codex/worker_environment.py +25 -0
  71. package/ccx/constants.py +288 -0
  72. package/ccx/controllers/__init__.py +0 -0
  73. package/ccx/controllers/controller.py +1449 -0
  74. package/ccx/controllers/live_vars.py +1706 -0
  75. package/ccx/controllers/live_vars_runner.py +216 -0
  76. package/ccx/controllers/notify.py +213 -0
  77. package/ccx/controllers/orchestrator.py +193 -0
  78. package/ccx/controllers/policy.py +57 -0
  79. package/ccx/controllers/store.py +681 -0
  80. package/ccx/errors.py +634 -0
  81. package/ccx/notify/__init__.py +0 -0
  82. package/ccx/notify/__main__.py +5 -0
  83. package/ccx/notify/client.py +116 -0
  84. package/ccx/notify/daemon.py +480 -0
  85. package/ccx/notify/deliver.py +304 -0
  86. package/ccx/notify/paths.py +51 -0
  87. package/ccx/notify/registry.py +649 -0
  88. package/ccx/notify/select.py +750 -0
  89. package/ccx/notify/settle.py +684 -0
  90. package/ccx/ops/__init__.py +0 -0
  91. package/ccx/ops/background.py +115 -0
  92. package/ccx/ops/cancel.py +289 -0
  93. package/ccx/ops/codex_import.py +96 -0
  94. package/ccx/ops/common.py +397 -0
  95. package/ccx/ops/compact.py +337 -0
  96. package/ccx/ops/compact_ops.py +550 -0
  97. package/ccx/ops/context_events.py +48 -0
  98. package/ccx/ops/conversation.py +1540 -0
  99. package/ccx/ops/conversation_paging.py +1338 -0
  100. package/ccx/ops/create.py +56 -0
  101. package/ccx/ops/creation.py +952 -0
  102. package/ccx/ops/creation_steps.py +579 -0
  103. package/ccx/ops/event_feed.py +710 -0
  104. package/ccx/ops/forking.py +711 -0
  105. package/ccx/ops/input.py +556 -0
  106. package/ccx/ops/maintenance.py +295 -0
  107. package/ccx/ops/report.py +770 -0
  108. package/ccx/ops/semantics.py +490 -0
  109. package/ccx/ops/send.py +961 -0
  110. package/ccx/ops/steer_ops.py +867 -0
  111. package/ccx/ops/terminate.py +383 -0
  112. package/ccx/ops/turn_reports.py +306 -0
  113. package/ccx/ops/wait.py +762 -0
  114. package/ccx/ops/worker_events.py +636 -0
  115. package/ccx/rpc/__init__.py +0 -0
  116. package/ccx/rpc/local_rpc.py +1602 -0
  117. package/ccx/rpc/rpc.py +559 -0
  118. package/ccx/rpc/serve.py +374 -0
  119. package/ccx/rpc/transport.py +409 -0
  120. package/ccx/rpc/view.py +125 -0
  121. package/ccx/store/__init__.py +0 -0
  122. package/ccx/store/discovery.py +170 -0
  123. package/ccx/store/identity.py +105 -0
  124. package/ccx/store/journal_io.py +79 -0
  125. package/ccx/store/liveness.py +194 -0
  126. package/ccx/store/paths.py +254 -0
  127. package/ccx/store/record_cache.py +40 -0
  128. package/ccx/store/records.py +309 -0
  129. package/ccx/store/root.py +804 -0
  130. package/ccx/store/runtime_presence.py +137 -0
  131. package/ccx/store/store.py +2332 -0
  132. package/ccx/store/worker_state.py +86 -0
  133. package/ccx/system/__init__.py +0 -0
  134. package/ccx/system/bounded.py +296 -0
  135. package/ccx/system/cwd_paths.py +25 -0
  136. package/ccx/system/engine_binary.py +59 -0
  137. package/ccx/system/executable.py +50 -0
  138. package/ccx/system/oslayer.py +667 -0
  139. package/ccx/system/pipes.py +46 -0
  140. package/ccx/system/sanitize.py +43 -0
  141. package/ccx/types.py +705 -0
  142. package/ccx/version.py +29 -0
  143. package/ccx/workers.py +2081 -0
  144. package/ccx.py +9 -0
  145. package/package.json +40 -4
  146. package/pyproject.toml +87 -0
  147. package/skills/ccx/SKILL.md +155 -0
  148. package/skills/ccx/references/controllers.md +244 -0
  149. package/skills/ccx/references/troubleshooting.md +94 -0
  150. package/skills/ccx/references/variables.md +62 -0
@@ -0,0 +1,1020 @@
1
+ from __future__ import annotations
2
+ import json
3
+ import os
4
+ import sys
5
+ import time
6
+ from ccx.codex import read as codex_read
7
+ from ccx.ops import event_feed
8
+ from ccx import constants
9
+ from ccx.constants import (
10
+ BACKGROUND_TASK_SURFACE_LIMIT,
11
+ EXIT_ERROR,
12
+ EXIT_NOT_FOUND,
13
+ EXIT_UNAVAILABLE,
14
+ S,
15
+ )
16
+ from ccx.store.liveness import recorded_host_loaded
17
+ from ccx.errors import (
18
+ CcxError,
19
+ EngineCliNotFound,
20
+ InvalidRequest,
21
+ SessionNotFound,
22
+ SessionUnavailable,
23
+ )
24
+ from ccx.rpc import rpc as host_rpc
25
+ from ccx.ops.semantics import (
26
+ payload_guidance as _payload_guidance, # noqa: F401
27
+ pending_input_kind,
28
+ )
29
+ from ccx.claude.context import public_context
30
+ from ccx.claude.transcript import (
31
+ background_task_current,
32
+ native_transcript_start,
33
+ background_task_summary,
34
+ extract_background_tasks,
35
+ )
36
+ from ccx.claude.usage import (
37
+ describe_usage_age,
38
+ fetch_usage_for_display,
39
+ set_usage_source,
40
+ usage_summary,
41
+ )
42
+ from ccx.ops.forking import public_lineage
43
+ from ccx.cli.commands._common import _truncate
44
+
45
+
46
+ def _background_task_facts(rec: dict | None) -> dict:
47
+ """Transcript-derived background-task facts for a session: the list plus
48
+ raw/open and host-aware liveness counts.
49
+
50
+ A bounded, read-only scan of the raw transcript — never blocks and never
51
+ changes lifecycle state. An *open* background task means a continuation may
52
+ still fire, NOT that the worker is unfinished. `running_background_task_count`
53
+ remains the transcript-open/back-compatible count; `active_*` / `stale_*`
54
+ decorate that with current host liveness. `background_tasks` is capped to the
55
+ most recent BACKGROUND_TASK_SURFACE_LIMIT entries so old completed tasks do
56
+ not bloat every status response; the counts are over the full transcript."""
57
+ src = (rec or {}).get("transcript")
58
+ tasks: list = []
59
+ if src:
60
+ try:
61
+ from ccx.ops.forking import inherited_task_filter
62
+ tasks = inherited_task_filter(rec, extract_background_tasks(src))
63
+ except Exception:
64
+ tasks = []
65
+ summary = background_task_summary(tasks)
66
+ host_live = _background_task_host_live(rec)
67
+ lifecycle = (rec or {}).get("lifecycle") or {}
68
+ native_start = native_transcript_start(
69
+ lifecycle.get("nativeTranscriptStart"), src
70
+ ) if src else None
71
+ decorated = [
72
+ _decorate_background_task_liveness(t, host_live, native_start)
73
+ for t in tasks
74
+ ]
75
+ shown = decorated[-BACKGROUND_TASK_SURFACE_LIMIT:]
76
+ open_count = summary["running_background_task_count"]
77
+ active_count = sum(1 for t in decorated if t.get("active"))
78
+ stale_count = sum(1 for t in decorated if t.get("liveness") == "stale")
79
+ facts = {
80
+ "background_tasks": shown,
81
+ **summary,
82
+ "open_background_task_count": open_count,
83
+ "active_background_task_count": active_count,
84
+ "stale_background_task_count": stale_count,
85
+ "background_tasks_truncated": len(decorated) > len(shown),
86
+ "background_task_limit": BACKGROUND_TASK_SURFACE_LIMIT,
87
+ }
88
+ # Advisory hint (LLM-readable): an active background task can wake the worker
89
+ # for a fresh turn on event/completion, but that turn may just acknowledge
90
+ # and go idle again — `ccx wait --next-turn` blocks for that worker's next
91
+ # actionable boundary (a settled turn or a new input gate).
92
+ if active_count > 0:
93
+ worker = (rec or {}).get("workerId") or "<worker>"
94
+ active_monitors = [
95
+ t for t in decorated
96
+ if t.get("active") and t.get("kind") == "monitor"
97
+ ]
98
+ if active_monitors:
99
+ facts["background_task_hint"] = (
100
+ f"{active_count} background task(s) active; Monitor tasks can stay "
101
+ f"open indefinitely and wake the worker on events. Use `ccx wait "
102
+ f"{worker} --next-turn` only when you want to block until the worker "
103
+ f"next settles a turn or stops on a new input gate."
104
+ )
105
+ else:
106
+ facts["background_task_hint"] = (
107
+ f"{active_count} background task(s) active; each completion wakes "
108
+ f"the worker for a new turn. Use `ccx wait {worker} --next-turn` to "
109
+ f"block until the worker next settles a turn or stops on a new "
110
+ f"input gate."
111
+ )
112
+ elif stale_count > 0:
113
+ if host_live is True:
114
+ facts["background_task_hint"] = (
115
+ f"{stale_count} transcript-open background task(s) are from an "
116
+ "earlier Claude process, not tracked by the current one; their "
117
+ "outcome is unknown, not proven completed."
118
+ )
119
+ else:
120
+ facts["background_task_hint"] = (
121
+ f"{stale_count} transcript-open background task(s) are stale because "
122
+ "the Claude Code host is not live; resume the session to let Claude "
123
+ "reconcile them."
124
+ )
125
+ return facts
126
+
127
+
128
+ def _background_task_host_live(rec: dict | None) -> bool | None:
129
+ """Best-effort host liveness for task decoration.
130
+
131
+ True means a transcript-open task is plausibly live. False means it is stale.
132
+ None means we cannot prove either way, so callers should not present it as
133
+ active.
134
+ """
135
+ if not rec:
136
+ return None
137
+ lc = rec.get("lifecycle") or {}
138
+ state = lc.get("state")
139
+ if state in (S.DEAD, S.IDLE_KILLED, S.CRASHED, S.BUDGET_STOPPED):
140
+ return False
141
+ worker_id = rec.get("workerId")
142
+ if worker_id:
143
+ return host_rpc.host_socket_live(worker_id, rec)
144
+ if lc.get("hostPid") or lc.get("workerPid"):
145
+ return True
146
+ if state in (S.BOOTING, S.IDLE, S.BUSY, S.WAITING, S.RESUMING):
147
+ return None
148
+ return None
149
+
150
+
151
+ def _decorate_background_task_liveness(
152
+ task: dict, host_live: bool | None, native_start: int | None
153
+ ) -> dict:
154
+ t = dict(task)
155
+ t.pop("last_open_offset", None)
156
+ if t.get("status") != "running":
157
+ t["liveness"] = "settled"
158
+ t["active"] = False
159
+ elif host_live is True:
160
+ current = background_task_current(task, native_start)
161
+ t["liveness"] = "live" if current else "stale"
162
+ t["active"] = current
163
+ elif host_live is False:
164
+ t["liveness"] = "stale"
165
+ t["active"] = False
166
+ else:
167
+ t["liveness"] = "unknown"
168
+ t["active"] = False
169
+ return t
170
+
171
+
172
+ def _status_context_for_record(worker: str, rec: dict) -> dict:
173
+ """One provenance-checked context reader across status and context APIs."""
174
+ from ccx import constants
175
+ from ccx.claude.context import current_context, read_claude_observation
176
+ from ccx.store.root import require_ccx_active
177
+
178
+ engine = worker.split(":", 1)[0]
179
+ if engine == "claude":
180
+ store = require_ccx_active(constants.CCX_ROOT)
181
+ owner = store.resolve(worker)
182
+ observed = read_claude_observation(store, owner, rec)
183
+ block = observed.get("context") if observed else None
184
+ else:
185
+ block = current_context(rec, engine="codex", live=rec.get("context"))
186
+ result = public_context(block, engine=engine)
187
+ if engine == "claude" and observed and observed.get("note"):
188
+ result["note"] = observed["note"]
189
+ return result
190
+
191
+
192
+ def _claude_recorded_delivery_busy(worker: str, record: dict) -> bool:
193
+ """An owned new turn outranks an idle record until its own settlement.
194
+
195
+ The receipt is durable and read-only. Never infer pending work from an
196
+ unowned/prepared intent or a previous turn's already-past settlement.
197
+ """
198
+ from ccx.claude.delivery import iter_receipts
199
+ from ccx.store.worker_state import claude_recorded_delivery_busy
200
+ return claude_recorded_delivery_busy(record, iter_receipts(worker))
201
+
202
+
203
+ def cmd_list(args):
204
+ from ccx import constants
205
+ from ccx.system.cwd_paths import canonical_cwd
206
+ from ccx.store.root import require_ccx_active
207
+ store = require_ccx_active(constants.CCX_ROOT)
208
+ """Public ccx list reads disk-backed full-ID owners; no provider discovery."""
209
+ from ccx.store.root import GenerationError
210
+ cwd = getattr(args, "cwd", None)
211
+ prefix = getattr(args, "cwd_prefix", None)
212
+ if cwd is not None and prefix is not None:
213
+ print("ccx list: --cwd and --cwd-prefix are mutually exclusive", file=sys.stderr)
214
+ raise SystemExit(1)
215
+ target_cwd = canonical_cwd(cwd) if cwd else None
216
+ prefix = canonical_cwd(prefix) if prefix else None
217
+ hours = getattr(args, "recent_hours", None)
218
+ if hours is not None and (
219
+ not isinstance(hours, (float, int)) or not 0 < hours <= 1_000_000
220
+ ):
221
+ print("ccx list: --recent-hours must be positive", file=sys.stderr)
222
+ raise SystemExit(1)
223
+ limit = getattr(args, "limit", None)
224
+ if limit is not None and (type(limit) is not int or limit < 0):
225
+ print("ccx list: --limit must be nonnegative", file=sys.stderr)
226
+ raise SystemExit(1)
227
+ try:
228
+ rows = store.list_workers(
229
+ engine=getattr(args, "engine", None),
230
+ cwd=target_cwd, state=getattr(args, "state", None),
231
+ )
232
+ except GenerationError as exc:
233
+ print(f"ccx list unavailable: {exc}", file=sys.stderr)
234
+ raise SystemExit(3) from None
235
+ if prefix is not None:
236
+ rows = [
237
+ row for row in rows
238
+ if isinstance(row.get("cwd"), str)
239
+ and (row["cwd"] == prefix or row["cwd"].startswith(prefix.rstrip(os.sep) + os.sep))
240
+ ]
241
+ if hours is not None:
242
+ import time
243
+ floor = int((time.time() - hours * 3600) * 1000)
244
+ rows = [
245
+ row for row in rows
246
+ if max(row.get("last_turn_at") or 0, row.get("created_at") or 0) >= floor
247
+ ]
248
+ # Advisory recorded-only predicates, never a probe of native liveness.
249
+ if args.active:
250
+ rows = [row for row in rows if not row.get("terminal_only")
251
+ and row.get("state") in {"starting", "idle", "busy", "waiting"}]
252
+ if args.sendable:
253
+ rows = [row for row in rows if not row.get("terminal_only")
254
+ and row.get("state") in {"idle", "stopped"}]
255
+ if limit is not None:
256
+ rows = rows[:limit]
257
+ if args.json:
258
+ print(json.dumps(rows, sort_keys=True, indent=2, ensure_ascii=False))
259
+ return
260
+ if not rows:
261
+ print("no ccx workers")
262
+ return
263
+ print("WORKER SHORT ENGINE STATE NAME")
264
+ for row in rows:
265
+ title = " ".join(str(row.get("name") or "").split())[:80]
266
+ state = str(row.get("state") or "?")[:13]
267
+ print(
268
+ f"{row['worker']:<45} {row['short']:<9} "
269
+ f"{row['engine']:<6} {state:<13} {title}"
270
+ + (" [short ambiguous]" if row["ambiguous_short"] else "")
271
+ )
272
+
273
+
274
+ def cmd_logs(args):
275
+ """Print one original-owned full-UUID worker's native conversation.
276
+
277
+ CLI prefix discovery already completed before this handler. Normalized
278
+ output is shared across engines; legacy cleaned and raw JSONL are Claude
279
+ only. Nothing here reopens untrusted record paths or resumes a model turn.
280
+ """
281
+ from ccx.workers import WorkerClient
282
+
283
+ try:
284
+ client = WorkerClient()
285
+ owner = client._uuid_owner(args.target)
286
+ if owner.engine == "codex" and (args.jsonl or args.format == "rendered"):
287
+ sys.stderr.write(
288
+ "[ccx] --jsonl and --format rendered are Claude-only; Codex history "
289
+ "is served only as the normalized conversation (the default)\n"
290
+ )
291
+ raise SystemExit(EXIT_ERROR)
292
+ if args.jsonl:
293
+ sys.stdout.write(client._claude.logs(owner.worker, jsonl=True))
294
+ return
295
+ if args.format == "rendered":
296
+ doc = client._claude.logs(owner.worker, tail=args.tail)
297
+ json.dump(doc, sys.stdout, indent=2)
298
+ sys.stdout.write("\n")
299
+ return
300
+ limit = None if args.full else args.max_tool_result_bytes
301
+ if limit is not None and limit < 0:
302
+ sys.stderr.write("[ccx] --max-tool-result-bytes must be zero or greater\n")
303
+ raise SystemExit(EXIT_ERROR)
304
+ # Read only the newest turns: a Claude transcript can be hundreds of
305
+ # MB, and Codex builds every turn it returns.
306
+ tail = args.tail if args.tail and args.tail > 0 else None
307
+ doc = client.conversation(
308
+ owner.worker, max_tool_result_bytes=limit,
309
+ **({"_recent_codex_turns": tail} if owner.engine == "codex"
310
+ else {"_recent_claude_turns": tail}))
311
+ if args.tail:
312
+ doc["turns"] = doc.get("turns", [])[-args.tail:]
313
+ json.dump(doc, sys.stdout, indent=2, ensure_ascii=False)
314
+ sys.stdout.write("\n")
315
+ except CcxError as error:
316
+ sys.stderr.write(f"[ccx] logs: {error}\n")
317
+ raise SystemExit(error.exit_code) from error
318
+
319
+
320
+ class _ReportFailure(Exception):
321
+ """One target that cannot be reported: message plus the CLI exit code."""
322
+
323
+ def __init__(self, message: str, exit_code: int = EXIT_NOT_FOUND):
324
+ super().__init__(message)
325
+ self.exit_code = exit_code
326
+
327
+
328
+ def cmd_report(args):
329
+ """Print the latest native final reply, or an exact settled receipt when owned.
330
+
331
+ Strict turn-ref and send/wait replies remain separately receipt-authoritative.
332
+ Plain output contains only the selected reply; JSON includes its provenance.
333
+ """
334
+ from ccx.ops.report import report_worker
335
+ from ccx.store.root import GenerationError
336
+ from ccx.store.identity import IdentityError
337
+ if args.max_chars is not None and args.max_chars < 0:
338
+ print("ccx report: --max-chars must be nonnegative",
339
+ file=sys.stderr)
340
+ raise SystemExit(EXIT_ERROR)
341
+ records = []
342
+ failures = []
343
+ for target in args.targets:
344
+ try:
345
+ records.append(report_worker(target, max_chars=args.max_chars))
346
+ except (GenerationError, IdentityError, CcxError) as exc:
347
+ failures.append({"target": target, "error": str(exc)})
348
+ if args.json:
349
+ if len(args.targets) == 1 and len(records) == 1:
350
+ print(json.dumps(records[0], ensure_ascii=False))
351
+ else:
352
+ print(json.dumps({
353
+ "ok": not failures,
354
+ "reports": records,
355
+ "errors": failures,
356
+ }, ensure_ascii=False))
357
+ else:
358
+ for i, report in enumerate(records):
359
+ if len(args.targets) > 1:
360
+ print(f"=== {report['worker']} ===")
361
+ if not report.get("has_final_message"):
362
+ status = report.get("status")
363
+ if status in {"interrupted", "cancelled"}:
364
+ print("(turn interrupted; no reply)")
365
+ elif status == "failed":
366
+ print("(turn failed; no reply)")
367
+ elif status == "completed":
368
+ print("(turn completed; no reply)")
369
+ else:
370
+ print(report["text"])
371
+ else:
372
+ print(report["text"])
373
+ if report.get("note"):
374
+ where = f" for {report['worker']}" if len(args.targets) > 1 else ""
375
+ print(f"note{where}: {report['note']}", file=sys.stderr)
376
+ for failed in failures:
377
+ print(f"ccx report {failed['target']}: {failed['error']}",
378
+ file=sys.stderr)
379
+ if failures:
380
+ raise SystemExit(3)
381
+
382
+
383
+ def _format_context_line(worker: str, ctx: dict | None) -> str:
384
+ """Plain Claude context from typed flat fields; state remains lifecycle."""
385
+ reason = (ctx or {}).get("reason")
386
+ if ctx is None or reason == "no-signal":
387
+ return f"{worker} context: no signal yet (none recorded; updates after the next turn)"
388
+ if reason == "pending":
389
+ return f"{worker} context: pending (no completed turn with token usage yet)"
390
+ if reason == "compacting":
391
+ return f"{worker} context: unknown (compacting)"
392
+ if reason == "just_compacted":
393
+ return f"{worker} context: unknown (just compacted)"
394
+ tokens = ctx.get("tokens_used")
395
+ return (f"{worker} context: {tokens:,} tokens"
396
+ if tokens is not None else f"{worker} context: unknown")
397
+
398
+
399
+ def cmd_context(args):
400
+ """Print an exact full-UUID owner's existing typed recorded context."""
401
+ from ccx import constants
402
+ from ccx.client import CcxClient
403
+ from ccx.codex.client import CodexClient
404
+ from ccx.store.root import GenerationError, require_ccx_active
405
+ from ccx.store.identity import IdentityError, UnknownWorker
406
+
407
+ try:
408
+ if getattr(args, "live", False):
409
+ raise SessionUnavailable(
410
+ "context --live unavailable: external Claude discovery disabled"
411
+ )
412
+ try:
413
+ store = require_ccx_active(constants.CCX_ROOT)
414
+ owner = store.resolve(args.target)
415
+ store.revalidate()
416
+ except UnknownWorker as exc:
417
+ raise SessionNotFound(f"context: worker not found: {args.target}") from exc
418
+ except IdentityError as exc:
419
+ raise InvalidRequest(f"context: invalid worker handle: {exc}") from exc
420
+ except (GenerationError, OSError) as exc:
421
+ raise SessionUnavailable(f"context: worker record unavailable; check CCX_ROOT: {exc}") from exc
422
+ typed = (CcxClient().context(owner.worker) if owner.engine == "claude"
423
+ else CodexClient().context(owner.worker))
424
+ output = typed.as_dict()
425
+ except CcxError as exc:
426
+ sys.stderr.write(f"[ccx] context: {exc}\n")
427
+ raise SystemExit(exc.exit_code) from exc
428
+
429
+ if args.json:
430
+ print(json.dumps(output, indent=2))
431
+ elif owner.engine == "claude":
432
+ print(_format_context_line(output["worker"], output))
433
+ else:
434
+ worker = output["worker"]
435
+ tokens = output["tokens_used"]
436
+ window = output["window_size"]
437
+ percent = output["percent_used"]
438
+ if tokens is None:
439
+ print(f"{worker} context unavailable")
440
+ elif window:
441
+ usage = f"{worker} {tokens:,} / {window:,} tokens"
442
+ if percent is not None:
443
+ usage += f" ({percent:.1f}%)"
444
+ print(usage)
445
+ else:
446
+ print(f"{worker} {tokens:,} tokens")
447
+
448
+
449
+ def _combined_usage(args):
450
+ """Run each native account reader once; only probe login on read failure."""
451
+ from contextlib import redirect_stdout, redirect_stderr
452
+ from copy import copy
453
+ from io import StringIO
454
+ from ccx.cli import codex as codex_commands
455
+ from ccx.system.engine_binary import resolve_engine_binary, account_signed_out
456
+
457
+ responses, answered = {}, 0
458
+ for engine, handler in (("claude", cmd_usage), ("codex", codex_commands.cmd_usage)):
459
+ try:
460
+ binary = resolve_engine_binary(engine)
461
+ except EngineCliNotFound as error:
462
+ responses[engine] = {"skipped": "not_installed", "message": str(error)}
463
+ continue
464
+ stdout, stderr = StringIO(), StringIO()
465
+ error = None
466
+ try:
467
+ selected = copy(args)
468
+ selected.engine = engine
469
+ with redirect_stdout(stdout), redirect_stderr(stderr):
470
+ handler(selected)
471
+ except (Exception, SystemExit) as exc:
472
+ error = exc
473
+ output, warning = stdout.getvalue(), stderr.getvalue()
474
+ if error is None:
475
+ answered += 1
476
+ responses[engine] = (json.loads(output) if args.json else output)
477
+ if warning:
478
+ sys.stderr.write(warning)
479
+ elif account_signed_out(engine, binary):
480
+ responses[engine] = {"skipped": "signed_out", "message": f"{engine} is not signed in"}
481
+ else:
482
+ message = warning.strip() or str(error)
483
+ responses[engine] = {"error": {"type": type(error).__name__, "message": message}}
484
+ if not args.json:
485
+ sys.stderr.write(f"[ccx] usage {engine}: {message}\n")
486
+ if args.json:
487
+ print(json.dumps(responses, indent=2))
488
+ else:
489
+ for engine in ("claude", "codex"):
490
+ row = responses[engine]
491
+ if isinstance(row, dict) and "skipped" in row:
492
+ print(f"{engine.capitalize()}: skipped ({row['skipped']}): {row['message']}")
493
+ elif isinstance(row, dict) and "error" in row:
494
+ print(f"{engine.capitalize()}: error (see stderr)")
495
+ else:
496
+ print(f"{engine.capitalize()}:")
497
+ print(row, end="" if row.endswith("\n") else "\n")
498
+ if not answered:
499
+ raise SystemExit(EXIT_ERROR)
500
+
501
+
502
+ def cmd_usage(args):
503
+ """Read Claude plan usage or persist its machine-wide source."""
504
+ selected = getattr(args, "set_source", None)
505
+ if getattr(args, "engine", None) is None and selected is None:
506
+ return _combined_usage(args)
507
+ if selected is not None:
508
+ if getattr(args, "cached", False):
509
+ raise InvalidRequest("--set-source cannot combine with --cached")
510
+ try:
511
+ print(f"Claude usage source set to {set_usage_source(selected)}")
512
+ except CcxError as error:
513
+ sys.stderr.write(f"[ccx] usage: {error}\n")
514
+ raise SystemExit(error.exit_code) from None
515
+ return
516
+
517
+ from ccx.system.engine_binary import resolve_engine_binary
518
+ try:
519
+ resolve_engine_binary("claude")
520
+ except EngineCliNotFound as error:
521
+ if args.json:
522
+ print(json.dumps({"ok": False, "code": "runtime_unavailable",
523
+ "message": str(error)}, sort_keys=True))
524
+ else:
525
+ sys.stderr.write(f"[ccx] usage: {error}\n")
526
+ raise SystemExit(error.exit_code) from error
527
+ data, origin = fetch_usage_for_display(force=not args.cached)
528
+ error = origin.get("live_error")
529
+ if data is None:
530
+ suffix = ""
531
+ if origin.get("held"):
532
+ suffix = f"; held, retry ~{_usage_clock(origin.get('held_until'))}"
533
+ sys.stderr.write(f"[ccx] usage: no signal: {error or 'Claude usage unavailable'}{suffix}; cache {origin['cache']}\n")
534
+ sys.exit(EXIT_ERROR)
535
+ if origin.get("unparsed"):
536
+ heading = "usage (unparsed provider response):"
537
+ elif origin.get("held"):
538
+ heading = (f"usage (last known {_usage_clock(origin.get('fetched_at'))} "
539
+ f"(held, retry ~{_usage_clock(origin.get('held_until'))})):" )
540
+ elif origin.get("stale"):
541
+ heading = (f"usage (last known {_usage_clock(origin.get('fetched_at'))} "
542
+ f"(stale, retry ~{_usage_clock(origin.get('next_attempt_at'))})):" )
543
+ elif origin["source"] == "live":
544
+ heading = "usage (live):"
545
+ else:
546
+ heading = f"usage (cached, {describe_usage_age(origin.get('age_s'))}):"
547
+ if (getattr(args, "json", False) or getattr(args, "summary", False)) and (
548
+ origin.get("stale") or origin.get("unparsed")
549
+ ):
550
+ sys.stderr.write(f"[ccx] usage: {heading} {error or 'last-known data'}\n")
551
+ if getattr(args, "summary", False):
552
+ print(json.dumps(usage_summary(data), indent=2))
553
+ return
554
+ if getattr(args, "json", False):
555
+ print(json.dumps(data, indent=2))
556
+ return
557
+ print(heading)
558
+ if origin.get("unparsed"):
559
+ print(json.dumps((data.get("_ccx") or {}).get("raw"), indent=2))
560
+ return
561
+
562
+ summary = usage_summary(data)
563
+ from ccx.cli.commands._common import local_reset_time
564
+ def row(label, field):
565
+ block = summary.get(field)
566
+ if not isinstance(block, dict) or "utilization" not in block:
567
+ return
568
+ util = block["utilization"]
569
+ resets = local_reset_time(block.get("resets_at"))
570
+ print(f" {label:<10} {util:>5.1f}% resets {resets}")
571
+
572
+ row("5h", "five_hour")
573
+ row("7d", "seven_day")
574
+ model_quotas = summary.get("model_quotas") or []
575
+ if model_quotas:
576
+ for quota in model_quotas:
577
+ resets = local_reset_time(quota.get("resets_at"))
578
+ label = f"7d {quota['model'].lower()}"
579
+ print(f" {label:<10} {quota['utilization']:>5.1f}% resets {resets}")
580
+ else:
581
+ row("7d opus", "seven_day_opus")
582
+ row("7d sonnet", "seven_day_sonnet")
583
+ xu = data.get("extra_usage")
584
+ if isinstance(xu, dict) and xu.get("is_enabled"):
585
+ util = xu.get("utilization")
586
+ if isinstance(util, (float, int)) and not isinstance(util, bool):
587
+ print(f" {'extra':<10} {util:>5.1f}% "
588
+ f"{xu.get('used_credits')}/{xu.get('monthly_limit')} {xu.get('currency') or ''}")
589
+
590
+
591
+ def _usage_clock(iso_time):
592
+ """Local wall-clock display of a reported (or estimated) UTC instant."""
593
+ from datetime import datetime
594
+ if not isinstance(iso_time, str):
595
+ return "unknown"
596
+ try:
597
+ return datetime.fromisoformat(iso_time.replace("Z", "+00:00")).astimezone().strftime("%H:%M")
598
+ except ValueError:
599
+ return "unknown"
600
+
601
+
602
+ def _recorded_peek_pending(owner, rec: dict) -> dict:
603
+ """One recorded-only gate projection for status and conversation peek."""
604
+ if owner.engine == "claude":
605
+ waiting = rec.get("waitingFor")
606
+ if not isinstance(waiting, dict):
607
+ return {"pending_input_observed": False}
608
+ return {
609
+ "pending_input_observed": True,
610
+ "waiting_for": waiting,
611
+ "pending_input": {"request_id": waiting.get("id"),
612
+ "kind": pending_input_kind(waiting)},
613
+ }
614
+ lifecycle = rec.get("lifecycle")
615
+ lifecycle = lifecycle if isinstance(lifecycle, dict) else {}
616
+ approvals = ([value for value in (rec.get("pendingApprovals") or [])
617
+ if isinstance(value, dict)]
618
+ if lifecycle.get("state") == "waiting" else [])
619
+ selected = codex_read.pending_input(approvals)
620
+ return {
621
+ "pending_input_observed": bool(approvals),
622
+ **({"pending_input": selected, "pending_approvals": [
623
+ {"gateId": value.get("gateId"), "kind": value.get("kind"),
624
+ "command": value.get("command"), "runtimeId": value.get("runtimeId")}
625
+ for value in approvals]}
626
+ if selected is not None else {}),
627
+ }
628
+
629
+
630
+ def _background_summary_line(summary: dict | None) -> str | None:
631
+ """Brief recorded owner snapshot; never read native background sources."""
632
+ if not isinstance(summary, dict):
633
+ return None
634
+ running = summary.get("running_count", summary.get("runningCount"))
635
+ pending = summary.get("pending_count", 0)
636
+ if running == 0 and pending == 0:
637
+ return None
638
+ if summary.get("available") is False or running is None:
639
+ return "background: unavailable"
640
+ kinds = summary.get("by_kind", summary.get("kinds")) or {}
641
+ return (f"background: running={running} pending={pending} "
642
+ f"command={kinds.get('command', kinds.get('bash', 0))} "
643
+ f"monitor={kinds.get('monitor', 0)} "
644
+ f"subagent={kinds.get('subagent', 0)}")
645
+
646
+
647
+ def _peek_conversation(args, store):
648
+ """Project one original schema-5 snapshot for the CLI, without native writes."""
649
+ from ccx.store.root import GenerationError
650
+ from ccx.store.identity import IdentityError
651
+ from ccx.workers import WorkerClient
652
+
653
+ try:
654
+ owner = store.resolve(args.target)
655
+ options = {}
656
+ 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
+ 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
+ )
664
+ if (not isinstance(doc, dict) or doc.get("schemaVersion") != 5
665
+ or not isinstance(doc.get("worker"), dict)
666
+ or doc["worker"].get("handle") != owner.worker
667
+ or not isinstance(doc.get("turns"), list)
668
+ or not isinstance(doc.get("completeness"), dict)
669
+ or not isinstance(doc.get("evidence"), dict)):
670
+ raise SessionUnavailable("The conversation is incomplete; retry after the worker finishes writing")
671
+ rec = store.record(owner.worker)
672
+ pending = _recorded_peek_pending(owner, rec)
673
+ bg_summary = rec.get("backgroundTaskSummary")
674
+ store.revalidate()
675
+ except (CcxError, GenerationError, IdentityError, OSError) as exc:
676
+ print(f"ccx peek: cannot read the conversation; check the worker: {exc}", file=sys.stderr)
677
+ raise SystemExit(getattr(exc, "exit_code", EXIT_UNAVAILABLE)) from None
678
+
679
+ turns = [t for t in doc["turns"]
680
+ if args.since_turn is None or t["sequence"] >= args.since_turn]
681
+ # The input/output buckets are NOT chronological. The original ordinal is.
682
+ ordered = [
683
+ {**item, "turn_sequence": turn["sequence"], "turn_id": turn.get("turnId")}
684
+ for turn in turns
685
+ for item in sorted(turn["input"] + turn["output"], key=lambda i: i["ordinal"])
686
+ ]
687
+ users = [item for item in ordered if item["kind"] == "user-text"]
688
+ assistants = [item for item in ordered if item["kind"] == "assistant-text"]
689
+ tools = [item for item in ordered if item["kind"] in {"tool-call", "tool-result"}]
690
+ recent_tools = tools[-args.tail:] if args.tail else []
691
+ # Engine/API errors occupy the same bounded chronological activity window
692
+ # as tools, not an independent unbounded failure-history bucket.
693
+ activities = [item for item in ordered
694
+ if item["kind"] in {"tool-call", "tool-result", "error"}]
695
+ recent_activity = activities[-args.tail:] if args.tail else []
696
+ if args.tools_only:
697
+ displayed = recent_tools
698
+ elif args.text_only:
699
+ displayed = [*users[-1:], *assistants[-1:]]
700
+ else:
701
+ # Show latest user and assistant (including mid-turn steers) IN source
702
+ # order relative to the recent tools, not grouped by bucket.
703
+ selected = {id(item) for item in [*recent_activity,
704
+ *users[-1:], *assistants[-1:]]}
705
+ displayed = [item for item in ordered if id(item) in selected]
706
+ projection = {
707
+ "worker": doc["worker"], "state": doc.get("state"),
708
+ **pending,
709
+ "generated_at": doc.get("generatedAt"),
710
+ "completeness": doc["completeness"], "evidence": doc["evidence"],
711
+ "native_artifacts": doc.get("nativeArtifacts"),
712
+ "since_turn": args.since_turn, "turn_count": len(turns),
713
+ **({"history_window": doc["peekHistoryWindow"]}
714
+ if "peekHistoryWindow" in doc else {}),
715
+ "last_user": users[-1] if users and not args.tools_only else None,
716
+ "last_assistant": assistants[-1] if assistants and not args.tools_only else None,
717
+ "tool_count": len(tools), "tools": recent_tools if not args.text_only else [],
718
+ "recent_items": displayed,
719
+ **({"background_task_summary": bg_summary} if bg_summary is not None else {}),
720
+ }
721
+ if args.json:
722
+ print(json.dumps(projection, indent=2, ensure_ascii=False))
723
+ return
724
+
725
+ worker = doc["worker"]
726
+ lines = [
727
+ f"{worker['handle']}: {doc.get('state') or '?'} "
728
+ f"native={worker.get('nativeSessionId') or '?'}",
729
+ f"source: schema-5 completeness={doc['completeness'].get('status')} "
730
+ f"evidence={doc['evidence'].get('status')}",
731
+ ]
732
+ waiting = pending.get("pending_input")
733
+ if waiting:
734
+ if owner.engine == "codex":
735
+ approvals = pending.get("pending_approvals") or []
736
+ first = approvals[0] if approvals else {}
737
+ line = f"awaiting: approval gate_id={waiting.get('request_id') or 'unavailable'}"
738
+ if isinstance(first.get("command"), str) and first["command"]:
739
+ line += " command=" + _truncate(" ".join(first["command"].split()), 120)
740
+ lines.append(line)
741
+ else:
742
+ if waiting.get("request_id"):
743
+ lines.append(f"awaiting: {waiting.get('kind') or 'input'} "
744
+ f"request_id={waiting['request_id']}")
745
+ else:
746
+ lines.append(f"awaiting: {waiting.get('kind') or 'input'} in the worker terminal "
747
+ f"(ccx attach {worker['handle']})")
748
+ background_line = _background_summary_line(bg_summary)
749
+ if background_line:
750
+ lines.append(background_line)
751
+ for field in ("completeness", "evidence"):
752
+ for warning in doc[field].get("warnings") or []:
753
+ lines.append(f"{field} warning: {warning}")
754
+ if args.brief:
755
+ if assistants and not args.tools_only:
756
+ lines.append("assistant: " + _truncate(assistants[-1].get("text"), 240))
757
+ if not args.text_only:
758
+ names = [i.get("name") or i.get("nativeType") or "tool"
759
+ for i in (recent_tools[-2:] if args.tail else [])]
760
+ lines.append(f"tools: {len(tools)} recorded; recent " + ", ".join(names))
761
+ else:
762
+ for item in displayed:
763
+ where = (f"recent turn ordinal {item['ordinal']}"
764
+ if item["turn_sequence"] is None
765
+ else f"turn {item['turn_sequence']} ordinal {item['ordinal']}")
766
+ kind = item["kind"]
767
+ if kind in {"user-text", "assistant-text"}:
768
+ text = str(item.get("text") or "")
769
+ lines.append(f"{where} {kind}: " + (text if args.full else _truncate(text, 2000)))
770
+ elif kind in {"tool-call", "tool-result"}:
771
+ label = item.get("name") or item.get("callId") or item.get("id") or "tool"
772
+ lines.append(f"{where} {kind} {label}: {item.get('status') or 'recorded'}")
773
+ if kind == "tool-call" and item.get("arguments"):
774
+ arguments = json.dumps(item["arguments"], ensure_ascii=False, default=str)
775
+ lines.append(" args: " + (arguments if args.full else _truncate(arguments, 400)))
776
+ result = item.get("result")
777
+ if isinstance(result, dict):
778
+ content = str(result.get("content") or "")
779
+ lines.append(" result" + (" ERROR" if result.get("isError") else "")
780
+ + (" [native truncated]" if result.get("truncated") else "")
781
+ + ": " + (content if args.full else _truncate(content, 1000)))
782
+ elif kind == "error":
783
+ summary = item.get("summary")
784
+ if isinstance(summary, dict):
785
+ summary = " ".join(str(summary.get(k) or "") for k in ("verb", "target")).strip()
786
+ message = str(item.get("text") or item.get("message") or summary or item.get("result") or "error")
787
+ lines.append(f"{where} error: " +
788
+ (message if args.full else _truncate(message, 1000)))
789
+ output = "\n".join(lines) + "\n"
790
+ budget = args.max_chars if args.max_chars is not None else (None if args.full else 8000)
791
+ if budget is not None and len(output) > budget:
792
+ notice = "\n[peek display truncated; use --json or --full]\n"
793
+ output = (output[:max(0, budget-len(notice))] + notice)[:budget]
794
+ sys.stdout.write(output)
795
+
796
+
797
+ def cmd_peek(args):
798
+ from ccx import constants
799
+ from ccx.store.root import require_ccx_active
800
+ store = require_ccx_active(constants.CCX_ROOT)
801
+ """Forensic-only exact-UUID recorded status; no host/native observation."""
802
+ import time
803
+ from ccx.store.root import GenerationError
804
+ from ccx.store.identity import IdentityError
805
+
806
+ if (args.tail < 0 or (args.since_turn is not None and args.since_turn < 1)
807
+ or (args.max_chars is not None and args.max_chars < 0)
808
+ or (args.tools_only and args.text_only)):
809
+ print("ccx peek: --tail/--max-chars must be nonnegative, --since-turn >= 1; "
810
+ "choose at most one --tools-only/--text-only",
811
+ file=sys.stderr)
812
+ raise SystemExit(1)
813
+ if args.status and (args.full
814
+ or args.tools_only or args.text_only
815
+ or args.brief or args.since_turn is not None
816
+ or args.max_chars is not None or args.tail != 4):
817
+ print(
818
+ "ccx peek: --status is recorded-only; rich conversation options require "
819
+ "a single target without --status",
820
+ file=sys.stderr,
821
+ )
822
+ raise SystemExit(1)
823
+ if not args.status:
824
+ if args.extra_shorts:
825
+ print("ccx peek: only --status supports multiple targets", file=sys.stderr)
826
+ raise SystemExit(1)
827
+ return _peek_conversation(args, store)
828
+ targets = [args.target, *(getattr(args, "extra_shorts", None) or [])]
829
+ if len(targets) > 128:
830
+ print("ccx peek: too many target UUIDs for one bounded snapshot",
831
+ file=sys.stderr)
832
+ raise SystemExit(1)
833
+ from ccx.store.worker_state import normalize_native_state
834
+ from ccx.store.discovery import _query, _match
835
+
836
+ def read_one(target):
837
+ engine, prefix, incomplete = _query(target)
838
+ resolved = _match(target, engine, prefix, store) if incomplete else target
839
+ owner = store.resolve(resolved)
840
+ rec = store.record(owner.worker)
841
+ lifecycle = rec.get("lifecycle")
842
+ lifecycle = lifecycle if isinstance(lifecycle, dict) else {}
843
+ native = rec.get("native")
844
+ native = native if isinstance(native, dict) else {}
845
+ stats = rec.get("stats")
846
+ stats = stats if isinstance(stats, dict) else {}
847
+ timestamp = rec.get("lastTurnAt")
848
+ age = (max(0, time.time() - timestamp / 1000)
849
+ if type(timestamp) is int and timestamp > 0 else None)
850
+ state = normalize_native_state(lifecycle.get("state"))
851
+ if (owner.engine == "claude" and state == "idle"
852
+ and _claude_recorded_delivery_busy(owner.worker, rec)):
853
+ state = "busy"
854
+ return {
855
+ "worker": owner.worker,
856
+ "worker_id": owner.worker_id,
857
+ "engine": owner.engine,
858
+ "short": owner.short,
859
+ "state": state,
860
+ "native_state": native.get("nativeState"),
861
+ "state_source": "recorded",
862
+ "host_status": "unobserved",
863
+ "loaded": recorded_host_loaded(owner.engine, lifecycle),
864
+ **_recorded_peek_pending(owner, rec),
865
+ "turn": stats.get("turns"),
866
+ "last_turn_age_s": age,
867
+ "terminal_only": bool(rec.get("terminalOnly")),
868
+ "record_revision": rec.get("recordRevision"),
869
+ "context": _status_context_for_record(owner.worker, rec),
870
+ "forked_from": public_lineage(rec),
871
+ **({"background_task_summary": rec["backgroundTaskSummary"]}
872
+ if "backgroundTaskSummary" in rec else {}),
873
+ }
874
+
875
+ rows, errors = [], {}
876
+ for target in targets:
877
+ try:
878
+ rows.append(read_one(target))
879
+ except (IdentityError, GenerationError, CcxError, OSError) as exc:
880
+ if len(targets) == 1:
881
+ if isinstance(exc, IdentityError):
882
+ print(f"ccx peek: invalid or unowned full UUID: {exc}", file=sys.stderr)
883
+ raise SystemExit(2) from None
884
+ print(f"ccx peek: unavailable generation or record: {exc}",
885
+ file=sys.stderr)
886
+ raise SystemExit(getattr(exc, "exit_code", 3)) from None
887
+ errors[target] = {"error": type(exc).__name__, "message": str(exc),
888
+ "exit_code": getattr(exc, "exit_code", 3)}
889
+ try:
890
+ store.revalidate()
891
+ except GenerationError as exc:
892
+ print(f"ccx peek: unavailable generation or record: {exc}", file=sys.stderr)
893
+ raise SystemExit(3) from None
894
+ if args.json:
895
+ print(json.dumps({"statuses": rows, **({"errors": errors} if errors else {})},
896
+ indent=2, sort_keys=True))
897
+ else:
898
+ for r in rows:
899
+ pending = r.get("pending_input") or {}
900
+ label = (f" awaiting={pending.get('kind')} "
901
+ f"request_id={pending.get('request_id')}" if pending else "")
902
+ if r["engine"] == "codex" and r.get("pending_approvals"):
903
+ gates = [item.get("gateId") for item in r["pending_approvals"]
904
+ if isinstance(item.get("gateId"), str) and item["gateId"]]
905
+ label = " approval_gate_ids=" + ",".join(gates) if gates else " approval_gate_id=unavailable"
906
+ tokens = r["context"]["tokens_used"]
907
+ ctx = (f"{tokens / 1_000_000:.1f}M" if tokens is not None and tokens >= 1_000_000
908
+ else f"{tokens / 1_000:.0f}k" if tokens is not None and tokens >= 1_000
909
+ else str(tokens) if tokens is not None else "—")
910
+ bg_line = _background_summary_line(r.get("background_task_summary"))
911
+ print(f"{r['worker']}: {r['state'] or '?'} ctx={ctx}"
912
+ + (" terminal-only" if r["terminal_only"] else "") + label
913
+ + (f" {bg_line}" if bg_line else ""))
914
+ if errors:
915
+ for target, error in errors.items():
916
+ print(f"ccx peek: {target}: {error['message']}", file=sys.stderr)
917
+ raise SystemExit(1)
918
+
919
+
920
+ EVENTS_TAIL_DEFAULT = 20
921
+
922
+
923
+ def _event_summary(event: dict) -> str:
924
+ """One short human line for a feed event (the JSON form has everything)."""
925
+ kind = event.get("kind")
926
+ if kind == "state":
927
+ return f"{event.get('from')} -> {event.get('to')}"
928
+ if kind in {"tool.started", "tool.finished"}:
929
+ detail = (event.get("input_preview") if kind == "tool.started"
930
+ else ("ok" if event.get("ok") else "failed"))
931
+ return f"{event.get('tool')} {str(detail or '')[:80]}".rstrip()
932
+ if kind == "turn.ended":
933
+ return str(event.get("outcome") or "")
934
+ if kind in {"input.requested", "input.resolved"}:
935
+ request = event.get("request") or {}
936
+ rid = event.get("request_id") or request.get("request_id") or request.get("id")
937
+ return f"request {rid}" if rid is not None else ""
938
+ if kind == "error":
939
+ return str(event.get("message") or event.get("reason") or "")[:120]
940
+ return ""
941
+
942
+
943
+ def _print_event(event: dict, *, as_json: bool) -> None:
944
+ if as_json:
945
+ sys.stdout.write(json.dumps(event) + "\n")
946
+ elif event.get("kind") != event_feed.KIND_HEARTBEAT:
947
+ stamp = time.strftime("%H:%M:%S", time.localtime(event.get("ts") or 0))
948
+ label = event.get("kind") or event.get("type")
949
+ if event.get("kind") and event.get("kind") != event.get("type"):
950
+ label = f"{event['kind']} ({event.get('type')})"
951
+ sys.stdout.write(f"{stamp} {label} {_event_summary(event)}".rstrip() + "\n")
952
+ if event.get("gap"):
953
+ sys.stderr.write(f"[ccx] events: cursor could not be placed "
954
+ f"({event['gap'].get('reason')}); replayed what is retained\n")
955
+ sys.stdout.flush()
956
+
957
+
958
+ def cmd_events(args):
959
+ """Read or follow the original full-UUID worker's lifecycle events.
960
+
961
+ Snapshot JSON is one complete page with a resumable cursor; follow JSON
962
+ is NDJSON (events and cursor heartbeats). Neither path starts a worker.
963
+ """
964
+ from ccx.errors import CursorInvalidated
965
+ from ccx.workers import WorkerClient
966
+
967
+ if args.after is not None and args.tail is not None:
968
+ sys.stderr.write("[ccx] --after and --tail cannot be combined\n")
969
+ sys.exit(EXIT_ERROR)
970
+ if args.timeout is not None and not args.follow:
971
+ sys.stderr.write("[ccx] --timeout requires --follow\n")
972
+ sys.exit(EXIT_ERROR)
973
+ tail = args.tail
974
+ if args.after is None and tail is None:
975
+ tail = EVENTS_TAIL_DEFAULT
976
+ kinds = args.kind or None
977
+
978
+ try:
979
+ client = WorkerClient()
980
+ if args.follow:
981
+ for item in client.follow(
982
+ args.target, after=args.after, tail=tail, kinds=kinds,
983
+ timeout=args.timeout, heartbeat=args.heartbeat,
984
+ ):
985
+ _print_event(item, as_json=args.json)
986
+ return
987
+ page = client.events(
988
+ args.target, after=args.after, tail=tail,
989
+ limit=event_feed.LIMIT_MAX, kinds=kinds,
990
+ )
991
+ events = list(page["events"])
992
+ gap = page["gap"]
993
+ # A short tail page continues from the journal head; paging on would
994
+ # append the whole history after the newest events.
995
+ while page["has_more"] and tail is None:
996
+ page = client.events(
997
+ args.target, after=page["next_cursor"],
998
+ limit=event_feed.LIMIT_MAX, kinds=kinds,
999
+ )
1000
+ events.extend(page["events"])
1001
+ gap = gap or page["gap"]
1002
+ if args.json:
1003
+ print(json.dumps({**page, "events": events,
1004
+ "has_more": page["has_more"] and tail is not None,
1005
+ "gap": gap}))
1006
+ return
1007
+ for event in events:
1008
+ _print_event(event, as_json=False)
1009
+ if gap:
1010
+ _print_event({"kind": event_feed.KIND_HEARTBEAT, "gap": gap},
1011
+ as_json=False)
1012
+ except KeyboardInterrupt:
1013
+ pass
1014
+ except CursorInvalidated as error:
1015
+ sys.stderr.write(f"[ccx] invalid --after cursor: {error}; "
1016
+ "obtain a fresh cursor with ccx events --json\n")
1017
+ sys.exit(EXIT_ERROR)
1018
+ except CcxError as error:
1019
+ sys.stderr.write(f"[ccx] {error}\n")
1020
+ sys.exit(error.exit_code)