cctally 1.77.0 → 1.78.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.
@@ -275,13 +275,22 @@ def cmd_statusline(args: argparse.Namespace) -> int:
275
275
  # side effect that runs AFTER the line is printed and is FULLY guarded:
276
276
  # persistence must NEVER break or slow rendering, so the whole call is
277
277
  # wrapped here (the feeder itself forks detached + swallows its own
278
- # errors, but this is belt-and-suspenders). Absence of rate_limits, a
279
- # lost persist lock, or the throttle window all degrade to a clean
280
- # no-op; the OAuth backfill covers the "no statusline feeding" case.
278
+ # errors, but this is belt-and-suspenders). Absence of rate_limits or a
279
+ # lost persist lock degrades to a clean no-op; the authoritative OAuth
280
+ # confirmation below covers the missing/stale-payload case.
281
281
  try:
282
282
  _statusline_persist(inp)
283
283
  except Exception:
284
284
  pass
285
+ # Claude Code may replay an unchanged rate_limits object across timer
286
+ # renders even after the provider counters have advanced. Confirm against
287
+ # the authoritative OAuth surface once per account-wide timer cycle. The
288
+ # helper is fully detached, shares hook-tick's throttle lock/marker, and
289
+ # honors the existing selected-freshness and 429-backoff gates.
290
+ try:
291
+ c._statusline_oauth_tick()
292
+ except Exception:
293
+ pass
285
294
  return 0
286
295
 
287
296
 
@@ -1229,6 +1238,122 @@ def _statusline_persist(parsed, *, sync_for_test: bool = False) -> None:
1229
1238
  c._release_persist_lock(lock_fd)
1230
1239
 
1231
1240
 
1241
+ def _try_acquire_statusline_oauth_lock() -> "int | None":
1242
+ """Take hook-tick's account-wide OAuth throttle lock without blocking."""
1243
+ try:
1244
+ _cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
1245
+ fd = os.open(
1246
+ _cctally_core.HOOK_TICK_THROTTLE_LOCK_PATH,
1247
+ os.O_WRONLY | os.O_CREAT,
1248
+ 0o644,
1249
+ )
1250
+ except OSError:
1251
+ return None
1252
+ try:
1253
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
1254
+ except OSError:
1255
+ try:
1256
+ os.close(fd)
1257
+ except OSError:
1258
+ pass
1259
+ return None
1260
+ return fd
1261
+
1262
+
1263
+ def _release_statusline_oauth_lock(fd: int) -> None:
1264
+ try:
1265
+ fcntl.flock(fd, fcntl.LOCK_UN)
1266
+ except OSError:
1267
+ pass
1268
+ try:
1269
+ os.close(fd)
1270
+ except OSError:
1271
+ pass
1272
+
1273
+
1274
+ def _run_statusline_oauth_refresh() -> None:
1275
+ """Run one already-throttled automatic OAuth confirmation."""
1276
+ c = _cctally()
1277
+ status, _ = c._hook_tick_oauth_refresh(
1278
+ throttle_seconds=float(_cctally_core.STATUSLINE_OAUTH_POLL_SECONDS)
1279
+ )
1280
+ if status.startswith("ok("):
1281
+ c._hook_tick_throttle_touch()
1282
+
1283
+
1284
+ def _fork_statusline_oauth_refresh(lock_fd: int) -> bool:
1285
+ """Detach one OAuth confirmation while keeping ``lock_fd`` in the child."""
1286
+ try:
1287
+ pid = os.fork()
1288
+ except OSError:
1289
+ return False
1290
+ if pid > 0:
1291
+ # Do not explicitly unlock: the child inherited the same open file
1292
+ # description and owns the account-wide lock until its refresh ends.
1293
+ try:
1294
+ os.close(lock_fd)
1295
+ except OSError:
1296
+ pass
1297
+ return True
1298
+
1299
+ try:
1300
+ try:
1301
+ os.setsid()
1302
+ except OSError:
1303
+ pass
1304
+ try:
1305
+ devnull = os.open(os.devnull, os.O_RDWR)
1306
+ os.dup2(devnull, 0)
1307
+ os.dup2(devnull, 1)
1308
+ os.dup2(devnull, 2)
1309
+ if devnull > 2:
1310
+ os.close(devnull)
1311
+ except OSError:
1312
+ pass
1313
+ _run_statusline_oauth_refresh()
1314
+ except BaseException:
1315
+ pass
1316
+ finally:
1317
+ _release_statusline_oauth_lock(lock_fd)
1318
+ os._exit(0)
1319
+
1320
+
1321
+ def _statusline_oauth_tick(*, sync_for_test: bool = False) -> None:
1322
+ """Schedule one bounded authoritative refresh from a statusline timer.
1323
+
1324
+ Fast prechecks avoid forks while selected data is fresh, a shared 429
1325
+ deadline is active, or another session/hook owns the account-wide poll.
1326
+ Every condition is rechecked under hook-tick's throttle lock before the
1327
+ detached child starts.
1328
+ """
1329
+ c = _cctally()
1330
+ interval = float(_cctally_core.STATUSLINE_OAUTH_POLL_SECONDS)
1331
+ if c._statusline_observe_age_seconds() < interval:
1332
+ return
1333
+ if c._oauth_backoff_remaining_seconds() > 0:
1334
+ return
1335
+ if c._hook_tick_throttle_age_seconds() < interval:
1336
+ return
1337
+ lock_fd = _try_acquire_statusline_oauth_lock()
1338
+ if lock_fd is None:
1339
+ return
1340
+ try:
1341
+ if c._statusline_observe_age_seconds() < interval:
1342
+ return
1343
+ if c._oauth_backoff_remaining_seconds() > 0:
1344
+ return
1345
+ if c._hook_tick_throttle_age_seconds() < interval:
1346
+ return
1347
+ if sync_for_test:
1348
+ _run_statusline_oauth_refresh()
1349
+ return
1350
+ if _fork_statusline_oauth_refresh(lock_fd):
1351
+ lock_fd = -1
1352
+ finally:
1353
+ if lock_fd >= 0:
1354
+ _release_statusline_oauth_lock(lock_fd)
1355
+
1356
+
1232
1357
  def _resolve_context_window(model_id, warn_once) -> "int | None":
1233
1358
  """Look up ``model_id`` in ``CLAUDE_MODEL_CONTEXT_WINDOWS``; fall back
1234
1359
  to a family-substring match against
@@ -0,0 +1,43 @@
1
+ """Strict outbound JSON contract shared by dashboard HTTP/SSE and Doctor.
2
+
3
+ Python's ``json.dumps`` defaults to emitting JavaScript-only ``NaN`` and
4
+ ``Infinity`` tokens. Browsers reject those tokens in ``JSON.parse`` and
5
+ ``Response.json``. Normalize supported JSON containers recursively, then keep
6
+ ``allow_nan=False`` as the final fail-loud guard. Unsupported objects and
7
+ non-finite mapping keys are deliberately not coerced.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import math
13
+ from typing import Any
14
+
15
+
16
+ def normalize_dashboard_json(value: Any) -> Any:
17
+ """Return a non-mutating JSON value with non-finite floats mapped to null."""
18
+ if isinstance(value, float):
19
+ return value if math.isfinite(value) else None
20
+ if isinstance(value, dict):
21
+ return {
22
+ key: normalize_dashboard_json(item)
23
+ for key, item in value.items()
24
+ }
25
+ if isinstance(value, list):
26
+ return [normalize_dashboard_json(item) for item in value]
27
+ if isinstance(value, tuple):
28
+ return tuple(normalize_dashboard_json(item) for item in value)
29
+ return value
30
+
31
+
32
+ def encode_dashboard_json(value: Any, **kwargs: Any) -> str:
33
+ """Serialize one outbound payload with browser-strict JSON semantics."""
34
+ return json.dumps(
35
+ normalize_dashboard_json(value),
36
+ allow_nan=False,
37
+ **kwargs,
38
+ )
39
+
40
+
41
+ def encode_dashboard_json_bytes(value: Any, **kwargs: Any) -> bytes:
42
+ """UTF-8 bytes companion for HTTP response bodies."""
43
+ return encode_dashboard_json(value, **kwargs).encode("utf-8")
@@ -83,9 +83,26 @@ def assign_collision_safe_project_labels(
83
83
  selection the exact string that terminal, JSON, and share emit.
84
84
  """
85
85
  values = tuple(entries)
86
+ assigned = collision_safe_project_label_map(
87
+ (entry.project_key, entry.project_label) for entry in values
88
+ )
89
+ return tuple(
90
+ replace(entry, display_label=assigned.get(entry.project_key, entry.project_label))
91
+ for entry in values
92
+ )
93
+
94
+
95
+ def collision_safe_project_label_map(
96
+ identities_and_labels: Iterable[tuple[str, str]],
97
+ ) -> dict[str, str]:
98
+ """Allocate the shared deterministic privacy-safe label contract.
99
+
100
+ Callers supply opaque internal identities. Only the returned labels are
101
+ presentation data; the identity keys must remain inside the adapter.
102
+ """
86
103
  keys_by_label: dict[str, set[str]] = defaultdict(set)
87
- for entry in values:
88
- keys_by_label[entry.project_label].add(entry.project_key)
104
+ for identity, label in identities_and_labels:
105
+ keys_by_label[label].add(identity)
89
106
  # Never let an allocator-owned ``label (N)`` shadow a literal project
90
107
  # label. Reserving every raw label also makes a later presentation pass
91
108
  # unnecessary, which avoids producing ``label (N) (N)``.
@@ -107,10 +124,7 @@ def assign_collision_safe_project_labels(
107
124
  assigned[key] = candidate
108
125
  used_labels.add(candidate)
109
126
  ordinal += 1
110
- return tuple(
111
- replace(entry, display_label=assigned.get(entry.project_key, entry.project_label))
112
- for entry in values
113
- )
127
+ return assigned
114
128
 
115
129
 
116
130
  def opaque_project_key(source: str, root_key: str, resolved_key: str) -> str:
package/bin/cctally CHANGED
@@ -558,6 +558,10 @@ _lib_log = _load_sibling("_lib_log")
558
558
  _lib_json_envelope = _load_sibling("_lib_json_envelope")
559
559
  stamp_schema_version = _lib_json_envelope.stamp_schema_version
560
560
 
561
+ # Strict outbound dashboard/Doctor JSON: recursive non-finite normalization
562
+ # followed by allow_nan=False serialization.
563
+ _lib_dashboard_json = _load_sibling("_lib_dashboard_json")
564
+
561
565
  _lib_changelog = _load_sibling("_lib_changelog")
562
566
  # Backward-compat re-export: callers and tests reach the helper through
563
567
  # ``cctally._release_read_latest_release_version`` (incl. monkeypatch
@@ -1336,6 +1340,7 @@ _build_statusline_injections = _cctally_statusline._build_statusline_injections
1336
1340
  # `cctally._statusline_persist` (test surface) + the `_cctally()` accessor
1337
1341
  # resolve them.
1338
1342
  _statusline_persist = _cctally_statusline._statusline_persist
1343
+ _statusline_oauth_tick = _cctally_statusline._statusline_oauth_tick
1339
1344
  _fork_persist = _cctally_statusline._fork_persist
1340
1345
  _record_args = _cctally_statusline._record_args
1341
1346
  _try_acquire_persist_lock = _cctally_statusline._try_acquire_persist_lock