ccx-native 0.0.1 → 0.2.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 (156) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +240 -4
  3. package/bin/ccx +55 -2
  4. package/ccx/__init__.py +167 -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 +807 -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 +269 -0
  16. package/ccx/claude/host/session.py +5244 -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 +2535 -0
  26. package/ccx/claude/usage.py +796 -0
  27. package/ccx/claude/usage_control.py +204 -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/models.py +53 -0
  35. package/ccx/cli/commands/read.py +1104 -0
  36. package/ccx/cli/commands/turns.py +1081 -0
  37. package/ccx/cli/controller.py +757 -0
  38. package/ccx/cli/main.py +302 -0
  39. package/ccx/cli/parser.py +1439 -0
  40. package/ccx/cli/skills.py +215 -0
  41. package/ccx/cli/statusline.py +160 -0
  42. package/ccx/client.py +1904 -0
  43. package/ccx/codex/__init__.py +0 -0
  44. package/ccx/codex/attach_tty.py +267 -0
  45. package/ccx/codex/client.py +738 -0
  46. package/ccx/codex/completion.py +121 -0
  47. package/ccx/codex/control.py +288 -0
  48. package/ccx/codex/daemon/__init__.py +0 -0
  49. package/ccx/codex/daemon/__main__.py +4 -0
  50. package/ccx/codex/daemon/daemon.py +1558 -0
  51. package/ccx/codex/daemon/models.py +140 -0
  52. package/ccx/codex/daemon/native.py +1590 -0
  53. package/ccx/codex/daemon/release.py +367 -0
  54. package/ccx/codex/daemon/worker.py +2424 -0
  55. package/ccx/codex/delivery.py +758 -0
  56. package/ccx/codex/delivery_recovery.py +232 -0
  57. package/ccx/codex/evidence.py +827 -0
  58. package/ccx/codex/history.py +158 -0
  59. package/ccx/codex/history_store.py +131 -0
  60. package/ccx/codex/notify_history.py +140 -0
  61. package/ccx/codex/ops.py +666 -0
  62. package/ccx/codex/paths.py +70 -0
  63. package/ccx/codex/protocol.py +805 -0
  64. package/ccx/codex/provenance.py +105 -0
  65. package/ccx/codex/read.py +29 -0
  66. package/ccx/codex/records.py +51 -0
  67. package/ccx/codex/reports.py +177 -0
  68. package/ccx/codex/runtime.py +1454 -0
  69. package/ccx/codex/settle.py +363 -0
  70. package/ccx/codex/telemetry.py +792 -0
  71. package/ccx/codex/types.py +659 -0
  72. package/ccx/codex/worker_environment.py +25 -0
  73. package/ccx/constants.py +288 -0
  74. package/ccx/controllers/__init__.py +0 -0
  75. package/ccx/controllers/controller.py +1449 -0
  76. package/ccx/controllers/live_vars.py +1706 -0
  77. package/ccx/controllers/live_vars_runner.py +216 -0
  78. package/ccx/controllers/notify.py +213 -0
  79. package/ccx/controllers/orchestrator.py +193 -0
  80. package/ccx/controllers/policy.py +57 -0
  81. package/ccx/controllers/store.py +681 -0
  82. package/ccx/errors.py +634 -0
  83. package/ccx/models.py +225 -0
  84. package/ccx/notify/__init__.py +0 -0
  85. package/ccx/notify/__main__.py +5 -0
  86. package/ccx/notify/client.py +116 -0
  87. package/ccx/notify/daemon.py +480 -0
  88. package/ccx/notify/deliver.py +304 -0
  89. package/ccx/notify/paths.py +51 -0
  90. package/ccx/notify/registry.py +649 -0
  91. package/ccx/notify/select.py +750 -0
  92. package/ccx/notify/settle.py +684 -0
  93. package/ccx/ops/__init__.py +0 -0
  94. package/ccx/ops/background.py +115 -0
  95. package/ccx/ops/cancel.py +296 -0
  96. package/ccx/ops/codex_import.py +96 -0
  97. package/ccx/ops/common.py +397 -0
  98. package/ccx/ops/compact.py +337 -0
  99. package/ccx/ops/compact_ops.py +550 -0
  100. package/ccx/ops/context_events.py +48 -0
  101. package/ccx/ops/conversation.py +1593 -0
  102. package/ccx/ops/conversation_paging.py +1339 -0
  103. package/ccx/ops/create.py +56 -0
  104. package/ccx/ops/creation.py +952 -0
  105. package/ccx/ops/creation_steps.py +575 -0
  106. package/ccx/ops/event_feed.py +710 -0
  107. package/ccx/ops/forking.py +711 -0
  108. package/ccx/ops/history.py +1041 -0
  109. package/ccx/ops/input.py +556 -0
  110. package/ccx/ops/maintenance.py +295 -0
  111. package/ccx/ops/report.py +773 -0
  112. package/ccx/ops/semantics.py +490 -0
  113. package/ccx/ops/send.py +961 -0
  114. package/ccx/ops/steer_ops.py +867 -0
  115. package/ccx/ops/terminate.py +383 -0
  116. package/ccx/ops/turn_reports.py +348 -0
  117. package/ccx/ops/wait.py +762 -0
  118. package/ccx/ops/worker_events.py +640 -0
  119. package/ccx/rpc/__init__.py +0 -0
  120. package/ccx/rpc/local_rpc.py +1602 -0
  121. package/ccx/rpc/rpc.py +559 -0
  122. package/ccx/rpc/serve.py +374 -0
  123. package/ccx/rpc/transport.py +409 -0
  124. package/ccx/rpc/view.py +125 -0
  125. package/ccx/store/__init__.py +0 -0
  126. package/ccx/store/discovery.py +170 -0
  127. package/ccx/store/identity.py +105 -0
  128. package/ccx/store/journal_io.py +79 -0
  129. package/ccx/store/liveness.py +194 -0
  130. package/ccx/store/paths.py +250 -0
  131. package/ccx/store/record_cache.py +40 -0
  132. package/ccx/store/records.py +309 -0
  133. package/ccx/store/root.py +789 -0
  134. package/ccx/store/runtime_presence.py +137 -0
  135. package/ccx/store/store.py +2332 -0
  136. package/ccx/store/worker_state.py +86 -0
  137. package/ccx/system/__init__.py +0 -0
  138. package/ccx/system/bounded.py +296 -0
  139. package/ccx/system/cwd_paths.py +25 -0
  140. package/ccx/system/engine_binary.py +59 -0
  141. package/ccx/system/executable.py +50 -0
  142. package/ccx/system/oslayer.py +667 -0
  143. package/ccx/system/pipes.py +46 -0
  144. package/ccx/system/private_json.py +54 -0
  145. package/ccx/system/sanitize.py +43 -0
  146. package/ccx/types.py +809 -0
  147. package/ccx/version.py +29 -0
  148. package/ccx/workers.py +2093 -0
  149. package/ccx.py +9 -0
  150. package/package.json +39 -7
  151. package/pyproject.toml +87 -0
  152. package/skills/ccx/SKILL.md +166 -0
  153. package/skills/ccx/references/controllers.md +244 -0
  154. package/skills/ccx/references/history.md +50 -0
  155. package/skills/ccx/references/troubleshooting.md +94 -0
  156. package/skills/ccx/references/variables.md +62 -0
@@ -0,0 +1,796 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from contextlib import contextmanager
5
+ from email.utils import parsedate_to_datetime
6
+ import fcntl
7
+ import math
8
+ from pathlib import Path
9
+ from datetime import datetime, timezone
10
+ import json
11
+ import os
12
+ import stat
13
+ import time
14
+ import urllib.error
15
+ import urllib.parse
16
+ import urllib.request
17
+
18
+ from ccx.system import oslayer
19
+ from ccx.system.private_json import (
20
+ _atomic_private_json,
21
+ _open_private_file,
22
+ _read_private_json,
23
+ )
24
+ from ccx.claude.oauth import oauth_headers, oauth_organization_uuid, oauth_token
25
+ from ccx.constants import (
26
+ CAP_METRICS,
27
+ CLAUDE_HOME,
28
+ usage_source_path,
29
+ USAGE_MIN_FETCH_INTERVAL_S,
30
+ OAUTH_USAGE_MIN_FETCH_INTERVAL_S,
31
+ OAUTH_USAGE_STALE_LIMIT_S,
32
+ UNOFFICIAL_RESET_ENV,
33
+ CAP_POLL_INTERVAL_S,
34
+ usage_cache_path,
35
+ USAGE_STALE_LIMIT_S,
36
+ USAGE_URL,
37
+ opt_in_enabled,
38
+ )
39
+ from ccx.errors import InvalidRequest
40
+
41
+ _SESSION_RESET_PROGRAM = "juniper_tide"
42
+ _SESSION_RESET_RESULTS = frozenset({
43
+ "reset", "already_used", "not_limited", "ineligible", "unavailable",
44
+ })
45
+ _SESSION_RESET_REASONS = frozenset({
46
+ "tier", "tenure", "surface", "mobile", "cli_version", "not_at_wall",
47
+ "weekly_limit", "no_weekly_limit", "other_experiment", "extra_usage",
48
+ "unavailable", "unknown",
49
+ })
50
+ _SESSION_RESET_ARMS = frozenset({"control", "reset"})
51
+ _SESSION_RESET_TIMEOUT_S = 25.0
52
+
53
+
54
+ # --- machine-wide Claude plan usage source and shared cache -----------------
55
+
56
+ _SOURCE_CHOICES = frozenset({"get_usage", "oauth"})
57
+ _RETRY_ERROR_S = 120.0
58
+ _RETRY_HELD_INITIAL_S = 300.0
59
+ _RETRY_HELD_MAX_S = 1800.0
60
+
61
+
62
+ def _open_usage_cache() -> int:
63
+ return _open_private_file(usage_cache_path())
64
+
65
+
66
+ def _read_usage_cache() -> dict | None:
67
+ try:
68
+ return _read_private_json(usage_cache_path())
69
+ except (OSError, ValueError, UnicodeError):
70
+ return None
71
+
72
+
73
+ def _utc_timestamp(epoch: float) -> str:
74
+ return datetime.fromtimestamp(epoch, tz=timezone.utc).isoformat().replace("+00:00", "Z")
75
+
76
+
77
+ def _parse_epoch(value: object) -> float | None:
78
+ if not isinstance(value, str):
79
+ return None
80
+ try:
81
+ result = datetime.fromisoformat(value.replace("Z", "+00:00"))
82
+ if result.tzinfo is None:
83
+ return None
84
+ return result.timestamp()
85
+ except (ValueError, OverflowError):
86
+ return None
87
+
88
+
89
+ def _cache_age(data: dict | None = None) -> float | None:
90
+ """Age of the last successful provider sample, not the metadata file mtime."""
91
+ if data is None:
92
+ data = _read_usage_cache()
93
+ epoch = _parse_epoch((data.get("_ccx") or {}).get("fetchedAt")) if isinstance(data, dict) else None
94
+ return max(0.0, time.time() - epoch) if epoch is not None else None
95
+
96
+
97
+ def _write_usage_cache(data: dict) -> None:
98
+ _atomic_private_json(usage_cache_path(), data)
99
+
100
+
101
+ def _usage_source() -> str:
102
+ """Persistent per-CCX_ROOT selector, read on every use (including live hosts)."""
103
+ try:
104
+ record = _read_private_json(usage_source_path())
105
+ except FileNotFoundError:
106
+ return "get_usage"
107
+ except (OSError, ValueError, UnicodeError) as exc:
108
+ raise InvalidRequest("invalid private Claude usage-source setting") from exc
109
+ if not isinstance(record, dict) or record.get("version") != 1 or record.get("source") not in _SOURCE_CHOICES:
110
+ raise InvalidRequest("invalid Claude usage-source setting; choose get_usage or oauth")
111
+ if record["source"] == "oauth" and not oslayer.direct_oauth_usage_supported():
112
+ raise InvalidRequest("Claude OAuth usage source is Linux-only; select get_usage")
113
+ return record["source"]
114
+
115
+
116
+ def _lock_file_path() -> Path:
117
+ return Path(str(usage_cache_path()) + ".lock")
118
+
119
+
120
+ @contextmanager
121
+ def _usage_lock(timeout: float):
122
+ """Serialize source switches and refreshes across independent CCX processes."""
123
+ path = _lock_file_path()
124
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
125
+ fd = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600)
126
+ try:
127
+ st = os.fstat(fd)
128
+ if not stat.S_ISREG(st.st_mode) or st.st_uid != os.getuid() or st.st_mode & 0o077:
129
+ raise PermissionError("usage cache lock must be private and owned by this user")
130
+ deadline = time.monotonic() + max(0.0, timeout)
131
+ while True:
132
+ try:
133
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
134
+ break
135
+ except BlockingIOError:
136
+ if time.monotonic() >= deadline:
137
+ raise TimeoutError("Claude usage cache lock busy")
138
+ time.sleep(min(0.025, max(0.0, deadline - time.monotonic())))
139
+ try:
140
+ yield
141
+ finally:
142
+ fcntl.flock(fd, fcntl.LOCK_UN)
143
+ finally:
144
+ os.close(fd)
145
+
146
+
147
+ def set_usage_source(source: str) -> str:
148
+ """Explicit persistent selector update, without a provider read."""
149
+ if source not in _SOURCE_CHOICES:
150
+ raise InvalidRequest("usage source must be get_usage or oauth")
151
+ if source == "oauth" and not oslayer.direct_oauth_usage_supported():
152
+ raise InvalidRequest("direct OAuth usage is Linux-only; use get_usage")
153
+ try:
154
+ with _usage_lock(8.0):
155
+ try:
156
+ previous = _usage_source()
157
+ except InvalidRequest:
158
+ # The atomic writer still checks ownership and private-file mode.
159
+ previous = None
160
+ if previous != source or not usage_source_path().exists():
161
+ _atomic_private_json(usage_source_path(), {"version": 1, "source": source})
162
+ # Retire values from the previous source; never relabel them.
163
+ _write_usage_cache({"_ccx": _new_metadata(source)})
164
+ except (OSError, TimeoutError) as exc:
165
+ raise InvalidRequest(f"cannot set Claude usage source: {exc}") from exc
166
+ return source
167
+
168
+
169
+ _USAGE_RESPONSE_KEYS = frozenset({
170
+ "five_hour", "seven_day", "seven_day_opus", "seven_day_sonnet",
171
+ "limits", "model_scoped", "extra_usage", _SESSION_RESET_PROGRAM,
172
+ })
173
+
174
+
175
+ def _is_usage_payload(value: object) -> bool:
176
+ return isinstance(value, dict) and any(key in value for key in _USAGE_RESPONSE_KEYS)
177
+
178
+
179
+ def _usage_request_url(*, at_wall: bool, skip_spend: bool = False) -> str:
180
+ if not at_wall:
181
+ return USAGE_URL
182
+ query = {"at_wall": 1}
183
+ if skip_spend:
184
+ query["skip_spend"] = 1
185
+ return f"{USAGE_URL}?{urllib.parse.urlencode(query)}"
186
+
187
+
188
+ def _oauth_sample(timeout: float = 8.0, *, at_wall: bool = True,
189
+ skip_spend: bool = False) -> dict:
190
+ """The only read-only path that accesses the private OAuth identity."""
191
+ token = oauth_token()
192
+ if not token:
193
+ return {"kind": "error", "error": "no Claude OAuth token (log in to Claude Code)"}
194
+ request = urllib.request.Request(
195
+ _usage_request_url(at_wall=at_wall, skip_spend=skip_spend),
196
+ headers=oauth_headers(token),
197
+ )
198
+ try:
199
+ with urllib.request.urlopen(request, timeout=timeout) as response:
200
+ if not 200 <= response.status < 300:
201
+ return {"kind": "error", "error": f"usage endpoint returned HTTP {response.status}"}
202
+ body = json.loads(response.read(2 * 1024 * 1024 + 1).decode())
203
+ except urllib.error.HTTPError as exc:
204
+ if exc.code == 429:
205
+ retry_at = None
206
+ headers = exc.headers
207
+ if headers is not None:
208
+ value = headers.get("Retry-After")
209
+ if value:
210
+ try:
211
+ retry_at = time.time() + max(0.0, float(value))
212
+ except ValueError:
213
+ try:
214
+ retry_at = parsedate_to_datetime(value).timestamp()
215
+ except (ValueError, TypeError, OverflowError):
216
+ pass
217
+ return {"kind": "held", "error": "usage endpoint returned HTTP 429",
218
+ "retry_at": retry_at}
219
+ return {"kind": "error", "error": f"usage endpoint returned HTTP {exc.code}"}
220
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
221
+ return {"kind": "error", "error": f"usage endpoint unreachable: {exc.reason if isinstance(exc, urllib.error.URLError) else type(exc).__name__}"}
222
+ except (ValueError, UnicodeError):
223
+ return {"kind": "error", "error": "usage endpoint returned unreadable JSON"}
224
+ if _is_usage_payload(body):
225
+ return {"kind": "success", "body": body}
226
+ return {"kind": "unparsed", "raw": body,
227
+ "error": "usage endpoint returned an unrecognized payload"}
228
+
229
+
230
+ def _request_usage_live_detail(
231
+ timeout: float = 8.0, *, at_wall: bool = True, skip_spend: bool = False,
232
+ ) -> tuple[dict | None, str | None]:
233
+ """Private direct-OAuth read, kept separate for reset's authoritative preflight."""
234
+ result = _oauth_sample(timeout, at_wall=at_wall, skip_spend=skip_spend)
235
+ return (result.get("body"), None) if result["kind"] == "success" else (None, result["error"])
236
+
237
+
238
+ def _request_usage_live(
239
+ timeout: float = 8.0, *, at_wall: bool = True, skip_spend: bool = False,
240
+ ) -> dict | None:
241
+ return _request_usage_live_detail(timeout, at_wall=at_wall, skip_spend=skip_spend)[0]
242
+
243
+
244
+ def _new_metadata(source: str) -> dict:
245
+ """Only the owner-approved persisted CCX metadata fields."""
246
+ return {"version": 1, "source": source, "fetchedAt": None,
247
+ "lastAttemptAt": None, "nextAttemptAt": None, "held": False,
248
+ "heldUntil": None, "heldUntilEstimated": False, "unparsed": False,
249
+ "raw": None, "error": None}
250
+
251
+
252
+ def _matching_cache(source: str) -> dict | None:
253
+ data = _read_usage_cache()
254
+ meta = data.get("_ccx") if isinstance(data, dict) else None
255
+ if isinstance(meta, dict) and meta.get("version") == 1 and meta.get("source") == source:
256
+ return data
257
+ return None
258
+
259
+
260
+ def _is_stale(data: dict | None, stale_limit: float = USAGE_STALE_LIMIT_S) -> bool:
261
+ if not isinstance(data, dict):
262
+ return True
263
+ meta = data.get("_ccx") or {}
264
+ if not isinstance(meta, dict) or meta.get("held") or meta.get("unparsed") or meta.get("error"):
265
+ return True
266
+ if meta.get("source") == "oauth":
267
+ stale_limit = max(stale_limit, OAUTH_USAGE_STALE_LIMIT_S)
268
+ age = _cache_age(data)
269
+ return age is None or age > stale_limit
270
+
271
+
272
+ def _refresh_due(data: dict | None, *, force: bool, max_age: float, now: float) -> bool:
273
+ if isinstance(data, dict):
274
+ meta = data.get("_ccx") or {}
275
+ deadline = _parse_epoch(meta.get("nextAttemptAt"))
276
+ if deadline is not None and now < deadline:
277
+ return False
278
+ if not force:
279
+ age = _cache_age(data)
280
+ if age is not None and age < max_age and not meta.get("held") and not meta.get("error") and not meta.get("unparsed"):
281
+ return False
282
+ return True
283
+
284
+
285
+ def _next_held_interval(old_meta: dict) -> float:
286
+ if old_meta.get("held"):
287
+ start = _parse_epoch(old_meta.get("lastAttemptAt"))
288
+ end = _parse_epoch(old_meta.get("heldUntil"))
289
+ if start is not None and end is not None and end > start:
290
+ return min(_RETRY_HELD_MAX_S, max(_RETRY_HELD_INITIAL_S, (end - start) * 2))
291
+ return _RETRY_HELD_INITIAL_S
292
+
293
+
294
+ def _apply_sample(source: str, prior: dict | None, sample: dict, now: float) -> dict:
295
+ """Last-known provider values are retained; failed attempts never freshen them."""
296
+ old_meta = (prior or {}).get("_ccx") or {}
297
+ meta = _new_metadata(source)
298
+ meta["fetchedAt"] = old_meta.get("fetchedAt")
299
+ meta["lastAttemptAt"] = _utc_timestamp(now)
300
+ kind = sample["kind"]
301
+ if kind == "success" and (
302
+ not _is_usage_payload(sample.get("body")) or "_ccx" in sample["body"]
303
+ ):
304
+ sample = {"kind": "unparsed", "raw": sample.get("body"),
305
+ "error": "unrecognized Claude usage response"}
306
+ kind = "unparsed"
307
+ if kind == "success":
308
+ body = dict(sample["body"])
309
+ meta["fetchedAt"] = _utc_timestamp(now)
310
+ floor = OAUTH_USAGE_MIN_FETCH_INTERVAL_S if source == "oauth" else USAGE_MIN_FETCH_INTERVAL_S
311
+ meta["nextAttemptAt"] = _utc_timestamp(now + floor)
312
+ else:
313
+ body = {k: v for k, v in (prior or {}).items() if k != "_ccx"}
314
+ meta["error"] = sample.get("error") or "Claude usage unavailable"
315
+ if kind == "held":
316
+ interval = _next_held_interval(old_meta)
317
+ provider_retry = sample.get("retry_at")
318
+ valid_retry = isinstance(provider_retry, (int, float)) and now < provider_retry <= now + _RETRY_HELD_MAX_S
319
+ until = provider_retry if valid_retry else now + interval
320
+ meta["held"] = True
321
+ meta["heldUntil"] = _utc_timestamp(until)
322
+ meta["heldUntilEstimated"] = not valid_retry
323
+ meta["nextAttemptAt"] = meta["heldUntil"]
324
+ else:
325
+ delay = 300.0 if kind == "unavailable" else _RETRY_ERROR_S
326
+ meta["nextAttemptAt"] = _utc_timestamp(now + delay)
327
+ if kind == "unparsed":
328
+ meta["unparsed"] = True
329
+ meta["raw"] = sample.get("raw")
330
+ body["_ccx"] = meta
331
+ return body
332
+
333
+
334
+ def _origin(data: dict | None, source: str | None, live: bool,
335
+ error: str | None = None) -> dict:
336
+ meta = (data or {}).get("_ccx") or {}
337
+ age = _cache_age(data)
338
+ return {"source": "live" if live else "cache" if data is not None else None,
339
+ "provider": source, "age_s": age,
340
+ "fetched_at": meta.get("fetchedAt"),
341
+ "held": bool(meta.get("held")), "held_until": meta.get("heldUntil"),
342
+ "held_until_estimated": bool(meta.get("heldUntilEstimated")),
343
+ "stale": _is_stale(data), "unparsed": bool(meta.get("unparsed")),
344
+ "next_attempt_at": meta.get("nextAttemptAt"),
345
+ "live_error": error or meta.get("error"), "cache": str(usage_cache_path())}
346
+
347
+
348
+ def _fetch_usage_common(*, timeout: float, max_age: float, force: bool) -> tuple[dict | None, dict]:
349
+ try:
350
+ source = _usage_source()
351
+ except InvalidRequest as exc:
352
+ return None, _origin(None, None, False, str(exc))
353
+ cached = _matching_cache(source)
354
+ now = time.time()
355
+ if not _refresh_due(cached, force=force, max_age=max_age, now=now):
356
+ return cached, _origin(cached, source, False)
357
+ began = time.monotonic()
358
+ try:
359
+ with _usage_lock(timeout):
360
+ # A different process might have fetched or changed the source.
361
+ source = _usage_source()
362
+ cached = _matching_cache(source)
363
+ now = time.time()
364
+ if not _refresh_due(cached, force=force, max_age=max_age, now=now):
365
+ return cached, _origin(cached, source, False)
366
+ left = max(0.0, timeout - (time.monotonic() - began))
367
+ if left <= 0:
368
+ return cached, _origin(cached, source, False, "Claude usage fetch deadline exceeded")
369
+ if source == "oauth":
370
+ sample = _oauth_sample(left)
371
+ else:
372
+ from ccx.claude.usage_control import request_usage
373
+ sample = request_usage(left)
374
+ fresh = _apply_sample(source, cached, sample, time.time())
375
+ _write_usage_cache(fresh)
376
+ return fresh, _origin(fresh, source, sample["kind"] == "success")
377
+ except (OSError, TimeoutError, InvalidRequest) as exc:
378
+ return cached, _origin(cached, source, False, f"Claude usage fetch failed: {exc}")
379
+
380
+
381
+ def fetch_usage(timeout: float = 8.0, max_age: float = CAP_POLL_INTERVAL_S,
382
+ force: bool = False, stale_limit: float | None = None) -> dict | None:
383
+ """One source-aware, process-shared cached Claude account usage read.
384
+
385
+ force ignores the caller's age preference, never the 60s global floor or
386
+ provider hold/backoff. stale_limit additionally fails open for caps.
387
+ """
388
+ data, _ = _fetch_usage_common(timeout=8.0 if timeout is None else max(0.0, timeout),
389
+ max_age=max(0.0, max_age), force=force)
390
+ if data is None or (stale_limit is not None and _is_stale(data, stale_limit)):
391
+ return None
392
+ if not _is_usage_payload(data) and not (data.get("_ccx") or {}).get("unparsed"):
393
+ return None
394
+ return data
395
+
396
+
397
+ def fetch_usage_for_display(
398
+ *, force: bool, timeout: float = 8.0, max_age: float = CAP_POLL_INTERVAL_S,
399
+ ) -> tuple[dict | None, dict]:
400
+ data, origin = _fetch_usage_common(timeout=max(0.0, timeout),
401
+ max_age=max(0.0, max_age), force=force)
402
+ if data is not None and not _is_usage_payload(data) and not origin["unparsed"]:
403
+ return None, origin
404
+ return data, origin
405
+
406
+
407
+ def describe_usage_age(age_s: float | None) -> str:
408
+ if age_s is None:
409
+ return "age unknown"
410
+ if age_s < 90:
411
+ return f"{max(0, int(age_s))}s old"
412
+ if age_s < 90 * 60:
413
+ return f"{int(age_s // 60)}m old"
414
+ return f"{int(age_s // 3600)}h old"
415
+
416
+
417
+ def _strict_provider_time(value: object) -> str | None:
418
+ if value is None:
419
+ return None
420
+ if not isinstance(value, str) or not value.strip():
421
+ raise ValueError("provider time must be a non-empty ISO string or null")
422
+ try:
423
+ parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
424
+ except ValueError as exc:
425
+ raise ValueError("invalid provider time") from exc
426
+ if parsed.tzinfo is None:
427
+ raise ValueError("provider time must include a timezone")
428
+ return value
429
+
430
+
431
+ def _parse_session_reset_status(data: dict | None) -> dict | None:
432
+ """Strictly parse Anthropic's private session-reset status, else fail closed."""
433
+ if not isinstance(data, dict):
434
+ return None
435
+ block = data.get(_SESSION_RESET_PROGRAM)
436
+ if not isinstance(block, dict):
437
+ return None
438
+ eligible = block.get("eligible")
439
+ available = block.get("available")
440
+ if not isinstance(eligible, bool) or not isinstance(available, bool):
441
+ return None
442
+ reason = block.get("ineligible_reason")
443
+ if reason is not None and (
444
+ not isinstance(reason, str) or reason not in _SESSION_RESET_REASONS
445
+ ):
446
+ return None
447
+ arm = block.get("arm")
448
+ if arm is not None and (not isinstance(arm, str) or arm not in _SESSION_RESET_ARMS):
449
+ return None
450
+ in_experiment = block.get("in_experiment")
451
+ if in_experiment is not None and not isinstance(in_experiment, bool):
452
+ return None
453
+ resets_per_week = block.get("resets_per_week")
454
+ if resets_per_week is not None and (
455
+ isinstance(resets_per_week, bool)
456
+ or not isinstance(resets_per_week, (int, float))
457
+ or not float(resets_per_week).is_integer()
458
+ or resets_per_week < 0
459
+ ):
460
+ return None
461
+ try:
462
+ next_available_at = _strict_provider_time(block.get("next_available_at"))
463
+ weekly_resets_at = _strict_provider_time(block.get("weekly_resets_at"))
464
+ except ValueError:
465
+ return None
466
+ return {
467
+ "eligible": eligible,
468
+ "available": available,
469
+ "claimable": bool(eligible and available and arm == "reset"),
470
+ "ineligible_reason": reason,
471
+ "next_available_at": next_available_at,
472
+ "weekly_resets_at": weekly_resets_at,
473
+ "resets_per_week": int(resets_per_week) if resets_per_week is not None else None,
474
+ # Private protocol detail used only inside ccx's mutation gate.
475
+ "_arm": arm,
476
+ }
477
+
478
+
479
+ def session_reset_status(data: dict | None) -> dict | None:
480
+ """Stable public projection of the private provider reset entitlement."""
481
+ parsed = _parse_session_reset_status(data)
482
+ if parsed is None:
483
+ return None
484
+ return {k: v for k, v in parsed.items() if not k.startswith("_")}
485
+
486
+
487
+ def _parse_reset_result(value: object) -> dict | None:
488
+ if not isinstance(value, dict):
489
+ return None
490
+ result = value.get("result")
491
+ if not isinstance(result, str) or result not in _SESSION_RESET_RESULTS:
492
+ return None
493
+ try:
494
+ next_available_at = _strict_provider_time(value.get("next_available_at"))
495
+ weekly_resets_at = _strict_provider_time(value.get("weekly_resets_at"))
496
+ except ValueError:
497
+ return None
498
+ return {
499
+ "result": result,
500
+ "next_available_at": next_available_at,
501
+ "weekly_resets_at": weekly_resets_at,
502
+ }
503
+
504
+
505
+ def reset_five_hour_limit(*, timeout: float = _SESSION_RESET_TIMEOUT_S) -> dict:
506
+ """Claim Anthropic's provider-reported weekly 5-hour reset exactly once.
507
+
508
+ Eligibility is re-read live immediately before the mutation. A stale cache,
509
+ the locally displayed 5-hour percentage, or a locally calculated date can
510
+ never authorize this call. The mutation itself is never retried. After any
511
+ attempted mutation, one read-only usage fetch reconciles current state; an
512
+ ambiguous mutation error stays an error even if that reconciliation succeeds.
513
+
514
+ This claims Claude Code's own reset offer (a limited rollout) through its
515
+ account endpoint, which isn't a public API; disabled without
516
+ CCX_ENABLE_UNOFFICIAL_RESET=1. The opt-in check precedes all network I/O.
517
+ """
518
+ if not opt_in_enabled(UNOFFICIAL_RESET_ENV):
519
+ raise InvalidRequest(
520
+ "reset_five_hour_limit: this claims Claude Code's five-hour reset "
521
+ "offer through its account endpoint, which isn't a public API and can "
522
+ f"change without notice; it is disabled by default. Set {UNOFFICIAL_RESET_ENV}=1 to "
523
+ "opt in."
524
+ )
525
+ if _usage_source() != "oauth":
526
+ raise InvalidRequest(
527
+ "reset_five_hour_limit: also select the machine-wide oauth usage "
528
+ "source with ccx usage --engine claude --set-source oauth"
529
+ )
530
+ preflight = _request_usage_live(
531
+ timeout=min(8.0, timeout), at_wall=True, skip_spend=True
532
+ )
533
+ parsed_status = _parse_session_reset_status(preflight)
534
+ public_status = session_reset_status(preflight)
535
+ if parsed_status is None or not parsed_status["claimable"]:
536
+ return {
537
+ "result": "not_available",
538
+ "status": public_status,
539
+ "refreshed": False,
540
+ }
541
+
542
+ token = oauth_token()
543
+ organization = oauth_organization_uuid()
544
+ if not token or not organization:
545
+ return {"result": "auth_error", "status": public_status, "refreshed": False}
546
+
547
+ encoded_org = urllib.parse.quote(organization, safe="")
548
+ url = f"https://api.anthropic.com/api/organizations/{encoded_org}/reset_rate_limits"
549
+ body = json.dumps({"program": _SESSION_RESET_PROGRAM}, separators=(",", ":")).encode()
550
+ req = urllib.request.Request(
551
+ url,
552
+ data=body,
553
+ method="POST",
554
+ headers=oauth_headers(token),
555
+ )
556
+ outcome: dict
557
+ try:
558
+ with urllib.request.urlopen(req, timeout=timeout) as response:
559
+ if not (200 <= response.status < 300):
560
+ outcome = {"result": "error"}
561
+ else:
562
+ try:
563
+ decoded = json.loads(response.read().decode())
564
+ except ValueError:
565
+ decoded = None
566
+ outcome = _parse_reset_result(decoded) or {"result": "error"}
567
+ except urllib.error.HTTPError as exc:
568
+ if exc.code == 429:
569
+ outcome = {"result": "rate_limited"}
570
+ elif exc.code in (401, 403):
571
+ outcome = {"result": "auth_error"}
572
+ else:
573
+ outcome = {"result": "error"}
574
+ except (OSError, urllib.error.URLError, TimeoutError):
575
+ outcome = {"result": "error"}
576
+
577
+ # Reconcile with one safe read. This is not a mutation retry and never
578
+ # upgrades an ambiguous POST failure into a guessed success.
579
+ refreshed = _request_usage_live(
580
+ timeout=min(8.0, timeout), at_wall=True, skip_spend=False
581
+ )
582
+ outcome["status"] = (
583
+ session_reset_status(refreshed) if refreshed is not None else public_status
584
+ )
585
+ outcome["refreshed"] = refreshed is not None
586
+ return outcome
587
+
588
+
589
+ def read_usage_cached(stale_limit: float | None = None) -> dict | None:
590
+ """Nonblocking, cache-only source-aware read for the host's select loop."""
591
+ try:
592
+ data = _matching_cache(_usage_source())
593
+ except InvalidRequest:
594
+ return None
595
+ if data is None or (stale_limit is not None and _is_stale(data, stale_limit)):
596
+ return None
597
+ return data if _is_usage_payload(data) else None
598
+
599
+
600
+ def _valid_pct(value: object) -> float | None:
601
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
602
+ return None
603
+ number = float(value)
604
+ return number if math.isfinite(number) and 0.0 <= number <= 100.0 else None
605
+
606
+
607
+ def _metric_util(data: dict, key: str) -> float | None:
608
+ """A missing/null/invalid metric is unknown, never an assumed zero."""
609
+ field = CAP_METRICS.get(key)
610
+ if field is None or not data:
611
+ return None
612
+ block = data.get(field)
613
+ util = _valid_pct(block.get("utilization")) if isinstance(block, dict) else None
614
+ if util is not None:
615
+ return util
616
+ if key == "opus":
617
+ for quota in scoped_weekly_limits(data):
618
+ if quota["model"].casefold() == "opus":
619
+ return quota["utilization"]
620
+ return None
621
+
622
+
623
+ def unknown_cap_metrics(data: dict | None, caps: dict | None) -> list[str]:
624
+ """Explain fail-open omissions without exposing an arbitrary private payload."""
625
+ if not caps:
626
+ return []
627
+ if data is None or (isinstance(data.get("_ccx"), dict) and _is_stale(data)):
628
+ return [key for key, limit in caps.items() if limit is not None]
629
+ return [key for key, limit in caps.items()
630
+ if limit is not None and _metric_util(data, key) is None]
631
+
632
+
633
+ def cap_tripped(data: dict | None, caps: dict | None):
634
+ """Return first known exceeded cap; held/stale/unknown signals fail open."""
635
+ if not data or not caps:
636
+ return None
637
+ if isinstance(data.get("_ccx"), dict) and _is_stale(data):
638
+ return None
639
+ for key, cap in caps.items():
640
+ if cap is None:
641
+ continue
642
+ util = _metric_util(data, key)
643
+ if util is not None and util >= float(cap):
644
+ return (key, util, float(cap))
645
+ return None
646
+
647
+
648
+ _SUMMARY_QUOTA_FIELDS = ("five_hour", "seven_day")
649
+ _LEGACY_MODEL_FIELDS = ("seven_day_opus", "seven_day_sonnet")
650
+
651
+ def _summary_block(block) -> dict | None:
652
+ if block is None:
653
+ return None
654
+ if not isinstance(block, dict):
655
+ return None
656
+ out = {}
657
+ if "utilization" in block:
658
+ util = block.get("utilization")
659
+ validated = _valid_pct(util)
660
+ if validated is not None:
661
+ out["utilization"] = validated
662
+ resets_at = block.get("resets_at")
663
+ if _parse_epoch(resets_at) is not None:
664
+ out["resets_at"] = resets_at
665
+ return out or None
666
+
667
+
668
+ def scoped_weekly_limits(data: dict) -> list[dict]:
669
+ """Normalize the currently relevant per-model weekly quota catalog.
670
+
671
+ The ``limits`` catalog describes model-specific quotas. Consumers follow
672
+ that catalog so their UI does not depend on a fixed set of model names.
673
+ """
674
+ out = []
675
+ limits = data.get("limits") if isinstance(data, dict) else None
676
+ for item in limits if isinstance(limits, list) else ():
677
+ if not isinstance(item, dict) or item.get("kind") != "weekly_scoped":
678
+ continue
679
+ scope = item.get("scope")
680
+ model = scope.get("model") if isinstance(scope, dict) else None
681
+ name = model.get("display_name") if isinstance(model, dict) else None
682
+ percent = _valid_pct(item.get("percent"))
683
+ if not isinstance(name, str) or not name or percent is None:
684
+ continue
685
+ quota = {
686
+ "model": name,
687
+ "utilization": percent,
688
+ }
689
+ model_id = model.get("id")
690
+ if isinstance(model_id, str) and model_id:
691
+ quota["model_id"] = model_id
692
+ resets_at = item.get("resets_at")
693
+ if _parse_epoch(resets_at) is not None:
694
+ quota["resets_at"] = resets_at
695
+ if isinstance(item.get("is_active"), bool):
696
+ quota["is_active"] = item["is_active"]
697
+ out.append(quota)
698
+ # The control reply's model_scoped[] is additive. Never synthesize limits[]
699
+ # in the cache; only add missing models to the in-memory presentation.
700
+ scoped = data.get("model_scoped") if isinstance(data, dict) else None
701
+ seen_names = {item["model"].casefold() for item in out}
702
+ if isinstance(scoped, list):
703
+ for item in scoped:
704
+ if not isinstance(item, dict):
705
+ continue
706
+ name, util = item.get("display_name"), _valid_pct(item.get("utilization"))
707
+ if not isinstance(name, str) or not name or util is None or name.casefold() in seen_names:
708
+ continue
709
+ quota = {"model": name, "utilization": util}
710
+ if _parse_epoch(item.get("resets_at")) is not None:
711
+ quota["resets_at"] = item["resets_at"]
712
+ out.append(quota)
713
+ seen_names.add(name.casefold())
714
+ return out
715
+
716
+
717
+ def usage_summary(data: dict) -> dict:
718
+ """Compact public quota summary: stable actionable fields only.
719
+
720
+ Includes five_hour, seven_day, provider-announced model_quotas, and
721
+ extra_usage (when enabled). Non-null legacy per-model fields are retained
722
+ for cached payload compatibility. Omits all other raw API keys."""
723
+ meta = data.get("_ccx") or {}
724
+ if isinstance(meta, dict) and meta.get("unparsed"):
725
+ return {"unparsed": True, "raw": meta.get("raw"), "error": meta.get("error")}
726
+ out = {}
727
+ for key in _SUMMARY_QUOTA_FIELDS:
728
+ if key not in data:
729
+ continue
730
+ block = _summary_block(data.get(key))
731
+ if block is not None:
732
+ out[key] = block
733
+ model_quotas = scoped_weekly_limits(data)
734
+ if model_quotas:
735
+ out["model_quotas"] = model_quotas
736
+ else:
737
+ # Old caches may predate the dynamic limits catalog. Preserve actual
738
+ # legacy quota blocks, but omit null retired fields rather than showing
739
+ # misleading Opus/Sonnet 0% gauges.
740
+ for key in _LEGACY_MODEL_FIELDS:
741
+ block = data.get(key)
742
+ if not isinstance(block, dict):
743
+ continue
744
+ summary = _summary_block(block)
745
+ if summary is not None:
746
+ out[key] = summary
747
+ xu = data.get("extra_usage")
748
+ if isinstance(xu, dict) and xu.get("is_enabled"):
749
+ extra = {"enabled": True}
750
+ for key in ("utilization", "used_credits", "monthly_limit", "currency"):
751
+ value = xu.get(key)
752
+ if value is not None:
753
+ extra[key] = value
754
+ out["extra_usage"] = extra
755
+ return out
756
+
757
+
758
+ def _pct(s: str) -> float:
759
+ """argparse type for a 0–100 utilization percentage."""
760
+ try:
761
+ v = float(s)
762
+ except ValueError:
763
+ raise argparse.ArgumentTypeError(f"{s!r} is not a number")
764
+ if not (0.0 <= v <= 100.0):
765
+ raise argparse.ArgumentTypeError(f"cap must be between 0 and 100 (got {v})")
766
+ return v
767
+
768
+ def _add_cap_flags(p):
769
+ p.add_argument("--cap-5h", type=_pct, default=None, metavar="PCT",
770
+ help="Refuse to start when 5-hour usage is already at PCT%% (0–100), and stop "
771
+ "the turn if it reaches PCT%% while running (exit 7).")
772
+ p.add_argument("--cap-7d", type=_pct, default=None, metavar="PCT",
773
+ help="The same for 7-day usage.")
774
+ p.add_argument("--cap-opus", type=_pct, default=None, metavar="PCT",
775
+ help="Claude only: the same for 7-day Opus usage.")
776
+
777
+ def _caps_from_args(args) -> dict | None:
778
+ caps = {}
779
+ for k, attr in (("5h", "cap_5h"), ("7d", "cap_7d"), ("opus", "cap_opus")):
780
+ v = getattr(args, attr, None)
781
+ if v is not None:
782
+ caps[k] = v
783
+ return caps or None
784
+
785
+ def _merge_caps(caps_list) -> dict | None:
786
+ """Combine the caps of several sends that batch into one turn — strictest wins
787
+ (lowest threshold per metric, union of metrics)."""
788
+ merged = {}
789
+ for c in caps_list:
790
+ if not c:
791
+ continue
792
+ for k, v in c.items():
793
+ if v is None:
794
+ continue
795
+ merged[k] = min(v, merged[k]) if k in merged else v
796
+ return merged or None