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.
- package/CHANGELOG.md +15 -0
- package/bin/_cctally_core.py +7 -4
- package/bin/_cctally_dashboard.py +177 -38
- package/bin/_cctally_dashboard_conversation.py +3 -2
- package/bin/_cctally_dashboard_sources.py +10 -2
- package/bin/_cctally_doctor.py +19 -3
- package/bin/_cctally_statusline.py +128 -3
- package/bin/_lib_dashboard_json.py +43 -0
- package/bin/_lib_source_analytics.py +20 -6
- package/bin/cctally +5 -0
- package/dashboard/static/assets/index-CkTe6VJt.js +87 -0
- package/dashboard/static/assets/index-DsEXuMpq.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-CvcCeA4L.css +0 -1
- package/dashboard/static/assets/index-DLNXAevv.js +0 -87
|
@@ -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
|
|
279
|
-
# lost persist lock
|
|
280
|
-
#
|
|
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
|
|
88
|
-
keys_by_label[
|
|
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
|
|
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
|