ccx-native 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +241 -2
  3. package/bin/ccx +56 -0
  4. package/ccx/__init__.py +163 -0
  5. package/ccx/__main__.py +28 -0
  6. package/ccx/claude/__init__.py +0 -0
  7. package/ccx/claude/analytics.py +466 -0
  8. package/ccx/claude/attach.py +762 -0
  9. package/ccx/claude/context.py +465 -0
  10. package/ccx/claude/delivery.py +806 -0
  11. package/ccx/claude/external.py +253 -0
  12. package/ccx/claude/host/__init__.py +0 -0
  13. package/ccx/claude/host/events.py +297 -0
  14. package/ccx/claude/host/hooks.py +438 -0
  15. package/ccx/claude/host/pty.py +250 -0
  16. package/ccx/claude/host/session.py +5184 -0
  17. package/ccx/claude/host/subagents.py +169 -0
  18. package/ccx/claude/host/successor.py +267 -0
  19. package/ccx/claude/managed_turns.py +240 -0
  20. package/ccx/claude/oauth.py +176 -0
  21. package/ccx/claude/settings.py +339 -0
  22. package/ccx/claude/spawn.py +557 -0
  23. package/ccx/claude/tasks.py +72 -0
  24. package/ccx/claude/telemetry.py +282 -0
  25. package/ccx/claude/transcript.py +2281 -0
  26. package/ccx/claude/usage.py +837 -0
  27. package/ccx/claude/usage_control.py +152 -0
  28. package/ccx/cli/__init__.py +0 -0
  29. package/ccx/cli/codex.py +1009 -0
  30. package/ccx/cli/commands/__init__.py +0 -0
  31. package/ccx/cli/commands/_common.py +407 -0
  32. package/ccx/cli/commands/control.py +161 -0
  33. package/ccx/cli/commands/create.py +669 -0
  34. package/ccx/cli/commands/read.py +1020 -0
  35. package/ccx/cli/commands/turns.py +1081 -0
  36. package/ccx/cli/controller.py +757 -0
  37. package/ccx/cli/main.py +301 -0
  38. package/ccx/cli/parser.py +1324 -0
  39. package/ccx/cli/skills.py +215 -0
  40. package/ccx/cli/statusline.py +160 -0
  41. package/ccx/client.py +1900 -0
  42. package/ccx/codex/__init__.py +0 -0
  43. package/ccx/codex/attach_tty.py +267 -0
  44. package/ccx/codex/client.py +732 -0
  45. package/ccx/codex/completion.py +121 -0
  46. package/ccx/codex/control.py +288 -0
  47. package/ccx/codex/daemon/__init__.py +0 -0
  48. package/ccx/codex/daemon/__main__.py +4 -0
  49. package/ccx/codex/daemon/daemon.py +1556 -0
  50. package/ccx/codex/daemon/models.py +140 -0
  51. package/ccx/codex/daemon/native.py +1590 -0
  52. package/ccx/codex/daemon/release.py +367 -0
  53. package/ccx/codex/daemon/worker.py +2396 -0
  54. package/ccx/codex/delivery.py +758 -0
  55. package/ccx/codex/delivery_recovery.py +232 -0
  56. package/ccx/codex/evidence.py +827 -0
  57. package/ccx/codex/history.py +158 -0
  58. package/ccx/codex/notify_history.py +140 -0
  59. package/ccx/codex/ops.py +666 -0
  60. package/ccx/codex/paths.py +70 -0
  61. package/ccx/codex/protocol.py +805 -0
  62. package/ccx/codex/provenance.py +105 -0
  63. package/ccx/codex/read.py +29 -0
  64. package/ccx/codex/records.py +51 -0
  65. package/ccx/codex/reports.py +177 -0
  66. package/ccx/codex/runtime.py +1412 -0
  67. package/ccx/codex/settle.py +363 -0
  68. package/ccx/codex/telemetry.py +792 -0
  69. package/ccx/codex/types.py +659 -0
  70. package/ccx/codex/worker_environment.py +25 -0
  71. package/ccx/constants.py +288 -0
  72. package/ccx/controllers/__init__.py +0 -0
  73. package/ccx/controllers/controller.py +1449 -0
  74. package/ccx/controllers/live_vars.py +1706 -0
  75. package/ccx/controllers/live_vars_runner.py +216 -0
  76. package/ccx/controllers/notify.py +213 -0
  77. package/ccx/controllers/orchestrator.py +193 -0
  78. package/ccx/controllers/policy.py +57 -0
  79. package/ccx/controllers/store.py +681 -0
  80. package/ccx/errors.py +634 -0
  81. package/ccx/notify/__init__.py +0 -0
  82. package/ccx/notify/__main__.py +5 -0
  83. package/ccx/notify/client.py +116 -0
  84. package/ccx/notify/daemon.py +480 -0
  85. package/ccx/notify/deliver.py +304 -0
  86. package/ccx/notify/paths.py +51 -0
  87. package/ccx/notify/registry.py +649 -0
  88. package/ccx/notify/select.py +750 -0
  89. package/ccx/notify/settle.py +684 -0
  90. package/ccx/ops/__init__.py +0 -0
  91. package/ccx/ops/background.py +115 -0
  92. package/ccx/ops/cancel.py +289 -0
  93. package/ccx/ops/codex_import.py +96 -0
  94. package/ccx/ops/common.py +397 -0
  95. package/ccx/ops/compact.py +337 -0
  96. package/ccx/ops/compact_ops.py +550 -0
  97. package/ccx/ops/context_events.py +48 -0
  98. package/ccx/ops/conversation.py +1540 -0
  99. package/ccx/ops/conversation_paging.py +1338 -0
  100. package/ccx/ops/create.py +56 -0
  101. package/ccx/ops/creation.py +952 -0
  102. package/ccx/ops/creation_steps.py +579 -0
  103. package/ccx/ops/event_feed.py +710 -0
  104. package/ccx/ops/forking.py +711 -0
  105. package/ccx/ops/input.py +556 -0
  106. package/ccx/ops/maintenance.py +295 -0
  107. package/ccx/ops/report.py +770 -0
  108. package/ccx/ops/semantics.py +490 -0
  109. package/ccx/ops/send.py +961 -0
  110. package/ccx/ops/steer_ops.py +867 -0
  111. package/ccx/ops/terminate.py +383 -0
  112. package/ccx/ops/turn_reports.py +306 -0
  113. package/ccx/ops/wait.py +762 -0
  114. package/ccx/ops/worker_events.py +636 -0
  115. package/ccx/rpc/__init__.py +0 -0
  116. package/ccx/rpc/local_rpc.py +1602 -0
  117. package/ccx/rpc/rpc.py +559 -0
  118. package/ccx/rpc/serve.py +374 -0
  119. package/ccx/rpc/transport.py +409 -0
  120. package/ccx/rpc/view.py +125 -0
  121. package/ccx/store/__init__.py +0 -0
  122. package/ccx/store/discovery.py +170 -0
  123. package/ccx/store/identity.py +105 -0
  124. package/ccx/store/journal_io.py +79 -0
  125. package/ccx/store/liveness.py +194 -0
  126. package/ccx/store/paths.py +254 -0
  127. package/ccx/store/record_cache.py +40 -0
  128. package/ccx/store/records.py +309 -0
  129. package/ccx/store/root.py +804 -0
  130. package/ccx/store/runtime_presence.py +137 -0
  131. package/ccx/store/store.py +2332 -0
  132. package/ccx/store/worker_state.py +86 -0
  133. package/ccx/system/__init__.py +0 -0
  134. package/ccx/system/bounded.py +296 -0
  135. package/ccx/system/cwd_paths.py +25 -0
  136. package/ccx/system/engine_binary.py +59 -0
  137. package/ccx/system/executable.py +50 -0
  138. package/ccx/system/oslayer.py +667 -0
  139. package/ccx/system/pipes.py +46 -0
  140. package/ccx/system/sanitize.py +43 -0
  141. package/ccx/types.py +705 -0
  142. package/ccx/version.py +29 -0
  143. package/ccx/workers.py +2081 -0
  144. package/ccx.py +9 -0
  145. package/package.json +40 -4
  146. package/pyproject.toml +87 -0
  147. package/skills/ccx/SKILL.md +155 -0
  148. package/skills/ccx/references/controllers.md +244 -0
  149. package/skills/ccx/references/troubleshooting.md +94 -0
  150. package/skills/ccx/references/variables.md +62 -0
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
+ ]