cctally 1.68.0 → 1.69.1
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 +61 -0
- package/bin/_cctally_alerts.py +54 -2
- package/bin/_cctally_cache.py +644 -107
- package/bin/_cctally_cache_report.py +41 -17
- package/bin/_cctally_codex.py +109 -4
- package/bin/_cctally_config.py +368 -2
- package/bin/_cctally_core.py +168 -5
- package/bin/_cctally_dashboard.py +679 -5
- package/bin/_cctally_dashboard_conversation.py +9 -4
- package/bin/_cctally_dashboard_envelope.py +110 -1
- package/bin/_cctally_dashboard_share.py +557 -79
- package/bin/_cctally_db.py +303 -17
- package/bin/_cctally_diff.py +49 -16
- package/bin/_cctally_doctor.py +118 -0
- package/bin/_cctally_forecast.py +12 -4
- package/bin/_cctally_parser.py +298 -92
- package/bin/_cctally_project.py +24 -8
- package/bin/_cctally_quota.py +1381 -0
- package/bin/_cctally_record.py +319 -14
- package/bin/_cctally_refresh.py +105 -3
- package/bin/_cctally_reporting.py +7 -1
- package/bin/_cctally_setup.py +343 -113
- package/bin/_cctally_statusline.py +228 -0
- package/bin/_cctally_tui.py +787 -7
- package/bin/_lib_alert_axes.py +11 -0
- package/bin/_lib_codex_hooks.py +367 -0
- package/bin/_lib_conversation_query.py +156 -67
- package/bin/_lib_doctor.py +202 -0
- package/bin/_lib_jsonl.py +529 -162
- package/bin/_lib_quota.py +566 -0
- package/bin/_lib_share.py +152 -34
- package/bin/_lib_snapshot_cache.py +26 -0
- package/bin/_lib_source_identity.py +74 -0
- package/bin/cctally +324 -10
- package/dashboard/static/assets/index-CBbErI-P.js +80 -0
- package/dashboard/static/assets/index-kDDVOLa_.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +5 -1
- package/dashboard/static/assets/index-BybNp_Di.js +0 -80
- package/dashboard/static/assets/index-DgAmLK65.css +0 -1
|
@@ -28,12 +28,14 @@ from __future__ import annotations
|
|
|
28
28
|
|
|
29
29
|
import argparse
|
|
30
30
|
import datetime as dt
|
|
31
|
+
import fcntl
|
|
31
32
|
import json
|
|
32
33
|
import os
|
|
33
34
|
import pathlib
|
|
34
35
|
import sys
|
|
35
36
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
36
37
|
|
|
38
|
+
import _cctally_core
|
|
37
39
|
import _lib_statusline
|
|
38
40
|
from _cctally_core import _command_as_of, eprint, open_db
|
|
39
41
|
|
|
@@ -259,9 +261,235 @@ def cmd_statusline(args: argparse.Namespace) -> int:
|
|
|
259
261
|
eprint(f"cctally statusline: render failed: {exc}")
|
|
260
262
|
return 1
|
|
261
263
|
print(line)
|
|
264
|
+
|
|
265
|
+
# Persist the CC-provided rate_limits as the PRIMARY automatic usage
|
|
266
|
+
# writer (spec 2026-07-17-usage-statusline-fallback). This is a pure
|
|
267
|
+
# side effect that runs AFTER the line is printed and is FULLY guarded:
|
|
268
|
+
# persistence must NEVER break or slow rendering, so the whole call is
|
|
269
|
+
# wrapped here (the feeder itself forks detached + swallows its own
|
|
270
|
+
# errors, but this is belt-and-suspenders). Absence of rate_limits, a
|
|
271
|
+
# lost persist lock, or the throttle window all degrade to a clean
|
|
272
|
+
# no-op; the OAuth backfill covers the "no statusline feeding" case.
|
|
273
|
+
try:
|
|
274
|
+
_statusline_persist(inp)
|
|
275
|
+
except Exception:
|
|
276
|
+
pass
|
|
262
277
|
return 0
|
|
263
278
|
|
|
264
279
|
|
|
280
|
+
# =========================================================================
|
|
281
|
+
# Statusline usage-persistence feeder (spec 2026-07-17)
|
|
282
|
+
# =========================================================================
|
|
283
|
+
#
|
|
284
|
+
# The statusline is the PRIMARY automatic writer of weekly/5h usage
|
|
285
|
+
# snapshots: it persists the CC-provided stdin `rate_limits` (which stay
|
|
286
|
+
# current as a side effect of inference, even while /api/oauth/usage is
|
|
287
|
+
# 429-banned) through the UNCHANGED cmd_record_usage kernel. Guards:
|
|
288
|
+
# - a cross-process flock (STATUSLINE_PERSIST_LOCK_PATH) so a multi-
|
|
289
|
+
# session render herd yields at most one snapshot per throttle window;
|
|
290
|
+
# - a liveness throttle keyed off the observation marker (NOT snapshot
|
|
291
|
+
# age — the kernel dedups unchanged percentages without refreshing
|
|
292
|
+
# captured_at, so snapshot age keeps growing while the statusline is
|
|
293
|
+
# actively feeding);
|
|
294
|
+
# - a DETACHED child so the render stays fast (cmd_record_usage may sync
|
|
295
|
+
# weekly cost, scan 5h totals, and evaluate budget axes).
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _try_acquire_persist_lock() -> "int | None":
|
|
299
|
+
"""Non-blocking acquire of the cross-process statusline persist lock.
|
|
300
|
+
|
|
301
|
+
Returns the open fd on success; ``None`` when another render already
|
|
302
|
+
holds it (EWOULDBLOCK) or the lock file can't be opened/locked — in
|
|
303
|
+
which case the caller renders without forking."""
|
|
304
|
+
try:
|
|
305
|
+
_cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
306
|
+
fd = os.open(
|
|
307
|
+
_cctally_core.STATUSLINE_PERSIST_LOCK_PATH,
|
|
308
|
+
os.O_WRONLY | os.O_CREAT, 0o644,
|
|
309
|
+
)
|
|
310
|
+
except OSError:
|
|
311
|
+
return None
|
|
312
|
+
try:
|
|
313
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
314
|
+
except OSError:
|
|
315
|
+
try:
|
|
316
|
+
os.close(fd)
|
|
317
|
+
except OSError:
|
|
318
|
+
pass
|
|
319
|
+
return None
|
|
320
|
+
return fd
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _release_persist_lock(fd: "int | None") -> None:
|
|
324
|
+
"""Release + close a persist-lock fd (best-effort)."""
|
|
325
|
+
if fd is None or fd < 0:
|
|
326
|
+
return
|
|
327
|
+
try:
|
|
328
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
329
|
+
except OSError:
|
|
330
|
+
pass
|
|
331
|
+
try:
|
|
332
|
+
os.close(fd)
|
|
333
|
+
except OSError:
|
|
334
|
+
pass
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _record_args(*, percent, resets_at, five_hour_percent, five_hour_resets_at,
|
|
338
|
+
source):
|
|
339
|
+
"""Build the cmd_record_usage Namespace the feeder passes to the kernel.
|
|
340
|
+
|
|
341
|
+
Percents pass through UNTOUCHED — the kernel's ingress `_normalize_percent`
|
|
342
|
+
is the single clamp site (no new clamp here). Epochs are stringified to
|
|
343
|
+
match the shape cmd_record_usage expects (`int(args.resets_at)`)."""
|
|
344
|
+
return argparse.Namespace(
|
|
345
|
+
percent=percent,
|
|
346
|
+
resets_at=str(int(resets_at)),
|
|
347
|
+
five_hour_percent=five_hour_percent,
|
|
348
|
+
five_hour_resets_at=(
|
|
349
|
+
str(int(five_hour_resets_at))
|
|
350
|
+
if five_hour_resets_at is not None else None
|
|
351
|
+
),
|
|
352
|
+
source=source,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _fork_persist(args, parent_lock_fd: int) -> None:
|
|
357
|
+
"""Run cmd_record_usage in a DETACHED child so the render stays fast.
|
|
358
|
+
|
|
359
|
+
Lifecycle (spec §2, Codex P2-3): single ``fork()``; **on fork() failure
|
|
360
|
+
SKIP persistence entirely** (unlike hook-tick we must NOT run inline —
|
|
361
|
+
cmd_record_usage may sync weekly cost / scan 5h totals / evaluate budget
|
|
362
|
+
axes, any of which would stall the render). In the child: ``setsid()``,
|
|
363
|
+
immediately redirect fd 0/1/2 to /dev/null (an inherited stdout pipe
|
|
364
|
+
would delay the statusline's EOF), then record and ``os._exit(0)``.
|
|
365
|
+
|
|
366
|
+
Lock contract: the PARENT releases only its OWN fd (in the caller's
|
|
367
|
+
``finally``); the child CLOSES its inherited copy of the parent fd and
|
|
368
|
+
RE-ACQUIRES the persist lock on a fresh, INDEPENDENT fd (blocking) — so
|
|
369
|
+
the parent's release cannot race a second render into a duplicate insert.
|
|
370
|
+
The child then re-checks the observation marker UNDER that held lock and
|
|
371
|
+
records only if still stale, which is the authoritative herd guard: a
|
|
372
|
+
concurrent render's non-blocking acquire fails until this child has
|
|
373
|
+
recorded AND touched the marker."""
|
|
374
|
+
c = _cctally()
|
|
375
|
+
try:
|
|
376
|
+
pid = os.fork()
|
|
377
|
+
except OSError:
|
|
378
|
+
# Out of pids/memory — skip persistence, render fast (spec §2).
|
|
379
|
+
return
|
|
380
|
+
if pid > 0:
|
|
381
|
+
# Parent: return at once; the caller's finally releases parent_lock_fd.
|
|
382
|
+
return
|
|
383
|
+
|
|
384
|
+
# --- child ---
|
|
385
|
+
try:
|
|
386
|
+
# Drop the inherited copy of the parent's lock fd so only the parent's
|
|
387
|
+
# own fd holds the shared lock; the child owns the lock ONLY via the
|
|
388
|
+
# independent fd re-acquired below (avoids a self-block on our own
|
|
389
|
+
# inherited exclusive lock).
|
|
390
|
+
try:
|
|
391
|
+
os.close(parent_lock_fd)
|
|
392
|
+
except OSError:
|
|
393
|
+
pass
|
|
394
|
+
try:
|
|
395
|
+
os.setsid()
|
|
396
|
+
except OSError:
|
|
397
|
+
pass
|
|
398
|
+
# Detach stdio immediately so nothing keeps the render's pipe open.
|
|
399
|
+
try:
|
|
400
|
+
devnull = os.open(os.devnull, os.O_RDWR)
|
|
401
|
+
os.dup2(devnull, 0)
|
|
402
|
+
os.dup2(devnull, 1)
|
|
403
|
+
os.dup2(devnull, 2)
|
|
404
|
+
if devnull > 2:
|
|
405
|
+
os.close(devnull)
|
|
406
|
+
except OSError:
|
|
407
|
+
pass
|
|
408
|
+
|
|
409
|
+
child_fd = -1
|
|
410
|
+
try:
|
|
411
|
+
child_fd = os.open(
|
|
412
|
+
_cctally_core.STATUSLINE_PERSIST_LOCK_PATH,
|
|
413
|
+
os.O_WRONLY | os.O_CREAT, 0o644,
|
|
414
|
+
)
|
|
415
|
+
# BLOCKING: wait for the parent to release its copy, then hold the
|
|
416
|
+
# lock across record + marker-touch so a concurrent render can't
|
|
417
|
+
# observe a stale marker and double-insert.
|
|
418
|
+
fcntl.flock(child_fd, fcntl.LOCK_EX)
|
|
419
|
+
except OSError:
|
|
420
|
+
child_fd = -1
|
|
421
|
+
try:
|
|
422
|
+
throttle = float(_cctally_core.STATUSLINE_PERSIST_THROTTLE_SECONDS)
|
|
423
|
+
if c._statusline_observe_age_seconds() >= throttle:
|
|
424
|
+
c.cmd_record_usage(args)
|
|
425
|
+
# Touch the liveness marker even on a dedup no-op return.
|
|
426
|
+
c._statusline_observe_touch()
|
|
427
|
+
finally:
|
|
428
|
+
if child_fd >= 0:
|
|
429
|
+
try:
|
|
430
|
+
fcntl.flock(child_fd, fcntl.LOCK_UN)
|
|
431
|
+
except OSError:
|
|
432
|
+
pass
|
|
433
|
+
try:
|
|
434
|
+
os.close(child_fd)
|
|
435
|
+
except OSError:
|
|
436
|
+
pass
|
|
437
|
+
except BaseException:
|
|
438
|
+
# A detached child must never surface a traceback onto the render.
|
|
439
|
+
pass
|
|
440
|
+
finally:
|
|
441
|
+
os._exit(0)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _statusline_persist(parsed, *, sync_for_test: bool = False) -> None:
|
|
445
|
+
"""Persist the parsed CC `rate_limits` (the primary automatic writer).
|
|
446
|
+
|
|
447
|
+
Fully guarded — cmd_statusline also wraps this in try/except, and every
|
|
448
|
+
step degrades to a clean no-op. ``sync_for_test=True`` runs the kernel
|
|
449
|
+
INLINE (no fork) so persistence tests are deterministic and no detached
|
|
450
|
+
child outlives fixture cleanup."""
|
|
451
|
+
c = _cctally()
|
|
452
|
+
# 1. Require a usable 7d reading. Absence is a clean no-op (older CC / CC
|
|
453
|
+
# not supplying rate_limits — the OAuth backfill covers that case).
|
|
454
|
+
if parsed.rate_limits_7d_pct is None or parsed.rate_limits_7d_resets_at is None:
|
|
455
|
+
return
|
|
456
|
+
# 2. 5h pair-gate: pass both or neither. An inactive 5h window arrives as
|
|
457
|
+
# {used_percentage: 0, resets_at: null}; the parser surfaces the two
|
|
458
|
+
# fields independently, so drop the whole pair when either is missing
|
|
459
|
+
# (mirrors both OAuth paths).
|
|
460
|
+
five_pct = parsed.rate_limits_5h_pct
|
|
461
|
+
five_reset = parsed.rate_limits_5h_resets_at
|
|
462
|
+
if five_pct is None or five_reset is None:
|
|
463
|
+
five_pct = None
|
|
464
|
+
five_reset = None
|
|
465
|
+
# 3. Non-blocking cross-process lock — losers render without forking.
|
|
466
|
+
lock_fd = _try_acquire_persist_lock()
|
|
467
|
+
if lock_fd is None:
|
|
468
|
+
return
|
|
469
|
+
try:
|
|
470
|
+
# 4. Liveness throttle UNDER the lock (observation marker, NOT
|
|
471
|
+
# snapshot age).
|
|
472
|
+
throttle = float(_cctally_core.STATUSLINE_PERSIST_THROTTLE_SECONDS)
|
|
473
|
+
if c._statusline_observe_age_seconds() < throttle:
|
|
474
|
+
return
|
|
475
|
+
args = _record_args(
|
|
476
|
+
percent=parsed.rate_limits_7d_pct,
|
|
477
|
+
resets_at=parsed.rate_limits_7d_resets_at,
|
|
478
|
+
five_hour_percent=five_pct,
|
|
479
|
+
five_hour_resets_at=five_reset,
|
|
480
|
+
source="statusline",
|
|
481
|
+
)
|
|
482
|
+
# 5. Run the kernel — detached unless test-sync.
|
|
483
|
+
if sync_for_test:
|
|
484
|
+
c.cmd_record_usage(args)
|
|
485
|
+
# Touch the liveness marker even on a dedup no-op return.
|
|
486
|
+
c._statusline_observe_touch()
|
|
487
|
+
return
|
|
488
|
+
_fork_persist(args, lock_fd)
|
|
489
|
+
finally:
|
|
490
|
+
_release_persist_lock(lock_fd)
|
|
491
|
+
|
|
492
|
+
|
|
265
493
|
def _resolve_context_window(model_id, warn_once) -> "int | None":
|
|
266
494
|
"""Look up ``model_id`` in ``CLAUDE_MODEL_CONTEXT_WINDOWS``; fall back
|
|
267
495
|
to a family-substring match against
|