ccx-native 0.0.1 → 0.1.0

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