ccx-native 0.0.1 → 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.
- package/LICENSE +21 -0
- package/README.md +241 -4
- package/bin/ccx +55 -2
- package/ccx/__init__.py +163 -0
- package/ccx/__main__.py +28 -0
- package/ccx/claude/__init__.py +0 -0
- package/ccx/claude/analytics.py +466 -0
- package/ccx/claude/attach.py +762 -0
- package/ccx/claude/context.py +465 -0
- package/ccx/claude/delivery.py +806 -0
- package/ccx/claude/external.py +253 -0
- package/ccx/claude/host/__init__.py +0 -0
- package/ccx/claude/host/events.py +297 -0
- package/ccx/claude/host/hooks.py +438 -0
- package/ccx/claude/host/pty.py +250 -0
- package/ccx/claude/host/session.py +5184 -0
- package/ccx/claude/host/subagents.py +169 -0
- package/ccx/claude/host/successor.py +267 -0
- package/ccx/claude/managed_turns.py +240 -0
- package/ccx/claude/oauth.py +176 -0
- package/ccx/claude/settings.py +339 -0
- package/ccx/claude/spawn.py +557 -0
- package/ccx/claude/tasks.py +72 -0
- package/ccx/claude/telemetry.py +282 -0
- package/ccx/claude/transcript.py +2281 -0
- package/ccx/claude/usage.py +837 -0
- package/ccx/claude/usage_control.py +152 -0
- package/ccx/cli/__init__.py +0 -0
- package/ccx/cli/codex.py +1009 -0
- package/ccx/cli/commands/__init__.py +0 -0
- package/ccx/cli/commands/_common.py +407 -0
- package/ccx/cli/commands/control.py +161 -0
- package/ccx/cli/commands/create.py +669 -0
- package/ccx/cli/commands/read.py +1020 -0
- package/ccx/cli/commands/turns.py +1081 -0
- package/ccx/cli/controller.py +757 -0
- package/ccx/cli/main.py +301 -0
- package/ccx/cli/parser.py +1324 -0
- package/ccx/cli/skills.py +215 -0
- package/ccx/cli/statusline.py +160 -0
- package/ccx/client.py +1900 -0
- package/ccx/codex/__init__.py +0 -0
- package/ccx/codex/attach_tty.py +267 -0
- package/ccx/codex/client.py +732 -0
- package/ccx/codex/completion.py +121 -0
- package/ccx/codex/control.py +288 -0
- package/ccx/codex/daemon/__init__.py +0 -0
- package/ccx/codex/daemon/__main__.py +4 -0
- package/ccx/codex/daemon/daemon.py +1556 -0
- package/ccx/codex/daemon/models.py +140 -0
- package/ccx/codex/daemon/native.py +1590 -0
- package/ccx/codex/daemon/release.py +367 -0
- package/ccx/codex/daemon/worker.py +2396 -0
- package/ccx/codex/delivery.py +758 -0
- package/ccx/codex/delivery_recovery.py +232 -0
- package/ccx/codex/evidence.py +827 -0
- package/ccx/codex/history.py +158 -0
- package/ccx/codex/notify_history.py +140 -0
- package/ccx/codex/ops.py +666 -0
- package/ccx/codex/paths.py +70 -0
- package/ccx/codex/protocol.py +805 -0
- package/ccx/codex/provenance.py +105 -0
- package/ccx/codex/read.py +29 -0
- package/ccx/codex/records.py +51 -0
- package/ccx/codex/reports.py +177 -0
- package/ccx/codex/runtime.py +1412 -0
- package/ccx/codex/settle.py +363 -0
- package/ccx/codex/telemetry.py +792 -0
- package/ccx/codex/types.py +659 -0
- package/ccx/codex/worker_environment.py +25 -0
- package/ccx/constants.py +288 -0
- package/ccx/controllers/__init__.py +0 -0
- package/ccx/controllers/controller.py +1449 -0
- package/ccx/controllers/live_vars.py +1706 -0
- package/ccx/controllers/live_vars_runner.py +216 -0
- package/ccx/controllers/notify.py +213 -0
- package/ccx/controllers/orchestrator.py +193 -0
- package/ccx/controllers/policy.py +57 -0
- package/ccx/controllers/store.py +681 -0
- package/ccx/errors.py +634 -0
- package/ccx/notify/__init__.py +0 -0
- package/ccx/notify/__main__.py +5 -0
- package/ccx/notify/client.py +116 -0
- package/ccx/notify/daemon.py +480 -0
- package/ccx/notify/deliver.py +304 -0
- package/ccx/notify/paths.py +51 -0
- package/ccx/notify/registry.py +649 -0
- package/ccx/notify/select.py +750 -0
- package/ccx/notify/settle.py +684 -0
- package/ccx/ops/__init__.py +0 -0
- package/ccx/ops/background.py +115 -0
- package/ccx/ops/cancel.py +289 -0
- package/ccx/ops/codex_import.py +96 -0
- package/ccx/ops/common.py +397 -0
- package/ccx/ops/compact.py +337 -0
- package/ccx/ops/compact_ops.py +550 -0
- package/ccx/ops/context_events.py +48 -0
- package/ccx/ops/conversation.py +1540 -0
- package/ccx/ops/conversation_paging.py +1338 -0
- package/ccx/ops/create.py +56 -0
- package/ccx/ops/creation.py +952 -0
- package/ccx/ops/creation_steps.py +579 -0
- package/ccx/ops/event_feed.py +710 -0
- package/ccx/ops/forking.py +711 -0
- package/ccx/ops/input.py +556 -0
- package/ccx/ops/maintenance.py +295 -0
- package/ccx/ops/report.py +770 -0
- package/ccx/ops/semantics.py +490 -0
- package/ccx/ops/send.py +961 -0
- package/ccx/ops/steer_ops.py +867 -0
- package/ccx/ops/terminate.py +383 -0
- package/ccx/ops/turn_reports.py +306 -0
- package/ccx/ops/wait.py +762 -0
- package/ccx/ops/worker_events.py +636 -0
- package/ccx/rpc/__init__.py +0 -0
- package/ccx/rpc/local_rpc.py +1602 -0
- package/ccx/rpc/rpc.py +559 -0
- package/ccx/rpc/serve.py +374 -0
- package/ccx/rpc/transport.py +409 -0
- package/ccx/rpc/view.py +125 -0
- package/ccx/store/__init__.py +0 -0
- package/ccx/store/discovery.py +170 -0
- package/ccx/store/identity.py +105 -0
- package/ccx/store/journal_io.py +79 -0
- package/ccx/store/liveness.py +194 -0
- package/ccx/store/paths.py +254 -0
- package/ccx/store/record_cache.py +40 -0
- package/ccx/store/records.py +309 -0
- package/ccx/store/root.py +804 -0
- package/ccx/store/runtime_presence.py +137 -0
- package/ccx/store/store.py +2332 -0
- package/ccx/store/worker_state.py +86 -0
- package/ccx/system/__init__.py +0 -0
- package/ccx/system/bounded.py +296 -0
- package/ccx/system/cwd_paths.py +25 -0
- package/ccx/system/engine_binary.py +59 -0
- package/ccx/system/executable.py +50 -0
- package/ccx/system/oslayer.py +667 -0
- package/ccx/system/pipes.py +46 -0
- package/ccx/system/sanitize.py +43 -0
- package/ccx/types.py +705 -0
- package/ccx/version.py +29 -0
- package/ccx/workers.py +2081 -0
- package/ccx.py +9 -0
- package/package.json +39 -7
- package/pyproject.toml +87 -0
- package/skills/ccx/SKILL.md +155 -0
- package/skills/ccx/references/controllers.md +244 -0
- package/skills/ccx/references/troubleshooting.md +94 -0
- 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)
|