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
package/ccx/types.py
ADDED
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
"""Typed return shapes for the public :mod:`ccx.client` API.
|
|
2
|
+
|
|
3
|
+
Every value the client hands back is a frozen dataclass with an ``as_dict()``
|
|
4
|
+
method, so callers get attribute access (``status.state``) *and* a plain-``dict``
|
|
5
|
+
projection that round-trips through ``json.dumps`` or feeds a Pydantic model
|
|
6
|
+
unchanged. The dict shapes intentionally mirror the JSON the corresponding
|
|
7
|
+
``--json`` CLI surfaces already emit, so existing consumers keep working when
|
|
8
|
+
they move from shelling out to importing.
|
|
9
|
+
|
|
10
|
+
Construct them with the ``from_*`` classmethods, which accept the loosely-typed
|
|
11
|
+
dicts the underlying record/RPC/transcript helpers produce and pick out the
|
|
12
|
+
fields each shape promises. Unknown extra keys are dropped rather than carried,
|
|
13
|
+
so the public shape stays stable even as internal records gain fields.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import asdict, dataclass, field
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class SessionInfo:
|
|
24
|
+
"""Recorded identity and location returned by client operations.
|
|
25
|
+
|
|
26
|
+
State and transcript may be unknown before startup. Listing reads records;
|
|
27
|
+
use peek or context for live observations.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
short: str
|
|
31
|
+
worker_id: str | None = None
|
|
32
|
+
session_id: str | None = None
|
|
33
|
+
name: str | None = None
|
|
34
|
+
cwd: str | None = None
|
|
35
|
+
state: str | None = None
|
|
36
|
+
transcript: str | None = None
|
|
37
|
+
model: str | None = None
|
|
38
|
+
effort: str | None = None
|
|
39
|
+
last_turn_at: int | None = None
|
|
40
|
+
created_at: int | None = None
|
|
41
|
+
# Cumulative managed-turn accounting from the record (None when the record
|
|
42
|
+
# carries none, e.g. an external session).
|
|
43
|
+
turns: int | None = None
|
|
44
|
+
warning: str | None = None
|
|
45
|
+
|
|
46
|
+
def as_dict(self) -> dict[str, Any]:
|
|
47
|
+
out = asdict(self)
|
|
48
|
+
if out["warning"] is None:
|
|
49
|
+
out.pop("warning")
|
|
50
|
+
if self.worker_id is not None:
|
|
51
|
+
# Full UUID is the only managed target. Do NOT project a short
|
|
52
|
+
# fallback when a historical record lacks verified ownership.
|
|
53
|
+
out["worker"] = f"claude:{self.worker_id}"
|
|
54
|
+
out["engine"] = "claude"
|
|
55
|
+
return out
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class ClaudeContext:
|
|
61
|
+
"""Carried context of one Claude session, the shape ``ccx context --json``
|
|
62
|
+
emits. ``state`` is the session's own lifecycle state; whether a token
|
|
63
|
+
measurement exists is ``reason`` (``None`` when ``tokens_used`` is current,
|
|
64
|
+
else ``pending`` / ``compacting`` / ``just_compacted`` / ``no-signal``).
|
|
65
|
+
Claude reports no window capacity, so ``window_size`` / ``percent_used`` are
|
|
66
|
+
always ``None``; ``as_of_turn_ref`` identifies settled turn context."""
|
|
67
|
+
|
|
68
|
+
short: str
|
|
69
|
+
worker_id: str | None = None
|
|
70
|
+
name: str | None = None
|
|
71
|
+
cwd: str | None = None
|
|
72
|
+
state: str | None = None
|
|
73
|
+
tokens_used: int | None = None
|
|
74
|
+
window_size: int | None = None
|
|
75
|
+
percent_used: float | None = None
|
|
76
|
+
as_of_turn_ref: str | None = None
|
|
77
|
+
as_of_ms: int | None = None
|
|
78
|
+
reason: str | None = None
|
|
79
|
+
engine: str = "claude"
|
|
80
|
+
note: str | None = None
|
|
81
|
+
|
|
82
|
+
def as_dict(self) -> dict[str, Any]:
|
|
83
|
+
out = asdict(self)
|
|
84
|
+
if out.get("note") is None:
|
|
85
|
+
out.pop("note", None)
|
|
86
|
+
if self.worker_id is not None:
|
|
87
|
+
out["worker"] = f"{self.engine}:{self.worker_id}"
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def from_record(cls, rec: dict, block: dict | None,
|
|
92
|
+
*, note: str | None = None) -> "ClaudeContext":
|
|
93
|
+
"""Project a session record plus its merged context block."""
|
|
94
|
+
from ccx.claude.context import public_context
|
|
95
|
+
worker_id = rec.get("workerId")
|
|
96
|
+
return cls(
|
|
97
|
+
short=worker_id[:8] if isinstance(worker_id, str) else rec.get("short"),
|
|
98
|
+
worker_id=worker_id,
|
|
99
|
+
name=rec.get("name"),
|
|
100
|
+
cwd=rec.get("cwd"),
|
|
101
|
+
state=(rec.get("lifecycle") or {}).get("state"),
|
|
102
|
+
note=note,
|
|
103
|
+
**public_context(block, engine="claude"),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True)
|
|
108
|
+
class ClaudeTask:
|
|
109
|
+
"""One read-only Claude Code task from the native structured task list.
|
|
110
|
+
|
|
111
|
+
The on-disk Claude schema is intentionally normalized here. Additive native
|
|
112
|
+
fields stay private to Claude Code; consumers get a small stable shape that
|
|
113
|
+
can survive upstream implementation changes without learning its file layout.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
id: str
|
|
117
|
+
subject: str
|
|
118
|
+
status: str
|
|
119
|
+
active_form: str | None = None
|
|
120
|
+
description: str | None = None
|
|
121
|
+
owner: str | None = None
|
|
122
|
+
blocks: list[str] = field(default_factory=list)
|
|
123
|
+
blocked_by: list[str] = field(default_factory=list)
|
|
124
|
+
|
|
125
|
+
def as_dict(self) -> dict[str, Any]:
|
|
126
|
+
return asdict(self)
|
|
127
|
+
|
|
128
|
+
@classmethod
|
|
129
|
+
def from_dict(cls, obj: dict) -> "ClaudeTask":
|
|
130
|
+
def text(key: str) -> str | None:
|
|
131
|
+
value = obj.get(key)
|
|
132
|
+
return value if isinstance(value, str) else None
|
|
133
|
+
|
|
134
|
+
def strings(key: str) -> list[str]:
|
|
135
|
+
value = obj.get(key)
|
|
136
|
+
if not isinstance(value, list):
|
|
137
|
+
return []
|
|
138
|
+
return [item for item in value if isinstance(item, str)]
|
|
139
|
+
|
|
140
|
+
ident = obj.get("id")
|
|
141
|
+
return cls(
|
|
142
|
+
id=str(ident) if ident is not None else "",
|
|
143
|
+
subject=text("subject") or "",
|
|
144
|
+
status=text("status") or "unknown",
|
|
145
|
+
active_form=text("activeForm"),
|
|
146
|
+
description=text("description"),
|
|
147
|
+
owner=text("owner"),
|
|
148
|
+
blocks=strings("blocks"),
|
|
149
|
+
blocked_by=strings("blockedBy"),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass(frozen=True)
|
|
154
|
+
class Question:
|
|
155
|
+
"""One question inside a pending AskUserQuestion. ``text`` is the exact
|
|
156
|
+
question string (the key `answer()` matches on), ``options`` are the raw
|
|
157
|
+
option dicts (``{label, description}``), and ``labels`` is the convenience
|
|
158
|
+
list of pickable option labels. ``raw`` round-trips the original dict."""
|
|
159
|
+
|
|
160
|
+
text: str | None = None
|
|
161
|
+
header: str | None = None
|
|
162
|
+
multi_select: bool = False
|
|
163
|
+
options: list = field(default_factory=list)
|
|
164
|
+
raw: dict = field(default_factory=dict)
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def labels(self) -> list[str]:
|
|
168
|
+
return [o.get("label") for o in self.options
|
|
169
|
+
if isinstance(o, dict) and o.get("label")]
|
|
170
|
+
|
|
171
|
+
def as_dict(self) -> dict[str, Any]:
|
|
172
|
+
return dict(self.raw)
|
|
173
|
+
|
|
174
|
+
@classmethod
|
|
175
|
+
def from_dict(cls, obj: dict) -> "Question":
|
|
176
|
+
return cls(
|
|
177
|
+
text=obj.get("question"),
|
|
178
|
+
header=obj.get("header"),
|
|
179
|
+
multi_select=bool(obj.get("multiSelect")),
|
|
180
|
+
options=list(obj.get("options") or []),
|
|
181
|
+
raw=dict(obj),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@dataclass(frozen=True)
|
|
186
|
+
class PendingInput:
|
|
187
|
+
"""Typed view of what a WAITING session is blocked on.
|
|
188
|
+
|
|
189
|
+
``kind`` is ``"question"`` for an AskUserQuestion (resolve with
|
|
190
|
+
:meth:`CcxClient.answer`), ``"elicitation"`` for an MCP server's input
|
|
191
|
+
request (resolve with :meth:`CcxClient.respond_elicitation`), or
|
|
192
|
+
``"permission"`` for a tool permission prompt (resolve with
|
|
193
|
+
:meth:`CcxClient.approve` / :meth:`CcxClient.reject`).
|
|
194
|
+
``questions`` is populated for the question kind; ``server``, ``mode``
|
|
195
|
+
(``"form"``/``"url"``), ``url`` and ``schema`` (the server's
|
|
196
|
+
``requestedSchema``) for the elicitation kind. ``raw`` carries the
|
|
197
|
+
host's original ``waitingFor`` preview verbatim, and ``as_dict()`` returns
|
|
198
|
+
it unchanged — so the JSON projection stays byte-compatible with the CLI's
|
|
199
|
+
``--json`` surfaces while attribute access is typed.
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
kind: str
|
|
203
|
+
tool: str | None = None
|
|
204
|
+
request_id: str | None = None
|
|
205
|
+
message: str | None = None
|
|
206
|
+
requested_at: int | None = None
|
|
207
|
+
questions: list = field(default_factory=list)
|
|
208
|
+
raw: dict = field(default_factory=dict)
|
|
209
|
+
server: str | None = None
|
|
210
|
+
mode: str | None = None
|
|
211
|
+
url: str | None = None
|
|
212
|
+
schema: dict | None = None
|
|
213
|
+
|
|
214
|
+
def as_dict(self) -> dict[str, Any]:
|
|
215
|
+
return dict(self.raw)
|
|
216
|
+
|
|
217
|
+
@classmethod
|
|
218
|
+
def from_waiting_for(cls, wf) -> "PendingInput | None":
|
|
219
|
+
"""Build from a host ``waitingFor`` preview dict (None-safe; a
|
|
220
|
+
PendingInput passes through unchanged)."""
|
|
221
|
+
if wf is None or isinstance(wf, PendingInput):
|
|
222
|
+
return wf
|
|
223
|
+
if not isinstance(wf, dict):
|
|
224
|
+
return None
|
|
225
|
+
from ccx.ops.semantics import pending_input_kind # single-sourced kind rule
|
|
226
|
+
tool = wf.get("tool")
|
|
227
|
+
qs = ((wf.get("input") or {}).get("questions")
|
|
228
|
+
if isinstance(wf.get("input"), dict) else None) or []
|
|
229
|
+
schema = wf.get("schema")
|
|
230
|
+
return cls(
|
|
231
|
+
kind=pending_input_kind(wf) or "permission",
|
|
232
|
+
tool=tool,
|
|
233
|
+
request_id=wf.get("id"),
|
|
234
|
+
message=wf.get("message"),
|
|
235
|
+
requested_at=wf.get("requested_at"),
|
|
236
|
+
questions=[Question.from_dict(q) for q in qs if isinstance(q, dict)],
|
|
237
|
+
raw=dict(wf),
|
|
238
|
+
server=wf.get("server"),
|
|
239
|
+
mode=wf.get("mode"),
|
|
240
|
+
url=wf.get("url"),
|
|
241
|
+
schema=schema if isinstance(schema, dict) else None,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@dataclass(frozen=True)
|
|
246
|
+
class SessionStatus:
|
|
247
|
+
"""Bounded, orchestration-only status projection — the shape ``peek
|
|
248
|
+
--status`` emits. Scalars only: no transcript text. ``error`` is set (e.g.
|
|
249
|
+
``"not_found"``) instead of raising for a vanished session, so a poller never
|
|
250
|
+
has to special-case one."""
|
|
251
|
+
|
|
252
|
+
short: str
|
|
253
|
+
worker_id: str | None = None
|
|
254
|
+
engine: str = "claude"
|
|
255
|
+
state: str | None = None
|
|
256
|
+
turn: int | None = None
|
|
257
|
+
last_turn_age_s: int | None = None
|
|
258
|
+
host_status: str | None = None
|
|
259
|
+
# Typed pending-input view (PendingInput) when the session is waiting;
|
|
260
|
+
# as_dict() projects it back to the raw host preview for CLI parity.
|
|
261
|
+
waiting_for: PendingInput | None = None
|
|
262
|
+
mode: str | None = None
|
|
263
|
+
needs: str | None = None
|
|
264
|
+
context: dict | None = None
|
|
265
|
+
# Stop-time transcript summary, recorded without a read-time task scan.
|
|
266
|
+
background_task_summary: dict | None = None
|
|
267
|
+
error: str | None = None
|
|
268
|
+
|
|
269
|
+
def as_dict(self) -> dict[str, Any]:
|
|
270
|
+
# Drop keys the projection didn't include, matching the CLI's output
|
|
271
|
+
# (mode/needs only appear for externals; error only on failure).
|
|
272
|
+
out = asdict(self)
|
|
273
|
+
if self.worker_id is not None:
|
|
274
|
+
out["worker"] = f"{self.engine}:{self.worker_id}"
|
|
275
|
+
else:
|
|
276
|
+
out.pop("engine", None)
|
|
277
|
+
out.pop("worker_id", None)
|
|
278
|
+
if isinstance(self.waiting_for, PendingInput):
|
|
279
|
+
# asdict() recursed into the dataclass; the public JSON shape is
|
|
280
|
+
# the raw host preview, byte-compatible with `peek --status --json`.
|
|
281
|
+
out["waiting_for"] = self.waiting_for.as_dict()
|
|
282
|
+
for k in ("mode", "needs", "error", "context", "background_task_summary"):
|
|
283
|
+
if out.get(k) is None:
|
|
284
|
+
out.pop(k, None)
|
|
285
|
+
return out
|
|
286
|
+
|
|
287
|
+
@property
|
|
288
|
+
def is_waiting(self) -> bool:
|
|
289
|
+
return bool(self.waiting_for)
|
|
290
|
+
|
|
291
|
+
@classmethod
|
|
292
|
+
def from_projection(cls, proj: dict) -> "SessionStatus":
|
|
293
|
+
return cls(
|
|
294
|
+
short=proj.get("short"),
|
|
295
|
+
worker_id=proj.get("worker_id"),
|
|
296
|
+
engine=proj.get("engine") or "claude",
|
|
297
|
+
state=proj.get("state"),
|
|
298
|
+
turn=proj.get("turn"),
|
|
299
|
+
last_turn_age_s=proj.get("last_turn_age_s"),
|
|
300
|
+
host_status=proj.get("host_status"),
|
|
301
|
+
waiting_for=PendingInput.from_waiting_for(proj.get("waiting_for")),
|
|
302
|
+
mode=proj.get("mode"),
|
|
303
|
+
needs=proj.get("needs"),
|
|
304
|
+
context=proj.get("context"),
|
|
305
|
+
background_task_summary=proj.get("background_task_summary"),
|
|
306
|
+
error=proj.get("error"),
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@dataclass(frozen=True)
|
|
311
|
+
class Report:
|
|
312
|
+
"""The last assistant turn's text for a session — the shape ``report
|
|
313
|
+
--json`` emits per session."""
|
|
314
|
+
|
|
315
|
+
short: str
|
|
316
|
+
turn: int | None
|
|
317
|
+
text: str
|
|
318
|
+
transcript: str | None = None
|
|
319
|
+
context: dict | None = None
|
|
320
|
+
# Transcript-derived background tasks for the session (see
|
|
321
|
+
# ``ccx.transcript.extract_background_tasks``). Additive and read-only: an
|
|
322
|
+
# open task means a continuation may still fire, not that the worker is
|
|
323
|
+
# unfinished. Empty list / zero when none.
|
|
324
|
+
background_tasks: list = field(default_factory=list)
|
|
325
|
+
background_task_count: int = 0
|
|
326
|
+
open_background_task_count: int = 0
|
|
327
|
+
active_background_task_count: int = 0
|
|
328
|
+
stale_background_task_count: int = 0
|
|
329
|
+
running_background_task_count: int = 0
|
|
330
|
+
background_tasks_truncated: bool = False
|
|
331
|
+
background_task_limit: int | None = None
|
|
332
|
+
# Advisory string surfaced when active/stale background task state needs a
|
|
333
|
+
# caller-facing hint (see ccx.commands._background_task_facts).
|
|
334
|
+
background_task_hint: str | None = None
|
|
335
|
+
# Full identity is authoritative; legacy short remains display-only.
|
|
336
|
+
worker_id: str | None = None
|
|
337
|
+
engine: str = "claude"
|
|
338
|
+
turn_ref: str | None = None
|
|
339
|
+
delivery_id: str | None = None
|
|
340
|
+
# Set when a newer turn is running or ended without a reply.
|
|
341
|
+
note: str | None = None
|
|
342
|
+
|
|
343
|
+
def as_dict(self) -> dict[str, Any]:
|
|
344
|
+
out = asdict(self)
|
|
345
|
+
if self.worker_id is not None:
|
|
346
|
+
out["worker"] = f"{self.engine}:{self.worker_id}"
|
|
347
|
+
return out
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@dataclass(frozen=True)
|
|
351
|
+
class SendResult:
|
|
352
|
+
"""Outcome of a ``send`` / ``answer`` / ``new(wait=True)`` call.
|
|
353
|
+
|
|
354
|
+
For a synchronous (waited) delivery ``reply`` holds the assistant's last
|
|
355
|
+
turn and ``queued`` is False. An accepted strict send with ``wait=False``
|
|
356
|
+
started its turn: ``queued`` is False and ``reply`` is None.
|
|
357
|
+
Brokered input replies may still set ``queued`` while a native turn resumes.
|
|
358
|
+
"""
|
|
359
|
+
|
|
360
|
+
short: str
|
|
361
|
+
reply: str | None = None
|
|
362
|
+
transcript: str | None = None
|
|
363
|
+
state: str | None = None
|
|
364
|
+
queued: bool = False
|
|
365
|
+
# Original full worker/delivery identity, additive to original result.
|
|
366
|
+
# A caller can reconcile an unknown send without guessing native source.
|
|
367
|
+
worker_id: str | None = None
|
|
368
|
+
engine: str = "claude"
|
|
369
|
+
delivery_id: str | None = None
|
|
370
|
+
delivery_status: str | None = None
|
|
371
|
+
settled: bool = False
|
|
372
|
+
turn_ref: str | None = None
|
|
373
|
+
|
|
374
|
+
def as_dict(self) -> dict[str, Any]:
|
|
375
|
+
out = asdict(self)
|
|
376
|
+
if self.worker_id is not None:
|
|
377
|
+
out["worker"] = f"{self.engine}:{self.worker_id}"
|
|
378
|
+
return out
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
@dataclass(frozen=True)
|
|
382
|
+
class CompactResult:
|
|
383
|
+
"""Outcome of a native Claude Code ``/compact`` request."""
|
|
384
|
+
|
|
385
|
+
short: str
|
|
386
|
+
state: str | None = None
|
|
387
|
+
compacted: bool = False
|
|
388
|
+
message: str | None = None
|
|
389
|
+
pre_tokens: int | None = None
|
|
390
|
+
post_tokens: int | None = None
|
|
391
|
+
duration_ms: int | None = None
|
|
392
|
+
|
|
393
|
+
def as_dict(self) -> dict[str, Any]:
|
|
394
|
+
return asdict(self)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
@dataclass(frozen=True)
|
|
398
|
+
class CompactOperationResult(CompactResult):
|
|
399
|
+
"""Durable compact result with operation identity and native evidence.
|
|
400
|
+
|
|
401
|
+
Subclassing preserves the frozen legacy :class:`CompactResult` schema for
|
|
402
|
+
consumers that construct or validate it directly, while new compact calls
|
|
403
|
+
can expose the production operation protocol additively.
|
|
404
|
+
"""
|
|
405
|
+
|
|
406
|
+
operation_id: str | None = None
|
|
407
|
+
operation_status: str | None = None
|
|
408
|
+
evidence_uuid: str | None = None
|
|
409
|
+
reason: str | None = None
|
|
410
|
+
worker_id: str | None = None
|
|
411
|
+
|
|
412
|
+
def as_dict(self) -> dict[str, Any]:
|
|
413
|
+
out = asdict(self)
|
|
414
|
+
if self.worker_id is not None:
|
|
415
|
+
out["worker"] = f"claude:{self.worker_id}"
|
|
416
|
+
return out
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
@dataclass(frozen=True)
|
|
420
|
+
class SemanticDeliveryResult:
|
|
421
|
+
"""Outcome of caller-identified semantic delivery.
|
|
422
|
+
|
|
423
|
+
Route may be known before final placement. turn_ref is non-null only after
|
|
424
|
+
placement/start identity is proven.
|
|
425
|
+
"""
|
|
426
|
+
|
|
427
|
+
short: str
|
|
428
|
+
delivery_id: str
|
|
429
|
+
status: str
|
|
430
|
+
accepted: bool = False
|
|
431
|
+
owned: bool = False
|
|
432
|
+
recommended_action: str | None = None
|
|
433
|
+
route: str | None = None
|
|
434
|
+
placement: str | None = None
|
|
435
|
+
target_turn_ref: str | None = None
|
|
436
|
+
turn_ref: str | None = None
|
|
437
|
+
managed_turn_ordinal: int | None = None
|
|
438
|
+
state: str | None = None
|
|
439
|
+
attempts: int = 0
|
|
440
|
+
maybe_written: bool = False
|
|
441
|
+
evidence: str | None = None
|
|
442
|
+
evidence_uuid: str | None = None
|
|
443
|
+
reason: str | None = None
|
|
444
|
+
worker_id: str | None = None
|
|
445
|
+
engine: str = "claude"
|
|
446
|
+
settled: bool = False
|
|
447
|
+
|
|
448
|
+
def as_dict(self) -> dict[str, Any]:
|
|
449
|
+
out = asdict(self)
|
|
450
|
+
if self.worker_id is not None:
|
|
451
|
+
out["worker"] = f"{self.engine}:{self.worker_id}"
|
|
452
|
+
return out
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
@dataclass(frozen=True)
|
|
456
|
+
class SteerResult:
|
|
457
|
+
"""Outcome of a durable ``steer`` on a busy managed worker.
|
|
458
|
+
|
|
459
|
+
``accepted`` is True once the prompt is durably owned by CCX — that is the
|
|
460
|
+
delivery guarantee, and it does not weaken when the observation window
|
|
461
|
+
elapses. ``status`` speaks one placement vocabulary:
|
|
462
|
+
|
|
463
|
+
``"accepted-pending"``
|
|
464
|
+
CCX owns the prompt; Claude has not visibly placed it in a turn yet.
|
|
465
|
+
Never a reason to resend.
|
|
466
|
+
``"consumed-current-turn"``
|
|
467
|
+
The running turn saw it (native ``queued_command`` attachment).
|
|
468
|
+
``"started-next-turn"``
|
|
469
|
+
It runs as the next turn (ordinary user event / dequeue).
|
|
470
|
+
``"unknown"``
|
|
471
|
+
Genuinely indeterminate transport or evidence.
|
|
472
|
+
|
|
473
|
+
``timed_out`` means only that stronger evidence had not appeared yet;
|
|
474
|
+
``recommended_action`` is then ``"wait"``. Retrying with the same
|
|
475
|
+
``delivery_id`` is idempotent and never pastes twice.
|
|
476
|
+
"""
|
|
477
|
+
|
|
478
|
+
short: str
|
|
479
|
+
delivery_id: str
|
|
480
|
+
accepted: bool = False
|
|
481
|
+
status: str | None = None
|
|
482
|
+
timed_out: bool = False
|
|
483
|
+
state: str | None = None
|
|
484
|
+
pending: bool = False
|
|
485
|
+
evidence: str | None = None
|
|
486
|
+
evidence_uuid: str | None = None
|
|
487
|
+
attempts: int | None = None
|
|
488
|
+
reason: str | None = None
|
|
489
|
+
recommended_action: str | None = None
|
|
490
|
+
worker_id: str | None = None
|
|
491
|
+
engine: str = "claude"
|
|
492
|
+
|
|
493
|
+
def as_dict(self) -> dict[str, Any]:
|
|
494
|
+
result = asdict(self)
|
|
495
|
+
if self.worker_id is not None:
|
|
496
|
+
result["worker"] = f"{self.engine}:{self.worker_id}"
|
|
497
|
+
return result
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
@dataclass(frozen=True)
|
|
501
|
+
class WaitResult:
|
|
502
|
+
"""Resolution of a ``wait`` call: the stable state a session settled into.
|
|
503
|
+
|
|
504
|
+
``status`` is the coarse verdict (``idle``/``waiting``/``dead``/
|
|
505
|
+
``budget-stopped``/``gone``/``timeout``/…), ``mode`` distinguishes managed vs
|
|
506
|
+
external, and ``reason`` is a human sentence. ``wait_kind`` is set when
|
|
507
|
+
``status`` is ``waiting`` (``permission`` or an AskUserQuestion).
|
|
508
|
+
``timeout`` means a numeric ``timeout=`` elapsed before the session
|
|
509
|
+
stabilized; ``state`` then carries the last observed lifecycle state.
|
|
510
|
+
"""
|
|
511
|
+
|
|
512
|
+
status: str
|
|
513
|
+
mode: str | None = None
|
|
514
|
+
state: str | None = None
|
|
515
|
+
reason: str | None = None
|
|
516
|
+
wait_kind: str | None = None
|
|
517
|
+
# Typed pending-input view when status is "waiting" (managed sessions);
|
|
518
|
+
# None when the verdict carries no preview. as_dict() projects it raw.
|
|
519
|
+
waiting_for: PendingInput | None = None
|
|
520
|
+
# `--next-turn` waits: ``wait_mode`` is "next-turn" when the call blocked for
|
|
521
|
+
# a future turn/revival (else None for a plain wait). ``next_turn_observed``
|
|
522
|
+
# is True once that future turn — or, for managed sessions, a new input
|
|
523
|
+
# gate or a settlement newer than ``after_turn`` — was seen.
|
|
524
|
+
# ``next_turn_event`` carries the managed host event when known: a terminal
|
|
525
|
+
# (`turn_complete` or additive `turn_settled`, liveness-only settlement
|
|
526
|
+
# preserving `outcome="unknown"`) or a gate event (`state`→waiting,
|
|
527
|
+
# `permission_requested`, `elicitation_requested`), which is NOT
|
|
528
|
+
# settlement. None on the `after_turn` comparison fast path and for
|
|
529
|
+
# native-bg, which has no such event.
|
|
530
|
+
wait_mode: str | None = None
|
|
531
|
+
next_turn_observed: bool = False
|
|
532
|
+
next_turn_event: dict | None = None
|
|
533
|
+
# Managed sessions: the exact latest settled turn (``turn_ref``,
|
|
534
|
+
# ``turn_id`` (None for Claude), ``outcome``, ``completed_at``) — the
|
|
535
|
+
# checkpoint to pass back as ``after_turn`` — and the host's active turn
|
|
536
|
+
# ref when known. None when unknown or not a managed session.
|
|
537
|
+
last_settled_turn: dict | None = None
|
|
538
|
+
active_turn_ref: str | None = None
|
|
539
|
+
# Background-task facts for the session `wait` reported on. `wait` reports
|
|
540
|
+
# them but never blocks on them — an open task does not extend the wait.
|
|
541
|
+
background_tasks: list = field(default_factory=list)
|
|
542
|
+
background_task_count: int = 0
|
|
543
|
+
open_background_task_count: int = 0
|
|
544
|
+
active_background_task_count: int = 0
|
|
545
|
+
stale_background_task_count: int = 0
|
|
546
|
+
running_background_task_count: int = 0
|
|
547
|
+
background_tasks_truncated: bool = False
|
|
548
|
+
background_task_limit: int | None = None
|
|
549
|
+
# Advisory string surfaced when active/stale background task state needs a hint.
|
|
550
|
+
background_task_hint: str | None = None
|
|
551
|
+
# Claude's classification when the session stopped on an API error, else
|
|
552
|
+
# None. `reason` states the same thing in prose; this is the half a caller
|
|
553
|
+
# can branch on — notably `rate_limited`, which must never be indistinguish-
|
|
554
|
+
# able from an ordinary crash. Managed Claude sessions only: Codex and
|
|
555
|
+
# native-bg carry no equivalent, and inventing one would be a lie.
|
|
556
|
+
api_error: dict | None = None
|
|
557
|
+
|
|
558
|
+
def as_dict(self) -> dict[str, Any]:
|
|
559
|
+
out = asdict(self)
|
|
560
|
+
if isinstance(self.waiting_for, PendingInput):
|
|
561
|
+
out["waiting_for"] = self.waiting_for.as_dict()
|
|
562
|
+
elif out.get("waiting_for") is None:
|
|
563
|
+
out.pop("waiting_for", None)
|
|
564
|
+
return out
|
|
565
|
+
|
|
566
|
+
@classmethod
|
|
567
|
+
def from_verdict(cls, verdict: dict) -> "WaitResult":
|
|
568
|
+
return cls(
|
|
569
|
+
status=verdict.get("status"),
|
|
570
|
+
mode=verdict.get("mode"),
|
|
571
|
+
state=verdict.get("state"),
|
|
572
|
+
reason=verdict.get("reason"),
|
|
573
|
+
wait_kind=verdict.get("wait_kind"),
|
|
574
|
+
waiting_for=PendingInput.from_waiting_for(verdict.get("waiting_for")),
|
|
575
|
+
wait_mode=verdict.get("wait_mode"),
|
|
576
|
+
next_turn_observed=verdict.get("next_turn_observed", False),
|
|
577
|
+
next_turn_event=verdict.get("next_turn_event"),
|
|
578
|
+
last_settled_turn=verdict.get("last_settled_turn"),
|
|
579
|
+
active_turn_ref=verdict.get("active_turn_ref"),
|
|
580
|
+
background_tasks=verdict.get("background_tasks") or [],
|
|
581
|
+
background_task_count=verdict.get("background_task_count", 0),
|
|
582
|
+
open_background_task_count=verdict.get("open_background_task_count", 0),
|
|
583
|
+
active_background_task_count=verdict.get("active_background_task_count", 0),
|
|
584
|
+
stale_background_task_count=verdict.get("stale_background_task_count", 0),
|
|
585
|
+
running_background_task_count=verdict.get("running_background_task_count", 0),
|
|
586
|
+
background_tasks_truncated=verdict.get("background_tasks_truncated", False),
|
|
587
|
+
background_task_limit=verdict.get("background_task_limit"),
|
|
588
|
+
background_task_hint=verdict.get("background_task_hint"),
|
|
589
|
+
api_error=verdict.get("apiError"),
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
@dataclass(frozen=True)
|
|
594
|
+
class Event:
|
|
595
|
+
"""One stamped event from a session's broadcast stream.
|
|
596
|
+
|
|
597
|
+
Every host broadcast carries an envelope: ``type`` (the event kind, e.g.
|
|
598
|
+
``state`` / ``tool_started`` / ``subagent_finished``), ``seq`` (a per-host
|
|
599
|
+
monotonic counter), ``short``, ``epoch`` (identifies the host life — changes
|
|
600
|
+
on respawn, so ``seq`` numbering restarts with it), ``ts`` (broadcast
|
|
601
|
+
wall-clock seconds), and ``v`` (schema version). The full stamped object —
|
|
602
|
+
envelope plus any event-specific fields — is kept in ``raw``; ``as_dict()``
|
|
603
|
+
returns it verbatim so it round-trips through ``json.dumps``.
|
|
604
|
+
"""
|
|
605
|
+
|
|
606
|
+
type: str | None
|
|
607
|
+
seq: int | None = None
|
|
608
|
+
short: str | None = None
|
|
609
|
+
epoch: str | None = None
|
|
610
|
+
ts: float | None = None
|
|
611
|
+
v: int | None = None
|
|
612
|
+
raw: dict = field(default_factory=dict)
|
|
613
|
+
|
|
614
|
+
def as_dict(self) -> dict[str, Any]:
|
|
615
|
+
return dict(self.raw)
|
|
616
|
+
|
|
617
|
+
@property
|
|
618
|
+
def cursor(self) -> tuple[str | None, int | None]:
|
|
619
|
+
"""The ``(epoch, seq)`` pair identifying this event's position. Pass it
|
|
620
|
+
back as ``events(since_epoch=…, since_seq=…)`` to page forward across
|
|
621
|
+
host respawns (``seq`` resets per epoch, so epoch is required)."""
|
|
622
|
+
return (self.epoch, self.seq)
|
|
623
|
+
|
|
624
|
+
@classmethod
|
|
625
|
+
def from_dict(cls, obj: dict) -> "Event":
|
|
626
|
+
return cls(
|
|
627
|
+
type=obj.get("type"),
|
|
628
|
+
seq=obj.get("seq"),
|
|
629
|
+
short=obj.get("short"),
|
|
630
|
+
epoch=obj.get("epoch"),
|
|
631
|
+
ts=obj.get("ts"),
|
|
632
|
+
v=obj.get("v"),
|
|
633
|
+
raw=dict(obj),
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
@dataclass(frozen=True)
|
|
638
|
+
class Usage:
|
|
639
|
+
"""Live quota utilization plus provider-normalized session-reset status.
|
|
640
|
+
|
|
641
|
+
``summary`` is the compact quota-only projection; ``raw`` is the full OAuth
|
|
642
|
+
payload. ``five_hour_reset`` is deliberately normalized by ccx so callers do
|
|
643
|
+
not need to know Anthropic's private experiment/program names.
|
|
644
|
+
"""
|
|
645
|
+
|
|
646
|
+
summary: dict = field(default_factory=dict)
|
|
647
|
+
raw: dict = field(default_factory=dict)
|
|
648
|
+
five_hour_reset: dict | None = None
|
|
649
|
+
|
|
650
|
+
def as_dict(self) -> dict[str, Any]:
|
|
651
|
+
return {
|
|
652
|
+
"summary": dict(self.summary),
|
|
653
|
+
"raw": dict(self.raw),
|
|
654
|
+
"five_hour_reset": (
|
|
655
|
+
dict(self.five_hour_reset) if self.five_hour_reset is not None else None
|
|
656
|
+
),
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
def utilization(self, metric: str) -> float | None:
|
|
660
|
+
"""Convenience for a directly named quota such as ``five_hour`` or
|
|
661
|
+
``seven_day``. Dynamic per-model quotas live in ``model_quotas``."""
|
|
662
|
+
block = self.summary.get(metric) or self.raw.get(metric)
|
|
663
|
+
if isinstance(block, dict):
|
|
664
|
+
return block.get("utilization")
|
|
665
|
+
return None
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
@dataclass(frozen=True)
|
|
669
|
+
class FiveHourReset:
|
|
670
|
+
"""Outcome of one provider-gated 5-hour session-limit reset attempt."""
|
|
671
|
+
|
|
672
|
+
result: str
|
|
673
|
+
status: dict | None = None
|
|
674
|
+
next_available_at: str | None = None
|
|
675
|
+
weekly_resets_at: str | None = None
|
|
676
|
+
refreshed: bool = False
|
|
677
|
+
|
|
678
|
+
@property
|
|
679
|
+
def succeeded(self) -> bool:
|
|
680
|
+
return self.result == "reset"
|
|
681
|
+
|
|
682
|
+
def as_dict(self) -> dict[str, Any]:
|
|
683
|
+
return {
|
|
684
|
+
"result": self.result,
|
|
685
|
+
"status": dict(self.status) if self.status is not None else None,
|
|
686
|
+
"next_available_at": self.next_available_at,
|
|
687
|
+
"weekly_resets_at": self.weekly_resets_at,
|
|
688
|
+
"refreshed": self.refreshed,
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
__all__ = [
|
|
693
|
+
"SessionInfo",
|
|
694
|
+
"ClaudeContext",
|
|
695
|
+
"SessionStatus",
|
|
696
|
+
"PendingInput",
|
|
697
|
+
"Question",
|
|
698
|
+
"Report",
|
|
699
|
+
"SendResult",
|
|
700
|
+
"SteerResult",
|
|
701
|
+
"WaitResult",
|
|
702
|
+
"Event",
|
|
703
|
+
"Usage",
|
|
704
|
+
"FiveHourReset",
|
|
705
|
+
]
|