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/errors.py ADDED
@@ -0,0 +1,634 @@
1
+ """Typed exceptions for the public CCX Python clients.
2
+
3
+ The CLI signals failure by writing to stderr and calling ``sys.exit`` with one
4
+ of the integer codes named in :mod:`ccx.constants`. A library cannot do that —
5
+ exiting the host process from inside a function call is hostile to anything that
6
+ imports it. So every public client method that its CLI would have exited from
7
+ raises one of the exceptions below instead.
8
+
9
+ Each exception carries the CLI ``exit_code`` it corresponds to, so an
10
+ orchestrator that wants the old numeric semantics (or a thin CLI adapter) can
11
+ recover them without re-deriving the mapping:
12
+
13
+ try:
14
+ client.send(short, "go")
15
+ except CcxError as e:
16
+ sys.exit(e.exit_code)
17
+
18
+ The mapping mirrors the exit-code table in ``ccx/constants.py`` exactly; see
19
+ that file for what each code means.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from .constants import (
25
+ BUDGET_EXIT,
26
+ EXIT_ERROR,
27
+ EXIT_NOT_FOUND,
28
+ EXIT_API_TRANSIENT,
29
+ EXIT_RATE_LIMITED,
30
+ EXIT_REJECTED,
31
+ EXIT_TURN_INCOMPLETE,
32
+ EXIT_UNAVAILABLE,
33
+ EXIT_WAITING,
34
+ )
35
+
36
+
37
+ class CcxError(Exception):
38
+ """Base class for every error the public client raises.
39
+
40
+ ``exit_code`` is the CLI exit code this condition maps to (see
41
+ ``ccx/constants.py``). ``short`` is the session id involved, when known.
42
+ Subclasses may attach extra structured context (e.g. ``waiting_for`` on
43
+ :class:`SessionWaiting`) so callers never have to parse the message string.
44
+ """
45
+
46
+ exit_code: int = EXIT_ERROR
47
+
48
+ def __init__(self, message: str = "", *, short: str | None = None):
49
+ super().__init__(message)
50
+ self.short = short
51
+
52
+
53
+ class InvalidRequest(CcxError):
54
+ """A call's arguments were invalid or unusable (bad prompt source, a usage
55
+ cap with no prompt to attach to, an unreadable cwd, …). Maps to the CLI's
56
+ general-failure code."""
57
+
58
+ exit_code = EXIT_ERROR
59
+
60
+
61
+ class AmbiguousWorker(InvalidRequest):
62
+ """Discovery matched multiple exact managed owners; no target was chosen."""
63
+
64
+ def __init__(self, query: str, candidates: tuple[str, ...] | list[str]):
65
+ self.candidates = tuple(sorted(candidates))
66
+ assert len(self.candidates) >= 2
67
+ super().__init__(
68
+ f"ambiguous managed worker prefix {query!r}; candidates: "
69
+ + ", ".join(self.candidates)
70
+ + "; use a longer canonical prefix or full UUID"
71
+ )
72
+
73
+
74
+ class OperationConflict(InvalidRequest):
75
+ """An immutable caller-owned identity conflicts with durable ownership."""
76
+
77
+ exit_code = EXIT_REJECTED
78
+
79
+ def __init__(self, message="", *, short=None, reason=None, worker_id=None):
80
+ super().__init__(message, short=short)
81
+ self.reason = reason
82
+ self.worker_id = worker_id
83
+
84
+
85
+ class CreatePartialFailure(CcxError):
86
+ """A durable worker identity is claimed, but creation is incomplete.
87
+
88
+ In phase="record", the claim may exist without a usable worker record
89
+ or native activation. Later phases may follow a live native worker. In
90
+ either case the caller must retain and reconcile the full worker_id.
91
+ """
92
+
93
+ exit_code = EXIT_UNAVAILABLE
94
+
95
+ def __init__(self, message="", *, info, phase, delivery_id=None,
96
+ delivery_status=None, cause=None, notification_intent=False):
97
+ super().__init__(message, short=info["short"])
98
+ self.worker_id = info["worker_id"]
99
+ self.worker = info["worker"]
100
+ self.engine = info["engine"]
101
+ self.phase = phase
102
+ self.delivery_id = delivery_id
103
+ self.delivery_status = delivery_status
104
+ self.cause = cause
105
+ self.notification_intent = notification_intent
106
+ self.info = info
107
+ if isinstance(cause, CcxError):
108
+ self.exit_code = cause.exit_code
109
+
110
+
111
+ class CursorInvalidated(InvalidRequest):
112
+ """An opaque paging cursor no longer belongs to the current native source."""
113
+
114
+ exit_code = EXIT_REJECTED
115
+
116
+
117
+ class SessionNotFound(CcxError):
118
+ """No session (managed or discoverable external) matches the identifier, or
119
+ a requested transcript/reply does not exist yet."""
120
+
121
+ exit_code = EXIT_NOT_FOUND
122
+
123
+
124
+ def worker_not_found(target: str) -> SessionNotFound:
125
+ """One public error for an absent owned worker or missing CLI prefix."""
126
+ return SessionNotFound(f"no such worker: {target}")
127
+
128
+
129
+ class PayloadTooLarge(InvalidRequest):
130
+ """A send()'s prompt text exceeds the paste-channel payload bound.
131
+
132
+ Managed delivery drains the complete bracketed paste through a bounded PTY
133
+ write path. Operators may still configure ``CCX_SEND_MAX_BYTES`` (unlimited
134
+ by default) to reject payloads above their own resource policy.
135
+
136
+ ``size`` is the offending UTF-8 byte length; ``limit`` the enforced bound.
137
+ """
138
+
139
+ def __init__(self, message: str = "", *, short: str | None = None,
140
+ size: int | None = None, limit: int | None = None):
141
+ super().__init__(message, short=short)
142
+ self.size = size
143
+ self.limit = limit
144
+
145
+
146
+ class CapabilityUnsupported(InvalidRequest):
147
+ """An engine cannot honor a requested option, and the request is refused
148
+ rather than silently downgraded.
149
+
150
+ Raised only by :class:`ccx.WorkerClient`, whose contract is that an
151
+ unsupported delivery mode, quota budget, or usage projection is an error —
152
+ never a quiet no-op that runs with different semantics than asked for.
153
+ ``engine`` and ``capability`` name the offending pair so a caller can branch
154
+ on them without parsing the message. See ``WorkerClient.capabilities()``.
155
+ """
156
+
157
+ def __init__(self, message: str = "", *, short: str | None = None,
158
+ engine: str | None = None, capability: str | None = None):
159
+ super().__init__(message, short=short)
160
+ self.engine = engine
161
+ self.capability = capability
162
+
163
+
164
+ class ProviderProtocolError(CcxError):
165
+ """A provider/runtime response violated a contract CCX needs for correctness.
166
+
167
+ Retrying the same request/cursor is not expected to repair the response.
168
+ Protocol V1 maps this to ``provider_protocol_error`` rather than presenting
169
+ the condition as caller input or transient runtime availability.
170
+ """
171
+
172
+ exit_code = EXIT_ERROR
173
+
174
+
175
+ class OperationTimeout(CcxError):
176
+ """A bounded operation exhausted its caller-owned deadline.
177
+
178
+ Unlike :class:`TurnIncomplete`, this does not imply that a model turn was
179
+ running or failed to settle; it is for read/control operations whose own
180
+ end-to-end deadline elapsed. V1 maps it to the stable ``timeout`` code.
181
+ """
182
+
183
+ exit_code = EXIT_TURN_INCOMPLETE
184
+
185
+
186
+ class SessionUnavailable(CcxError):
187
+ """The session exists but cannot service this operation in its current state
188
+ — wrong lifecycle state, host not alive, an external session that is
189
+ read-only for this verb, or a failed respawn. The most overloaded code."""
190
+
191
+ exit_code = EXIT_UNAVAILABLE
192
+
193
+
194
+ class EngineCliNotFound(SessionUnavailable):
195
+ """An engine's configured native executable cannot run; exit 3."""
196
+
197
+
198
+ class SpawnFailed(SessionUnavailable):
199
+ "The detached host launcher failed hard — it never handed off to a host\n process (could not run, crashed, or produced no verdict). Distinct from a\n slow boot: a host whose socket is merely late does NOT raise this. ``phase``\n is the launcher verdict phase (see :mod:`ccx.claude.spawn`) and ``detail`` carries\n the launcher's error text when present."
200
+
201
+ def __init__(self, message: str = "", *, short: str | None = None,
202
+ phase: str | None = None, detail: str | None = None):
203
+ super().__init__(message, short=short)
204
+ self.phase = phase
205
+ self.detail = detail
206
+
207
+
208
+ class SessionWaiting(CcxError):
209
+ """A turn ended (or a wait resolved) with the session blocked on input — an
210
+ AskUserQuestion dialog or a permission prompt. ``waiting_for`` carries the
211
+ live pending-input preview (the raw host dict) when one is available;
212
+ ``pending_input`` is the same thing as a typed
213
+ :class:`~ccx.types.PendingInput`."""
214
+
215
+ exit_code = EXIT_WAITING
216
+
217
+ def __init__(self, message: str = "", *, short: str | None = None,
218
+ waiting_for: dict | None = None):
219
+ super().__init__(message, short=short)
220
+ self.waiting_for = waiting_for
221
+
222
+ @property
223
+ def pending_input(self):
224
+ """Typed view of ``waiting_for`` (:class:`~ccx.types.PendingInput`),
225
+ or None when no preview was available."""
226
+ from .types import PendingInput # lazy: keep errors dependency-free
227
+ return PendingInput.from_waiting_for(self.waiting_for)
228
+
229
+
230
+ class DeliveryRejected(CcxError):
231
+ """The host/daemon refused to accept the message or decision (queue full,
232
+ wrong state, unreachable socket). ``reason`` and ``state`` echo the host's
233
+ rejection when present.
234
+
235
+ ``refusal`` is the host's STRUCTURED refusal code when it sent one
236
+ (``busy``, ``waiting``, ``input-surface``, ``steer-pending``). Branch on it
237
+ rather than on the prose in ``reason``: only the code distinguishes a
238
+ worker blocked on a question (answer/approve it) from one holding an
239
+ unresolved durable steer (reconcile it, never resend).
240
+
241
+ ``native_refusal`` is the engine's own code. It equals ``refusal`` except
242
+ where :class:`~ccx.workers.WorkerClient` normalized a busy refusal (Codex
243
+ ``worker-state``) to ``busy``."""
244
+
245
+ exit_code = EXIT_REJECTED
246
+
247
+ def __init__(self, message: str = "", *, short: str | None = None,
248
+ reason: str | None = None, state: str | None = None,
249
+ refusal: str | None = None, delivery_id: str | None = None,
250
+ delivery_status: str | None = None, operation_id: str | None = None,
251
+ native_refusal: str | None = None):
252
+ super().__init__(message, short=short)
253
+ self.reason = reason
254
+ self.state = state
255
+ self.refusal = refusal
256
+ self.native_refusal = native_refusal if native_refusal is not None else refusal
257
+ self.delivery_id = delivery_id
258
+ self.delivery_status = delivery_status
259
+ self.operation_id = operation_id
260
+
261
+
262
+ class TurnIncomplete(CcxError):
263
+ """A blocking call's turn did not finish cleanly — it never reached IDLE
264
+ before the timeout, or the session moved to a crashed/dead/idle-killed
265
+ state mid-turn. ``state`` is the final observed state. Codex turn failures
266
+ also carry the native turn identity, status, and structured error when
267
+ those facts are available."""
268
+
269
+ exit_code = EXIT_TURN_INCOMPLETE
270
+
271
+ def __init__(self, message: str = "", *, short: str | None = None,
272
+ state: str | None = None, turn_id: str | None = None,
273
+ turn_status: str | None = None, turn_error=None):
274
+ super().__init__(message, short=short)
275
+ self.state = state
276
+ self.turn_id = turn_id
277
+ self.turn_status = turn_status
278
+ self.turn_error = turn_error
279
+
280
+
281
+ class SendIndeterminate(TurnIncomplete):
282
+ """A Codex turn may have been accepted and must be reconciled, never resent.
283
+
284
+ The caller-known delivery_id remains the durable key. delivery_status is
285
+ the last durable CCX receipt state, normally prepared or submitted.
286
+ """
287
+
288
+ def __init__(
289
+ self,
290
+ message: str = "",
291
+ *,
292
+ short: str | None = None,
293
+ state: str | None = None,
294
+ delivery_id: str | None = None,
295
+ delivery_status: str | None = None,
296
+ ):
297
+ super().__init__(message, short=short, state=state)
298
+ self.delivery_id = delivery_id
299
+ self.delivery_status = delivery_status
300
+
301
+
302
+ class CompactFailed(TurnIncomplete):
303
+ """Claude reported that a submitted compaction did not happen.
304
+
305
+ A *proven* terminal failure, and the deliberate opposite of
306
+ :class:`CompactIndeterminate`: there the evidence was missing, here Claude
307
+ wrote the verdict itself. The session is unharmed — compaction owns no turn
308
+ of its own, so the worker stays idle and healthy and the conversation is
309
+ simply un-compacted. Claude Code does not retry compaction, so re-issuing
310
+ ``compact()`` is the caller's move.
311
+
312
+ ``api_error`` carries the :class:`WorkerApiError` when the cause was an API
313
+ failure, and is ``None`` for failures owned by the conversation itself (it
314
+ could not be reduced below the context limit, unstrippable media).
315
+ """
316
+
317
+ def __init__(self, message: str = "", *, short: str | None = None,
318
+ state: str | None = None, operation_id: str | None = None,
319
+ api_error: "WorkerApiError | None" = None):
320
+ super().__init__(message, short=short, state=state)
321
+ self.operation_id = operation_id
322
+ self.api_error = api_error
323
+
324
+
325
+ class CompactIndeterminate(TurnIncomplete):
326
+ """A native compact was accepted but CCX cannot prove its terminal result.
327
+
328
+ This is deliberately distinct from a confirmed compact failure. The
329
+ operation may still reconcile to success from durable transcript evidence;
330
+ ``operation_id`` lets an orchestrator retain and diagnose that identity
331
+ without parsing text.
332
+ """
333
+
334
+ def __init__(self, message: str = "", *, short: str | None = None,
335
+ state: str | None = None, operation_id: str | None = None,
336
+ operation_status: str | None = None):
337
+ super().__init__(message, short=short, state=state)
338
+ self.operation_id = operation_id
339
+ self.operation_status = operation_status
340
+
341
+
342
+ class SteerIndeterminate(TurnIncomplete):
343
+ """A steer's delivery cannot be established or proven.
344
+
345
+ Raised when durable acceptance itself could not be confirmed (so the caller
346
+ must reconcile with the SAME ``delivery_id`` rather than retry with a fresh
347
+ one), or when an accepted delivery resolved to durable ``unknown``. It is
348
+ never raised merely because an observation window elapsed: a still-pending
349
+ delivery is a successful, honest outcome.
350
+ """
351
+
352
+ def __init__(self, message: str = "", *, short: str | None = None,
353
+ state: str | None = None, delivery_id: str | None = None,
354
+ delivery_status: str | None = None):
355
+ super().__init__(message, short=short, state=state)
356
+ self.delivery_id = delivery_id
357
+ self.delivery_status = delivery_status
358
+
359
+
360
+ class BudgetStopped(CcxError):
361
+ """A usage cap hard-stopped the run. ``reason`` is the recorded stop reason.
362
+ The session is still inspectable and a fresh (re-capped or uncapped) send
363
+ will revive it."""
364
+
365
+ exit_code = BUDGET_EXIT
366
+
367
+ def __init__(self, message: str = "", *, short: str | None = None,
368
+ reason: str | None = None, phase: str | None = None):
369
+ super().__init__(message, short=short)
370
+ self.reason = reason
371
+ self.phase = phase
372
+
373
+
374
+ class WorkerApiError(SessionUnavailable):
375
+ """A turn ended because the Claude API returned a terminal error.
376
+
377
+ **The catch-all parent, and never raised on its own.** Every failure is one
378
+ of the three concrete subclasses below, each with the exit code its remedy
379
+ deserves. Catch this class when you only need "the API killed this"; catch a
380
+ subclass when you will act differently. If you ever *do* see this exact type,
381
+ it came from hand-construction, not from CCX.
382
+
383
+ CCX does **not** retry on your behalf, and neither should you immediately:
384
+ Claude Code has already spent its own retry budget — roughly ten attempts
385
+ with exponential backoff, honoring ``retry-after`` — before the error is
386
+ ever written to the transcript. An instant retry re-runs a call that just
387
+ failed ten times.
388
+
389
+ The fields are Claude's own classification, so a caller never has to match
390
+ on prose:
391
+
392
+ ``kind``
393
+ Claude's error class: ``server_error``, ``authentication_failed``,
394
+ ``invalid_request``, ``rate_limit``, ...
395
+ ``status``
396
+ HTTP status, or ``None`` for failures that have none — notably a stream
397
+ that dies after a successful 200.
398
+ ``transient``
399
+ The failure was a property of the server or connection at that instant
400
+ rather than of the request, so the identical call could succeed later.
401
+ Informational: it says whether coming back later is meaningful at all,
402
+ not that CCX will do it for you.
403
+ ``request_id``
404
+ Anthropic's request id, for support tickets.
405
+ ``message``
406
+ Claude's own message, verbatim. ``str(exc)`` is the composed CCX line
407
+ (``Claude API <label> (<status>): <that message>``); this attribute is
408
+ the upstream half of it on its own.
409
+ """
410
+
411
+ def __init__(self, message: str = "", *, short: str | None = None,
412
+ kind: str | None = None, status: int | None = None,
413
+ transient: bool = False, request_id: str | None = None,
414
+ detail: str | None = None):
415
+ super().__init__(message, short=short)
416
+ self.kind = kind
417
+ self.status = status
418
+ self.transient = transient
419
+ self.request_id = request_id
420
+ self.message = detail
421
+
422
+
423
+ class WorkerRateLimited(WorkerApiError):
424
+ """Anthropic rate-limited the account (HTTP 429).
425
+
426
+ Distinct from :class:`BudgetStopped`, which is CCX's *own* usage cap: this
427
+ limit is imposed upstream and clears on a reset the caller does not control.
428
+ Conflating them would hide the one condition no orchestrator should miss.
429
+
430
+ It is also deliberately not folded into ``transient``. Both flags can be
431
+ set at once — Claude Code marks a 429 that is a capacity throttle as
432
+ transient, while a quota wall is not — so ``transient and rate_limited``
433
+ means "throttled, worth retrying soon" and a bare rate limit means "wait
434
+ for the reset".
435
+
436
+ ``reset_text`` is the reset wording lifted verbatim from Claude's message
437
+ (e.g. ``"resets 8:40pm (Europe/Madrid)"``), or ``None`` when it carried
438
+ none. It is deliberately NOT parsed into a timestamp: the format is human
439
+ prose, not a contract, and a parser would fail silently when it drifts. Use
440
+ ``usage()`` when you need structured quota data.
441
+ """
442
+
443
+ exit_code = EXIT_RATE_LIMITED
444
+
445
+ def __init__(self, message: str = "", *, short: str | None = None,
446
+ kind: str | None = None, status: int | None = None,
447
+ transient: bool = False, request_id: str | None = None,
448
+ detail: str | None = None, reset_text: str | None = None):
449
+ super().__init__(message, short=short, kind=kind, status=status,
450
+ transient=transient, request_id=request_id,
451
+ detail=detail)
452
+ self.reset_text = reset_text
453
+
454
+
455
+ class WorkerApiPermanent(WorkerApiError):
456
+ """The request itself, the account, or the configuration is wrong.
457
+
458
+ Expired auth, a billing problem, a model you cannot reach, a malformed
459
+ request: the identical call will fail identically, so the remedy is to fix
460
+ something or stop — never to retry. Exit 3, the session-unavailable code,
461
+ because that is exactly the situation.
462
+
463
+ Spelled out as its own class rather than left as "the bare parent" so that
464
+ ``type(e) is WorkerApiError`` never has to silently mean "permanent".
465
+ """
466
+
467
+
468
+ class WorkerApiTransient(WorkerApiError):
469
+ """Anthropic could not serve the request right now, but could later.
470
+
471
+ Covers everything CCX classifies as transient: a 529 (the canonical case —
472
+ Anthropic at capacity), a 5xx, or a stream that died mid-response. They
473
+ share the only property that matters to a caller — the identical call may
474
+ succeed later, unchanged.
475
+
476
+ A 529 is capacity, **not** your quota, and costs nothing against it. That is
477
+ what separates this from :class:`WorkerRateLimited`: here you wait minutes
478
+ and retry, there you wait for a reset you do not control.
479
+
480
+ CCX still does not retry for you. Claude Code has already spent its own
481
+ retry budget before this reaches you, so back off meaningfully rather than
482
+ looping immediately.
483
+ """
484
+
485
+ exit_code = EXIT_API_TRANSIENT
486
+
487
+
488
+ def build_worker_api_error(
489
+ message: str, *, short: str | None = None, kind: str | None = None,
490
+ status: int | None = None, transient: bool = False,
491
+ rate_limited: bool = False, request_id: str | None = None,
492
+ detail: str | None = None, reset_text: str | None = None,
493
+ ) -> WorkerApiError:
494
+ """Construct the most specific API-error class the classification supports.
495
+
496
+ Single source of the class-selection rule. Every surface that rebuilds one
497
+ of these — lifecycle records, compact journals, controller replay — goes
498
+ through here, so they cannot drift into disagreeing about which condition
499
+ maps to which class and exit code.
500
+
501
+ Rate limiting wins over transience when both are set: Claude Code marks a
502
+ capacity-throttle 429 as transient, but it is still a rate limit, and that
503
+ is the one an orchestrator must not miss.
504
+
505
+ Always returns a concrete subclass; :class:`WorkerApiError` itself is only
506
+ ever the catch-all, never a verdict.
507
+ """
508
+ common = {
509
+ "short": short, "kind": kind, "status": status,
510
+ "transient": transient, "request_id": request_id, "detail": detail,
511
+ }
512
+ if rate_limited:
513
+ return WorkerRateLimited(message, reset_text=reset_text, **common)
514
+ if transient:
515
+ return WorkerApiTransient(message, **common)
516
+ return WorkerApiPermanent(message, **common)
517
+
518
+
519
+ def api_error_from_lifecycle(
520
+ lifecycle: dict | None, *, short: str | None = None,
521
+ ) -> WorkerApiError | None:
522
+ """Rebuild the typed error from a record's ``lifecycle.apiError`` block.
523
+
524
+ Returns ``None`` when the session did not stop on an API error, so callers
525
+ can fall through to their existing generic handling.
526
+ """
527
+ payload = (lifecycle or {}).get("apiError")
528
+ if not isinstance(payload, dict):
529
+ return None
530
+ reason = (lifecycle or {}).get("lastError") or payload.get("message") or ""
531
+ return build_worker_api_error(
532
+ reason,
533
+ short=short,
534
+ kind=payload.get("kind"),
535
+ status=payload.get("status"),
536
+ transient=bool(payload.get("transient")),
537
+ rate_limited=bool(payload.get("rate_limited")),
538
+ request_id=payload.get("request_id"),
539
+ detail=payload.get("message"),
540
+ reset_text=payload.get("reset_text"),
541
+ )
542
+
543
+
544
+ class ControllerError(CcxError):
545
+ """Base error for the durable Python controller runtime."""
546
+
547
+
548
+ class ControllerRunNotFound(ControllerError):
549
+ """No controller run matches the name or run-id prefix."""
550
+
551
+ exit_code = EXIT_NOT_FOUND
552
+
553
+
554
+ class ControllerLaunchRequired(ControllerError):
555
+ """A decorated controller was invoked outside ``ccx controller run``."""
556
+
557
+
558
+ class ControllerReplayMismatch(ControllerError):
559
+ """Persisted replay identity no longer matches the controller invocation."""
560
+
561
+
562
+ class ControllerLiveStateLost(ControllerError):
563
+ """An accepted worker operation mutated live Python state from a prior attempt.
564
+
565
+ CCX will not redeliver the already-accepted worker turn, and arbitrary live
566
+ object identity is intentionally not reconstructed across controller process
567
+ death. The controller must complete any branch that consumes live variables
568
+ in the same successful task/attempt and return a durable JSON-compatible
569
+ result for later replay.
570
+ """
571
+
572
+ def __init__(self, message: str = "", *, operation_id: str | None = None):
573
+ super().__init__(message)
574
+ self.operation_id = operation_id
575
+
576
+
577
+ class ControllerOperationIndeterminate(ControllerError):
578
+ """A worker mutation may have landed but cannot be safely replayed."""
579
+
580
+ def __init__(self, message: str = "", *, operation_id: str | None = None):
581
+ super().__init__(message)
582
+ self.operation_id = operation_id
583
+
584
+
585
+
586
+
587
+ # Exit code → exception class, for a thin CLI adapter or tests. Only the codes a
588
+ # library call can raise are listed; EXIT_ERROR is the CcxError default.
589
+ EXIT_CODE_TO_ERROR: dict[int, type[CcxError]] = {
590
+ EXIT_ERROR: InvalidRequest,
591
+ EXIT_NOT_FOUND: SessionNotFound,
592
+ EXIT_UNAVAILABLE: SessionUnavailable,
593
+ EXIT_WAITING: SessionWaiting,
594
+ EXIT_REJECTED: DeliveryRejected,
595
+ EXIT_TURN_INCOMPLETE: TurnIncomplete,
596
+ BUDGET_EXIT: BudgetStopped,
597
+ EXIT_RATE_LIMITED: WorkerRateLimited,
598
+ EXIT_API_TRANSIENT: WorkerApiTransient,
599
+ }
600
+
601
+
602
+ __all__ = [
603
+ "CcxError",
604
+ "InvalidRequest",
605
+ "OperationConflict",
606
+ "CursorInvalidated",
607
+ "ProviderProtocolError",
608
+ "OperationTimeout",
609
+ "SessionNotFound",
610
+ "PayloadTooLarge",
611
+ "CapabilityUnsupported",
612
+ "SessionUnavailable",
613
+ "SpawnFailed",
614
+ "SessionWaiting",
615
+ "DeliveryRejected",
616
+ "TurnIncomplete",
617
+ "CompactFailed",
618
+ "CompactIndeterminate",
619
+ "SteerIndeterminate",
620
+ "SendIndeterminate",
621
+ "BudgetStopped",
622
+ "WorkerApiError",
623
+ "WorkerApiPermanent",
624
+ "WorkerApiTransient",
625
+ "WorkerRateLimited",
626
+ "api_error_from_lifecycle",
627
+ "build_worker_api_error",
628
+ "ControllerError",
629
+ "ControllerLaunchRequired",
630
+ "ControllerReplayMismatch",
631
+ "ControllerLiveStateLost",
632
+ "ControllerOperationIndeterminate",
633
+ "EXIT_CODE_TO_ERROR",
634
+ ]
File without changes
@@ -0,0 +1,5 @@
1
+ """Run the notification daemon: ``python -m ccx.notify``."""
2
+ from ccx.notify.daemon import run
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(run())