cctally 1.67.1 → 1.69.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 (40) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/bin/_cctally_alerts.py +54 -2
  3. package/bin/_cctally_cache.py +754 -109
  4. package/bin/_cctally_cache_report.py +41 -17
  5. package/bin/_cctally_codex.py +109 -4
  6. package/bin/_cctally_config.py +368 -2
  7. package/bin/_cctally_core.py +187 -15
  8. package/bin/_cctally_dashboard.py +679 -5
  9. package/bin/_cctally_dashboard_conversation.py +9 -4
  10. package/bin/_cctally_dashboard_envelope.py +110 -1
  11. package/bin/_cctally_dashboard_share.py +557 -79
  12. package/bin/_cctally_db.py +366 -17
  13. package/bin/_cctally_diff.py +49 -16
  14. package/bin/_cctally_doctor.py +131 -0
  15. package/bin/_cctally_forecast.py +12 -4
  16. package/bin/_cctally_parser.py +321 -92
  17. package/bin/_cctally_project.py +24 -8
  18. package/bin/_cctally_quota.py +1381 -0
  19. package/bin/_cctally_record.py +319 -14
  20. package/bin/_cctally_refresh.py +105 -3
  21. package/bin/_cctally_reporting.py +7 -1
  22. package/bin/_cctally_setup.py +343 -113
  23. package/bin/_cctally_statusline.py +228 -0
  24. package/bin/_cctally_tui.py +787 -7
  25. package/bin/_lib_alert_axes.py +11 -0
  26. package/bin/_lib_codex_hooks.py +367 -0
  27. package/bin/_lib_conversation_query.py +156 -67
  28. package/bin/_lib_doctor.py +238 -0
  29. package/bin/_lib_jsonl.py +529 -162
  30. package/bin/_lib_quota.py +566 -0
  31. package/bin/_lib_share.py +152 -34
  32. package/bin/_lib_snapshot_cache.py +26 -0
  33. package/bin/_lib_source_identity.py +74 -0
  34. package/bin/cctally +325 -10
  35. package/dashboard/static/assets/index-CBbErI-P.js +80 -0
  36. package/dashboard/static/assets/index-kDDVOLa_.css +1 -0
  37. package/dashboard/static/dashboard.html +2 -2
  38. package/package.json +5 -1
  39. package/dashboard/static/assets/index-BybNp_Di.js +0 -80
  40. 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