cctally 1.110.0 → 1.111.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 CHANGED
@@ -5,6 +5,12 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.111.0] - 2026-09-24
9
+
10
+ ### Fixed
11
+ - After `cctally update` or a Homebrew upgrade installs a newer version, a running `dashboard` restarts itself so new prices and code apply. Restart it yourself once after installing this release, and after any manual `npm install -g`.
12
+ - A `dashboard` restart after an update no longer leaves an extra Node process running on npm installs.
13
+
8
14
  ## [1.110.0] - 2026-09-22
9
15
 
10
16
  ### Added
@@ -80,6 +80,7 @@ def _init_paths_from_env() -> None:
80
80
  global UPDATE_STATE_PATH, UPDATE_SUPPRESS_PATH
81
81
  global UPDATE_LOCK_PATH, UPDATE_LOG_PATH, UPDATE_LOG_ROTATED_PATH
82
82
  global UPDATE_CHECK_LAST_FETCH_PATH, CLAUDE_SETTINGS_PATH
83
+ global INSTALL_SUCCESS_RECORD_PATH
83
84
  global CLAUDE_PROJECTS_DIR, CLAUDE_JSON_PATH
84
85
  global TELEMETRY_INSTALL_ID_PATH, TELEMETRY_LAST_BEAT_PATH
85
86
  global TELEMETRY_NOTICE_SHOWN_PATH, TELEMETRY_FIRST_SEEN_PATH
@@ -195,6 +196,8 @@ def _init_paths_from_env() -> None:
195
196
  UPDATE_LOG_PATH = APP_DIR / "update.log"
196
197
  UPDATE_LOG_ROTATED_PATH = APP_DIR / "update.log.1"
197
198
  UPDATE_CHECK_LAST_FETCH_PATH = APP_DIR / "update-check.last-fetch"
199
+ # Written only by the install paths (#868), never by an update check.
200
+ INSTALL_SUCCESS_RECORD_PATH = APP_DIR / "install-success.json"
198
201
 
199
202
  # Anonymous install-count telemetry markers (see spec 2026-07-07).
200
203
  # All four derive from APP_DIR and are re-bound here so a redirected
@@ -712,10 +712,6 @@ from _lib_aggregators import (
712
712
  _finalize_bucket,
713
713
  )
714
714
  from _lib_fmt import stable_sum
715
- # The MODULE, not just the names: #714's `pricing_snapshot_context` has to be
716
- # reached through the live module object so a test that swaps the snapshot is
717
- # seen by the request, build and sync entries below.
718
- import _lib_pricing
719
715
  from _lib_pricing import (_calculate_entry_cost, _chip_for_model,
720
716
  _short_model_name, claude_usage_dict)
721
717
  from _lib_five_hour import _canonical_5h_window_key, _round_to_ten_minutes
@@ -2272,13 +2268,6 @@ def _make_dashboard_run_iteration(
2272
2268
 
2273
2269
  def run_iteration(batch=None) -> dict:
2274
2270
  warnings: list = []
2275
- # #714: the whole snapshot build runs on ONE pricing revision. A swap
2276
- # landing part-way through would publish a snapshot whose panels were
2277
- # priced from two different tables.
2278
- with _lib_pricing.pricing_snapshot_context():
2279
- return _run_iteration_body(batch, warnings)
2280
-
2281
- def _run_iteration_body(batch, warnings: list) -> dict:
2282
2271
  if batch is not None and batch[1] and not skip_sync:
2283
2272
  with sync_lock:
2284
2273
  result = _refresh_usage_inproc()
@@ -2556,103 +2545,38 @@ def _wake_schema_writer() -> None:
2556
2545
  _SCHEMA_WAKE_THREAD = None
2557
2546
 
2558
2547
 
2559
- #: How often the pricing watch stats the deployed pricing file. A monotonic
2560
- #: cadence, because a wall-clock one would re-poll or stall across a system
2561
- #: clock change.
2562
- _PRICING_WATCH_INTERVAL_S = 60.0
2563
-
2564
-
2565
- class _PricingWatch:
2566
- """Detect a replaced `_lib_pricing.py` and adopt it in process (#714).
2548
+ _INSTALL_WATCH = None
2567
2549
 
2568
- An installer overwrites the deployed file under a running dashboard.
2569
- Nothing about that reaches this process: every CLI invocation re-imports
2570
- and picks the new tables up, and only a long-lived process holds the old
2571
- ones.
2572
2550
 
2573
- STAT FIRST, PARSE SECOND. The poll compares an inode/size/mtime signature
2574
- and parses only when it changed, so the steady state is one `stat` a
2575
- minute rather than an `ast.parse` of a 1,400-line file.
2551
+ def _start_install_watch(boot):
2552
+ """Own the install watch on its own lifecycle thread, in every mode (#868).
2576
2553
 
2577
- NEVER RAISES OUT OF `poll_once`. The file it reads is one an installer may
2578
- be part-way through writing, so a half-written file is the ordinary case
2579
- rather than an exceptional one; a raising poll would end the thread and
2580
- leave the process permanently stale, which is the very condition this
2581
- exists to clear.
2554
+ Not started from a git checkout, where file changes are development work,
2555
+ not installs.
2582
2556
  """
2583
-
2584
- def __init__(self, source_path=None, interval_s=None):
2585
- self.source_path = pathlib.Path(
2586
- source_path if source_path is not None
2587
- else sys.modules["_lib_pricing"].__file__)
2588
- self.interval_s = (_PRICING_WATCH_INTERVAL_S if interval_s is None
2589
- else interval_s)
2590
- self._signature = None
2591
- self._stop = threading.Event()
2592
-
2593
- def _current_signature(self):
2594
- try:
2595
- st = self.source_path.stat()
2596
- except OSError:
2597
- return None
2598
- return (st.st_dev, st.st_ino, st.st_size, st.st_mtime_ns)
2599
-
2600
- def poll_once(self) -> bool:
2601
- """One check. Returns True iff a new snapshot was adopted."""
2602
- try:
2603
- signature = self._current_signature()
2604
- if signature is None or signature == self._signature:
2605
- return False
2606
- self._signature = signature
2607
- pricing = sys.modules["_lib_pricing"]
2608
- candidate = pricing.read_pricing_snapshot_from_source(
2609
- self.source_path)
2610
- if not pricing.adopt_pricing_candidate(candidate):
2611
- return False
2612
- eprint("[pricing] adopted pricing revision "
2613
- f"{candidate.snapshot_date} without restarting")
2614
- return True
2615
- except Exception: # noqa: BLE001
2616
- # Including `PricingCandidateRejected`: a rejected candidate is a
2617
- # normal outcome, and the signature is already recorded so the
2618
- # same bad file is not re-parsed every minute.
2619
- return False
2620
-
2621
- def run(self) -> None:
2622
- while not self._stop.wait(self.interval_s):
2623
- self.poll_once()
2624
-
2625
- def stop(self) -> None:
2626
- self._stop.set()
2627
-
2628
-
2629
- _PRICING_WATCH: "_PricingWatch | None" = None
2630
-
2631
-
2632
- def _start_pricing_watch() -> "_PricingWatch | None":
2633
- """Own the pricing detector on a lifecycle thread that runs in BOTH modes.
2634
-
2635
- Deliberately not `_DashboardSyncThread` and not the conversation sync
2636
- thread: `cmd_dashboard` disables both under `--no-sync`, so a detector in
2637
- either would silently have no owner exactly where a long-lived process is
2638
- most likely to run.
2639
- """
2640
- global _PRICING_WATCH
2641
- if _PRICING_WATCH is not None:
2642
- return _PRICING_WATCH
2557
+ global _INSTALL_WATCH
2558
+ if _cctally_core._is_dev_checkout():
2559
+ return None
2560
+ if _INSTALL_WATCH is not None:
2561
+ return _INSTALL_WATCH
2643
2562
  try:
2644
- watch = _PricingWatch()
2645
- watch.poll_once() # establish the baseline signature; adopts nothing
2646
- thread = threading.Thread(
2647
- target=watch.run, name="cctally-pricing-watch", daemon=True)
2648
- thread.start()
2563
+ watch = InstallWatch(
2564
+ boot, worker_fn=lambda: getattr(_cctally(), "_UPDATE_WORKER", None))
2565
+ watch.start()
2649
2566
  except Exception as exc: # noqa: BLE001
2650
- eprint(f"[pricing] watch not started ({exc})")
2567
+ eprint(f"[update] install watch not started ({exc})")
2651
2568
  return None
2652
- _PRICING_WATCH = watch
2569
+ _INSTALL_WATCH = watch
2653
2570
  return watch
2654
2571
 
2655
2572
 
2573
+ def _stop_install_watch() -> None:
2574
+ global _INSTALL_WATCH
2575
+ watch, _INSTALL_WATCH = _INSTALL_WATCH, None
2576
+ if watch is not None:
2577
+ watch.stop()
2578
+
2579
+
2656
2580
  def _dashboard_maybe_prune_retention() -> None:
2657
2581
  """#313 P3 (F7): throttled transcript retention prune driven from the
2658
2582
  independent conversation sync thread. Opens a dedicated transcript
@@ -2812,18 +2736,7 @@ def _conversation_sync_pass() -> str:
2812
2736
  therefore NO SECOND OPEN ATTEMPT AFTER A FAILED OPEN — stated that narrowly
2813
2737
  because a successful pass opens the store twice by design, once here and
2814
2738
  once inside the prune.
2815
-
2816
- #714: the whole pass runs on ONE pricing revision. This pass writes
2817
- materialized cost, so a swap landing between the Claude leg and the Codex
2818
- leg would derive one store's rollup from two revisions — the ordered-write
2819
- guard would not even see it, because both halves are the same process.
2820
2739
  """
2821
- with _lib_pricing.pricing_snapshot_context():
2822
- return _conversation_sync_pass_body()
2823
-
2824
-
2825
- def _conversation_sync_pass_body() -> str:
2826
- """The body of `_conversation_sync_pass`, run under its pricing context."""
2827
2740
  try:
2828
2741
  conn = open_conversations_db()
2829
2742
  except (OSError, sqlite3.DatabaseError) as exc:
@@ -3375,6 +3288,14 @@ def _DashboardUpdateCheckThread(*args, **kwargs):
3375
3288
  return sys.modules["cctally"]._DashboardUpdateCheckThread(*args, **kwargs)
3376
3289
 
3377
3290
 
3291
+ def capture_install_boot_state(*args, **kwargs):
3292
+ return sys.modules["cctally"].capture_install_boot_state(*args, **kwargs)
3293
+
3294
+
3295
+ def InstallWatch(*args, **kwargs):
3296
+ return sys.modules["cctally"].InstallWatch(*args, **kwargs)
3297
+
3298
+
3378
3299
  # Module-level __getattr__ — lazy-resolves a handful of cctally globals at
3379
3300
  # attribute-access time (used for the ``except cctally.SomeError`` sites
3380
3301
  # that need real class identity, and for non-callable constants that the
@@ -8610,19 +8531,6 @@ _GET_ROUTES = (
8610
8531
  ("scope", "endpoint.conversation_detail"), True),
8611
8532
  )
8612
8533
 
8613
- #: Handlers that block for the life of a client connection rather than for one
8614
- #: response. `_dispatch` must NOT pin a pricing revision around these: the pin
8615
- #: would last as long as the stream, so a viewer who left a tab open would keep
8616
- #: pricing from whichever revision was live when the tab connected — the exact
8617
- #: staleness #714 removes. Both are streams that carry no cost of their own;
8618
- #: the envelopes they forward are priced by the build that produced them, which
8619
- #: has its own context.
8620
- _STREAMING_ROUTE_HANDLERS = frozenset({
8621
- "_serve_api_events",
8622
- "_handle_get_update_stream",
8623
- "_handle_get_conversation_events",
8624
- })
8625
-
8626
8534
  _POST_ROUTES = (
8627
8535
  ("exact", "/api/auth", "_handle_post_auth", None, False),
8628
8536
  ("exact", "/api/sync", "_handle_post_sync", None, False),
@@ -8758,13 +8666,6 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
8758
8666
  eleven path-taking handlers. (NOTE: returning a bool, not ``fn()`` —
8759
8667
  the void handlers all return None, so a None-return contract could not
8760
8668
  tell "handled" from "unmatched".)
8761
-
8762
- #714: every matched route runs inside a pricing-snapshot context, so a
8763
- response is never assembled from two pricing revisions when an
8764
- installer replaces `_lib_pricing.py` mid-request. The two long-lived
8765
- streaming routes are excluded — a context entered there would pin one
8766
- revision for the whole life of the stream, which is minutes to hours
8767
- and the opposite of what the reload exists to achieve.
8768
8669
  """
8769
8670
  path = self.path.split("?", 1)[0]
8770
8671
  for kind, pattern, name, perf, wants_path in table:
@@ -8778,19 +8679,14 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
8778
8679
  continue
8779
8680
  fn = getattr(self, name)
8780
8681
  args = (path,) if wants_path else ()
8781
- if name in _STREAMING_ROUTE_HANDLERS:
8782
- pinned = contextlib.nullcontext()
8783
- else:
8784
- pinned = _lib_pricing.pricing_snapshot_context()
8785
- with pinned:
8786
- if perf is None:
8682
+ if perf is None:
8683
+ fn(*args)
8684
+ elif perf[0] == "scope":
8685
+ with self._perf_scope(perf[1]):
8686
+ fn(*args)
8687
+ else: # "phase"
8688
+ with self._perf_gate().phase(perf[1]):
8787
8689
  fn(*args)
8788
- elif perf[0] == "scope":
8789
- with self._perf_scope(perf[1]):
8790
- fn(*args)
8791
- else: # "phase"
8792
- with self._perf_gate().phase(perf[1]):
8793
- fn(*args)
8794
8690
  return True
8795
8691
  return False
8796
8692
 
@@ -11731,7 +11627,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
11731
11627
  as an explicit user pin; the worker resolves the selected channel
11732
11628
  afresh. CSRF-gated. Returns
11733
11629
  202 + ``{"run_id": ...}`` on accept; 409 + ``{"run_id_in_progress": ...}``
11734
- when another run is already in progress.
11630
+ when another run is already in progress; 503 + ``{"error": ...}`` while
11631
+ the install watcher holds the automatic-restart claim (#868), which
11632
+ the unchanged client shows as a failed start.
11735
11633
  """
11736
11634
  if not self._check_origin_csrf():
11737
11635
  return
@@ -11753,7 +11651,11 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
11753
11651
  # The dashboard has no explicit-version input. Older clients sent the
11754
11652
  # cached beta target here; trusting it as a pin recreates #342 whenever
11755
11653
  # the registry advances while the modal is open.
11756
- accepted, run_id = worker.start(None)
11654
+ try:
11655
+ accepted, run_id = worker.start(None)
11656
+ except sys.modules["cctally"].AutomaticRestartInProgress as exc:
11657
+ self._respond_json(503, {"error": str(exc)})
11658
+ return
11757
11659
  if accepted:
11758
11660
  self._respond_json(202, {"run_id": run_id})
11759
11661
  else:
@@ -12339,16 +12241,20 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
12339
12241
  _cctally_core.enable_quota_projection_reconciliation()
12340
12242
 
12341
12243
  # Spec §5.7: capture the un-mutated argv + PATH-resolved entrypoint
12342
- # at boot so the in-place ``execvp`` after a successful update
12343
- # re-enters the user-facing wrapper (npm Node shim → CCTALLY_PYTHON
12344
- # honoured; brew symlink → post-upgrade Python script). Module-level
12345
- # globals live in cctally (declared at L23205 pre-extract); we write
12346
- # them via the module-proxy so ``UpdateWorker`` (in _cctally_update)
12347
- # reads the right values via ``cctally.X`` at call time.
12244
+ # at boot. ``_resolve_execvp_target`` derives the in-place restart
12245
+ # from them: an npm install execs Python directly on its own
12246
+ # ``bin/cctally`` (no added Node process, #868), and a brew install
12247
+ # re-enters the symlink into the newly linked keg. Module-level
12248
+ # globals live in cctally; we write them via the module-proxy so
12249
+ # ``UpdateWorker`` and the install watch (in _cctally_update) read the
12250
+ # right values via ``cctally.X`` at call time.
12348
12251
  _c_boot = _cctally()
12349
12252
  _c_boot.ORIGINAL_SYS_ARGV = list(sys.argv)
12350
12253
  _c_boot.ORIGINAL_ENTRYPOINT = shutil.which("cctally")
12351
12254
  _c_boot._UPDATE_WORKER = UpdateWorker()
12255
+ # #868: captured before any thread starts; the install watch compares every
12256
+ # later poll against what this process booted from.
12257
+ _install_boot = capture_install_boot_state()
12352
12258
 
12353
12259
  # Load config for the bind-host + expose-transcripts resolution below. (#217
12354
12260
  # S1 / U7b: dropped the dead ``args._resolved_tz = resolve_display_tz(...)``
@@ -12433,11 +12339,9 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
12433
12339
  _cctally_cache_module.set_conversation_derivations_suppressed(
12434
12340
  bool(args.no_sync))
12435
12341
  _dashboard_startup_schema_migration(run_derivations=not bool(args.no_sync))
12436
- # #714: the pricing-file watch, in EVERY mode for the same reason. Both
12437
- # `_DashboardSyncThread` and `_make_conversation_sync_thread` are disabled
12438
- # under `--no-sync`, so a detector placed in either would have no owner in
12439
- # the one mode a long-lived read-only dashboard runs in.
12440
- _start_pricing_watch()
12342
+ # #868: in EVERY mode, because `--no-sync` disables both sync threads and a
12343
+ # long-lived read-only dashboard is still left behind by an install.
12344
+ _start_install_watch(_install_boot)
12441
12345
  _heal = _dashboard_self_heal_orphans(skip_sync=bool(args.no_sync))
12442
12346
  if _heal is not None and _heal.pruned_files:
12443
12347
  print(f"dashboard: pruned {_heal.pruned_files} orphaned cache file(s) "
@@ -12703,6 +12607,8 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
12703
12607
  try:
12704
12608
  _dashboard_wait_for_signal((_signal.SIGINT, _signal.SIGTERM))
12705
12609
  finally:
12610
+ # First, so a shutdown can never be followed by an automatic restart.
12611
+ _stop_install_watch()
12706
12612
  if sync_thread is not None:
12707
12613
  sync_thread.stop()
12708
12614
  conversation_sync_stop.set()
@@ -75,7 +75,12 @@ worker / polling thread:
75
75
  (two-thread pump → callbacks + log lines),
76
76
  ``_do_update_install`` (acquire lock → run steps → release →
77
77
  rotate log; dry-run path skips lock + subprocesses),
78
- ``_resolve_execvp_target`` (npm shim re-entry path, spec §5.7).
78
+ ``_resolve_execvp_target`` (spec §5.7; npm execs Python directly
79
+ since #868) and ``_execvp_target_problem`` (target validity).
80
+ - Install evidence (#868): ``install-success.json`` via
81
+ ``_write_install_success_record`` / ``_read_install_success_record``,
82
+ ``_update_lock_is_held``, ``_install_root_for_entrypoint``,
83
+ ``_read_changelog_version_at``, ``_install_layout``.
79
84
  - Dashboard surface: ``UpdateWorker`` (single-slot orchestrator,
80
85
  spec §5.6; idempotent-release contract per §5.6.1),
81
86
  ``_DashboardUpdateCheckThread`` (poll cadence ≠ network-call
@@ -184,12 +189,14 @@ import argparse
184
189
  import datetime as dt
185
190
  import fcntl
186
191
  import json
192
+ import math
187
193
  import os
188
194
  import pathlib
189
195
  import queue
190
196
  import re
191
197
  import secrets
192
198
  import shlex
199
+ import shutil
193
200
  import subprocess
194
201
  import sys
195
202
  import threading
@@ -677,6 +684,101 @@ def _release_update_lock(fd: int) -> None:
677
684
  pass
678
685
 
679
686
 
687
+ _INSTALL_SUCCESS_RECORD_SCHEMA = 1
688
+
689
+
690
+ @dataclass(frozen=True)
691
+ class InstallSuccessRecord:
692
+ """One install event from ``install-success.json`` (#868)."""
693
+
694
+ version: str
695
+ at_utc: str
696
+ token: str
697
+
698
+
699
+ def _write_install_success_record(version: str) -> None:
700
+ """Atomically replace the install-only success record (#868).
701
+
702
+ ``_do_update_check`` loads ``update-state.json`` before its network fetch
703
+ and saves that object afterward, so an install that stamps success during
704
+ the fetch loses its fields there. Only the install paths write this file,
705
+ so no update check can erase an install event. Every write mints a fresh
706
+ random token: a running dashboard compares tokens, never times, so a clock
707
+ step can neither hide a new install nor revive an old one.
708
+ """
709
+ path = _cctally_core.INSTALL_SUCCESS_RECORD_PATH
710
+ path.parent.mkdir(parents=True, exist_ok=True)
711
+ payload = (json.dumps({
712
+ "_schema": _INSTALL_SUCCESS_RECORD_SCHEMA,
713
+ "version": version,
714
+ "at_utc": _now_utc().isoformat(),
715
+ "token": secrets.token_hex(16),
716
+ }, sort_keys=True) + "\n").encode("utf-8")
717
+ tmp = path.with_name(f"{path.name}.tmp.{os.getpid()}")
718
+ fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
719
+ try:
720
+ try:
721
+ view = memoryview(payload)
722
+ while view:
723
+ view = view[os.write(fd, view):]
724
+ os.fsync(fd)
725
+ finally:
726
+ os.close(fd)
727
+ os.replace(str(tmp), str(path))
728
+ except BaseException:
729
+ try:
730
+ os.unlink(str(tmp))
731
+ except OSError:
732
+ pass
733
+ raise
734
+
735
+
736
+ def _read_install_success_record() -> "InstallSuccessRecord | None":
737
+ """The current install-success record, or None when absent or malformed."""
738
+ try:
739
+ raw = json.loads(
740
+ _cctally_core.INSTALL_SUCCESS_RECORD_PATH.read_text(encoding="utf-8"))
741
+ except (OSError, ValueError):
742
+ return None
743
+ if not isinstance(raw, dict) or raw.get("_schema") != _INSTALL_SUCCESS_RECORD_SCHEMA:
744
+ return None
745
+ fields = (raw.get("version"), raw.get("at_utc"), raw.get("token"))
746
+ if not all(isinstance(v, str) and v for v in fields):
747
+ return None
748
+ try:
749
+ _release_parse_semver(fields[0])
750
+ except ValueError:
751
+ return None
752
+ return InstallSuccessRecord(*fields)
753
+
754
+
755
+ def _update_lock_is_held() -> bool:
756
+ """True unless ``update.lock`` is provably free; never creates the file.
757
+
758
+ A shared non-blocking probe conflicts with the updater's exclusive lock. A
759
+ lock file left behind by a finished or crashed updater carries no lock, so
760
+ it reads as free. The probe holds its shared lock for an instant, so an
761
+ updater whose own non-blocking attempt lands in that instant takes its
762
+ contention path. Any other error proves nothing about the updater and
763
+ reads as held.
764
+ """
765
+ try:
766
+ fd = os.open(str(_cctally_core.UPDATE_LOCK_PATH), os.O_RDONLY)
767
+ except FileNotFoundError:
768
+ return False
769
+ except OSError:
770
+ return True
771
+ try:
772
+ try:
773
+ fcntl.flock(fd, fcntl.LOCK_SH | fcntl.LOCK_NB)
774
+ except OSError:
775
+ return True
776
+ fcntl.flock(fd, fcntl.LOCK_UN)
777
+ return False
778
+ finally:
779
+ os.close(fd)
780
+
781
+
680
782
  def _rotate_update_log_if_needed() -> None:
681
783
  """Rotate ``update.log`` → ``update.log.1`` when the live log
682
784
  crosses :data:`UPDATE_LOG_ROTATE_BYTES` (1 MB, spec §1.5).
@@ -905,6 +1007,9 @@ def _stamp_install_success_to_state(
905
1007
  installed version. Skipped on brew for the reason above.
906
1008
  4. ``state.latest_version`` — last resort, also covers the npm
907
1009
  path when CHANGELOG is unreadable.
1010
+
1011
+ The same version is also written to ``install-success.json`` (#868),
1012
+ which a running dashboard reads to decide whether to restart itself.
908
1013
  """
909
1014
  c = _cctally()
910
1015
  state = c._load_update_state() or {"_schema": 1}
@@ -923,6 +1028,15 @@ def _stamp_install_success_to_state(
923
1028
  state["current_version"] = cur
924
1029
  state["last_install_success_at_utc"] = _now_utc().isoformat()
925
1030
  c._save_update_state(state)
1031
+ try:
1032
+ c._write_install_success_record(cur)
1033
+ except OSError as exc:
1034
+ # The install itself succeeded; only a running dashboard's
1035
+ # automatic restart loses its evidence.
1036
+ eprint(f"cctally: could not write "
1037
+ f"{_cctally_core.INSTALL_SUCCESS_RECORD_PATH.name} ({exc}); "
1038
+ "a running dashboard will not restart itself for this "
1039
+ "install")
926
1040
 
927
1041
 
928
1042
  def _self_heal_current_version() -> None:
@@ -2106,7 +2220,7 @@ def _do_update_install(
2106
2220
  c._rotate_update_log_if_needed()
2107
2221
 
2108
2222
 
2109
- # === Dashboard execvp re-entry (spec §5.7) ===
2223
+ # === Dashboard execvp re-entry (spec §5.7; #868) ===
2110
2224
  # ORIGINAL_SYS_ARGV / ORIGINAL_ENTRYPOINT are captured at dashboard
2111
2225
  # server boot in cmd_dashboard (in bin/cctally, written via
2112
2226
  # ``global ORIGINAL_SYS_ARGV, ORIGINAL_ENTRYPOINT`` so the running
@@ -2117,26 +2231,81 @@ def _do_update_install(
2117
2231
  #
2118
2232
  # _resolve_execvp_target uses them to return (entrypoint, exec_argv)
2119
2233
  # for os.execvp:
2120
- # - npm: entrypoint = <prefix>/bin/cctally → Node shim, which
2121
- # re-resolves CCTALLY_PYTHON before re-spawning Python (so a
2122
- # custom interpreter setting survives the restart).
2234
+ # - npm: the interpreter the Node shim would use (CCTALLY_PYTHON, else
2235
+ # python3 on PATH) on the install's bin/cctally. Re-entering the shim
2236
+ # would add a Node process per restart, because the shim starts Python
2237
+ # with spawnSync and waits for it (#868).
2123
2238
  # - brew: entrypoint = <brew>/bin/cctally → symlink into the
2124
2239
  # post-upgrade Python script with its rewritten shebang.
2125
2240
  # - Fallback when shutil.which("cctally") returned None: use
2126
- # sys.argv[0] directly. Loses the npm shim layer; we accept the
2127
- # degraded edge case rather than guess.
2241
+ # sys.argv[0] directly.
2242
+ # The install watcher (#868) and UpdateWorker both refuse a target that
2243
+ # _execvp_target_problem rejects.
2128
2244
 
2129
2245
 
2130
- def _resolve_execvp_target() -> tuple[str, list[str]]:
2131
- """Return (entrypoint, exec_argv) per spec §5.7.
2246
+ def _install_root_for_entrypoint(entrypoint: "str | None") -> "pathlib.Path | None":
2247
+ """Install root behind a user-facing entrypoint, resolved afresh (#868).
2132
2248
 
2133
- Re-enters the npm shim by execvp'ing the PATH-resolved ``cctally``
2134
- (Node shim for npm, brew symlink for brew). Falls back to
2135
- ``sys.argv[0]`` only when ``shutil.which`` returned ``None`` at
2136
- dashboard boot (rare absolute-path invocation).
2249
+ npm: ``<prefix>/bin/cctally`` → ``…/node_modules/cctally/bin/cctally-npm-shim.js``
2250
+ → ``…/node_modules/cctally``. Homebrew: ``<prefix>/bin/cctally`` →
2251
+ ``Cellar/cctally/<v>/libexec/bin/cctally`` → ``…/libexec``, where the
2252
+ formula installs ``CHANGELOG.md``. The process's own ``CHANGELOG_PATH`` is
2253
+ never used for this: it stays inside the install the process booted from.
2254
+ """
2255
+ if not entrypoint:
2256
+ return None
2257
+ try:
2258
+ real = pathlib.Path(os.path.realpath(entrypoint))
2259
+ except (OSError, ValueError):
2260
+ return None
2261
+ if real.parent.name != "bin":
2262
+ return None
2263
+ return real.parent.parent
2264
+
2265
+
2266
+ def _read_changelog_version_at(root) -> "str | None":
2267
+ """Latest stamped version in ``<root>/CHANGELOG.md``, or None."""
2268
+ try:
2269
+ text = (pathlib.Path(root) / "CHANGELOG.md").read_text(encoding="utf-8")
2270
+ except (OSError, UnicodeDecodeError):
2271
+ return None
2272
+ m = _cctally().RELEASE_HEADER_RE.search(text)
2273
+ return m.group(1) if m else None
2274
+
2275
+
2276
+ def _install_layout(root) -> str:
2277
+ """``"npm"``, ``"brew"`` or ``"other"`` for an install root."""
2278
+ root = pathlib.Path(root)
2279
+ if ((root / "bin" / "cctally-npm-shim.js").is_file()
2280
+ and (root / "package.json").is_file()):
2281
+ return "npm"
2282
+ parts = root.parts
2283
+ if (root.name == "libexec" and len(parts) >= 4
2284
+ and parts[-4] == "Cellar" and parts[-3] == "cctally"):
2285
+ return "brew"
2286
+ return "other"
2287
+
2288
+
2289
+ def _resolve_execvp_target() -> tuple[str, list[str]]:
2290
+ """Return (entrypoint, exec_argv) for the in-place restart.
2291
+
2292
+ npm installs exec the interpreter the shim would use (``CCTALLY_PYTHON``,
2293
+ else ``python3`` on ``PATH``) on the install's ``bin/cctally`` directly, so
2294
+ the serving Python process keeps its PID and the original shim stays its
2295
+ only Node parent (#868). Homebrew re-enters the captured entrypoint, which
2296
+ resolves into the newly linked keg. Falls back to ``sys.argv[0]`` only when
2297
+ ``shutil.which`` returned ``None`` at dashboard boot.
2137
2298
  """
2138
2299
  c = _cctally()
2139
2300
  if c.ORIGINAL_ENTRYPOINT is not None:
2301
+ root = _install_root_for_entrypoint(c.ORIGINAL_ENTRYPOINT)
2302
+ if root is not None and _install_layout(root) == "npm":
2303
+ python = os.environ.get("CCTALLY_PYTHON") or "python3"
2304
+ if os.path.sep not in python:
2305
+ python = shutil.which(python) or python
2306
+ return (python,
2307
+ [python, str(root / "bin" / "cctally"),
2308
+ *c.ORIGINAL_SYS_ARGV[1:]])
2140
2309
  return (
2141
2310
  c.ORIGINAL_ENTRYPOINT,
2142
2311
  [c.ORIGINAL_ENTRYPOINT, *c.ORIGINAL_SYS_ARGV[1:]],
@@ -2144,6 +2313,34 @@ def _resolve_execvp_target() -> tuple[str, list[str]]:
2144
2313
  return (c.ORIGINAL_SYS_ARGV[0], list(c.ORIGINAL_SYS_ARGV))
2145
2314
 
2146
2315
 
2316
+ def _execvp_target_problem(entrypoint: str, argv: "list[str]") -> "str | None":
2317
+ """Why ``os.execvp(entrypoint, argv)`` cannot start cctally, or None.
2318
+
2319
+ The executable must resolve to an executable file. When ``argv[1]`` is an
2320
+ install's ``bin/cctally`` (the npm target), that script must exist and be
2321
+ readable, the same file the npm shim checks before it starts Python.
2322
+ """
2323
+ path = entrypoint if os.path.sep in entrypoint else shutil.which(entrypoint)
2324
+ if not path or not os.path.isfile(path) or not os.access(path, os.X_OK):
2325
+ return f"restart target {entrypoint!r} is not an executable file"
2326
+ if (len(argv) >= 2 and os.path.isabs(argv[1])
2327
+ and pathlib.Path(argv[1]).parts[-2:] == ("bin", "cctally")):
2328
+ if not (os.path.isfile(argv[1]) and os.access(argv[1], os.R_OK)):
2329
+ return f"restart script {argv[1]!r} is missing or unreadable"
2330
+ return None
2331
+
2332
+
2333
+ class AutomaticRestartInProgress(Exception):
2334
+ """``UpdateWorker.start`` refused: the install watcher holds the restart
2335
+ claim (#868, maintainer decision 6)."""
2336
+
2337
+ def __init__(self, version: str) -> None:
2338
+ self.version = version
2339
+ super().__init__(
2340
+ f"The dashboard is restarting to load v{version}. "
2341
+ "Try again in a moment.")
2342
+
2343
+
2147
2344
  class UpdateWorker:
2148
2345
  """Single-slot dashboard-side update orchestrator (spec §5.6).
2149
2346
 
@@ -2175,15 +2372,23 @@ class UpdateWorker:
2175
2372
  # consumer ever subscribes, ``start()`` reaps stale entries on
2176
2373
  # the next run.
2177
2374
  self._streams: dict[str, "queue.Queue"] = {}
2375
+ # The install watcher's restart claim (#868): the candidate version
2376
+ # while held, else None. Guarded by ``_lock`` together with
2377
+ # ``_current_id``, so a claim and an admitted run exclude each other.
2378
+ self._auto_restart_version: "str | None" = None
2178
2379
 
2179
2380
  def start(self, version: "str | None") -> tuple[bool, str]:
2180
2381
  """Begin a run. Returns (accepted, run_id).
2181
2382
 
2182
2383
  ``accepted=False`` when another run is in progress; the
2183
2384
  returned ``run_id`` is the in-progress one (so the caller can
2184
- surface it as ``run_id_in_progress`` to the client).
2385
+ surface it as ``run_id_in_progress`` to the client). Raises
2386
+ :class:`AutomaticRestartInProgress` while the install watcher holds
2387
+ the restart claim (#868).
2185
2388
  """
2186
2389
  with self._lock:
2390
+ if self._auto_restart_version is not None:
2391
+ raise AutomaticRestartInProgress(self._auto_restart_version)
2187
2392
  if self._current_id is not None:
2188
2393
  return (False, self._current_id)
2189
2394
  # Reap any stale entries from prior no-consumer runs. Safe
@@ -2206,6 +2411,26 @@ class UpdateWorker:
2206
2411
  with self._lock:
2207
2412
  return {"current_run_id": self._current_id}
2208
2413
 
2414
+ def claim_automatic_restart(self, version: str) -> bool:
2415
+ """Claim the in-place restart for the install watcher (#868).
2416
+
2417
+ Fails while an update run is admitted, because that run owns its own
2418
+ stamp, flush, lock release and exec. The claim is not an update run:
2419
+ it has no run id, stream or event, so ``status()`` still reports no
2420
+ run and a client that clicks Update meanwhile gets
2421
+ :class:`AutomaticRestartInProgress` from ``start``.
2422
+ """
2423
+ with self._lock:
2424
+ if (self._current_id is not None
2425
+ or self._auto_restart_version is not None):
2426
+ return False
2427
+ self._auto_restart_version = version
2428
+ return True
2429
+
2430
+ def release_automatic_restart(self) -> None:
2431
+ with self._lock:
2432
+ self._auto_restart_version = None
2433
+
2209
2434
  def _emit(self, run_id: str, event: dict) -> None:
2210
2435
  q = self._streams.get(run_id)
2211
2436
  if q is not None:
@@ -2310,6 +2535,9 @@ class UpdateWorker:
2310
2535
  _log_update_event(log_fd, "INSTALL_SUCCESS")
2311
2536
  c._stamp_install_success_to_state(resolved_version, method)
2312
2537
  entrypoint, exec_argv = c._resolve_execvp_target()
2538
+ problem = c._execvp_target_problem(entrypoint, exec_argv)
2539
+ if problem is not None:
2540
+ raise UpdateError(problem)
2313
2541
  self._emit(run_id, {"type": "execvp", "argv": exec_argv})
2314
2542
  try:
2315
2543
  log_fd.close()
@@ -2347,6 +2575,184 @@ class UpdateWorker:
2347
2575
  # start() sweeps stale entries on the next run.
2348
2576
 
2349
2577
 
2578
+ # === Install watch (#868) =================================================
2579
+ # A running dashboard restarts itself in place when a proven install puts a
2580
+ # strictly newer cctally under it. Spec:
2581
+ # docs/superpowers/specs/2026-09-23-868-dashboard-restart-on-install-design.md
2582
+
2583
+ _INSTALL_WATCH_INTERVAL_S = 60.0
2584
+ _INSTALL_RESTART_RETRY_DELAYS_S = (60.0, 300.0, 900.0)
2585
+ _INSTALL_RESTART_FLUSH_S = 0.5
2586
+
2587
+
2588
+ @dataclass(frozen=True)
2589
+ class InstallBootState:
2590
+ """What the dashboard process booted from. Later polls compare against it,
2591
+ never against wall-clock time, so a restarted process cannot qualify on the
2592
+ install it booted from."""
2593
+
2594
+ version: "str | None"
2595
+ root: "pathlib.Path | None"
2596
+ record_token: "str | None"
2597
+
2598
+
2599
+ def capture_install_boot_state() -> InstallBootState:
2600
+ c = _cctally()
2601
+ latest = c._release_read_latest_release_version()
2602
+ record = _read_install_success_record()
2603
+ return InstallBootState(
2604
+ version=latest[0] if latest else None,
2605
+ root=_install_root_for_entrypoint(c.ORIGINAL_ENTRYPOINT),
2606
+ record_token=record.token if record else None,
2607
+ )
2608
+
2609
+
2610
+ @dataclass(frozen=True)
2611
+ class RestartCandidate:
2612
+ """A proven newer install. ``identity`` keys the retry budget: the version
2613
+ with the record token (updater) or the resolved install root (Homebrew)."""
2614
+
2615
+ version: str
2616
+ evidence: str
2617
+ identity: tuple
2618
+
2619
+
2620
+ class InstallWatch:
2621
+ """Restart the dashboard in place after a proven newer install (#868).
2622
+
2623
+ Proven means cctally's updater wrote a new install-success record, or
2624
+ Homebrew switched the entrypoint into a newer keg. A manual npm install
2625
+ rewrites files in place with no completion signal, so it never qualifies.
2626
+
2627
+ The restart meets the Update-button safety bar and no more: nothing is
2628
+ stopped before the exec, and an in-flight write is interrupted as in a
2629
+ crash. The claim on ``UpdateWorker`` keeps an admitted update run as the
2630
+ sole owner of its own restart.
2631
+ """
2632
+
2633
+ def __init__(self, boot, *, worker_fn, exec_fn=os.execvp,
2634
+ sleep_fn=time.sleep, monotonic_fn=time.monotonic,
2635
+ log_fn=None, interval_s=_INSTALL_WATCH_INTERVAL_S,
2636
+ retry_delays=_INSTALL_RESTART_RETRY_DELAYS_S,
2637
+ flush_s=_INSTALL_RESTART_FLUSH_S):
2638
+ self._boot = boot
2639
+ self._worker_fn = worker_fn
2640
+ self._exec = exec_fn
2641
+ self._sleep = sleep_fn
2642
+ self._monotonic = monotonic_fn
2643
+ self._log = log_fn or eprint
2644
+ self.interval_s = interval_s
2645
+ self._retry_delays = tuple(retry_delays)
2646
+ self._flush_s = flush_s
2647
+ # identity -> (failed attempts, monotonic time of the next attempt)
2648
+ self._attempts: dict = {}
2649
+ self._stop = threading.Event()
2650
+ self._thread: "threading.Thread | None" = None
2651
+
2652
+ def _say(self, msg: str) -> None:
2653
+ # A lost log line, e.g. stderr on a closed pipe, must not count as a
2654
+ # failed restart attempt or end the watch thread.
2655
+ try:
2656
+ self._log(msg)
2657
+ except Exception: # noqa: BLE001
2658
+ pass
2659
+
2660
+ def find_candidate(self) -> "RestartCandidate | None":
2661
+ boot = self._boot
2662
+ if boot.version is None:
2663
+ return None
2664
+ root = _install_root_for_entrypoint(_cctally().ORIGINAL_ENTRYPOINT)
2665
+ if root is None:
2666
+ return None
2667
+ record = _read_install_success_record()
2668
+ if (record is not None and record.token != boot.record_token
2669
+ and _semver_gt(record.version, boot.version)
2670
+ and not _update_lock_is_held()
2671
+ and _read_changelog_version_at(root) == record.version):
2672
+ return RestartCandidate(record.version, "updater",
2673
+ (record.version, "updater", record.token))
2674
+ # Homebrew links the new keg only after installing it completely, so
2675
+ # the switch itself is the completion event.
2676
+ if root != boot.root and _install_layout(root) == "brew":
2677
+ version = _read_changelog_version_at(root)
2678
+ if version and _semver_gt(version, boot.version):
2679
+ return RestartCandidate(version, "brew",
2680
+ (version, "brew", str(root)))
2681
+ return None
2682
+
2683
+ def poll_once(self) -> str:
2684
+ """One check. Returns ``"idle"``, ``"backoff"``, ``"exhausted"``,
2685
+ ``"deferred"``, ``"stopped"``, ``"failed"`` or ``"exec"``."""
2686
+ candidate = self.find_candidate()
2687
+ if candidate is None:
2688
+ return "idle"
2689
+ now = self._monotonic()
2690
+ prior = self._attempts.get(candidate.identity)
2691
+ if prior is not None:
2692
+ failures, next_at = prior
2693
+ if failures > len(self._retry_delays):
2694
+ return "exhausted"
2695
+ if now < next_at:
2696
+ return "backoff"
2697
+ worker = self._worker_fn()
2698
+ if worker is None:
2699
+ return "deferred"
2700
+ c = _cctally()
2701
+ claimed = False
2702
+ try:
2703
+ entrypoint, argv = c._resolve_execvp_target()
2704
+ problem = c._execvp_target_problem(entrypoint, argv)
2705
+ if problem is None:
2706
+ again = self.find_candidate()
2707
+ if again is None or again.identity != candidate.identity:
2708
+ problem = "the install changed before the restart"
2709
+ if problem is not None:
2710
+ raise UpdateError(problem)
2711
+ if not worker.claim_automatic_restart(candidate.version):
2712
+ return "deferred"
2713
+ claimed = True
2714
+ self._say(f"[update] restarting dashboard: v{self._boot.version} "
2715
+ f"-> v{candidate.version} ({candidate.evidence} install)")
2716
+ # Lets a POST /api/update already refused under the claim finish
2717
+ # writing its 503 before the process image is replaced.
2718
+ self._sleep(self._flush_s)
2719
+ if self._stop.is_set():
2720
+ worker.release_automatic_restart()
2721
+ return "stopped"
2722
+ self._exec(entrypoint, argv)
2723
+ except Exception as exc: # noqa: BLE001 — a failed attempt keeps serving
2724
+ if claimed:
2725
+ worker.release_automatic_restart()
2726
+ failures = (prior[0] if prior else 0) + 1
2727
+ delay = (self._retry_delays[failures - 1]
2728
+ if failures <= len(self._retry_delays) else math.inf)
2729
+ self._attempts[candidate.identity] = (failures, now + delay)
2730
+ self._say(f"[update] automatic restart to v{candidate.version} "
2731
+ f"failed: {exc}")
2732
+ return "failed"
2733
+ return "exec"
2734
+
2735
+ def run(self) -> None:
2736
+ while not self._stop.wait(self.interval_s):
2737
+ try:
2738
+ self.poll_once()
2739
+ except Exception as exc: # noqa: BLE001 — the watch must not die
2740
+ self._say(f"[update] install watch poll failed: {exc!r}")
2741
+
2742
+ def start(self) -> "threading.Thread":
2743
+ thread = threading.Thread(target=self.run, name="cctally-install-watch",
2744
+ daemon=True)
2745
+ thread.start()
2746
+ self._thread = thread
2747
+ return thread
2748
+
2749
+ def stop(self, timeout: float = 2.0) -> None:
2750
+ self._stop.set()
2751
+ thread = self._thread
2752
+ if thread is not None and thread is not threading.current_thread():
2753
+ thread.join(timeout)
2754
+
2755
+
2350
2756
  class _DashboardUpdateCheckThread(threading.Thread):
2351
2757
  """Dedicated update-check polling thread (spec §3.5).
2352
2758
 
@@ -107,13 +107,10 @@ def _import_pricing_kernel():
107
107
 
108
108
 
109
109
  # ``DEFAULT_TIERED_THRESHOLD`` above is Anthropic's per-call >200K-tokens tier,
110
- # READ FROM ``_lib_pricing`` rather than duplicated here (#714). It used to be a
111
- # literal `200_000` with nothing keeping it equal to
112
- # ``_lib_pricing.TIERED_THRESHOLD``, which made it a pricing value a reload
113
- # could not replace. It is refreshed by ``PRICING_EXPORT_BINDINGS`` on every
114
- # swap, and ``_compute_entry_cache_dollars`` resolves it at CALL time — a
115
- # default argument would bind it once at function-definition time, so the
116
- # refresh would reach the name and never the calls.
110
+ # READ FROM ``_lib_pricing.TIERED_THRESHOLD`` rather than duplicated here, so
111
+ # the two cannot drift. ``_compute_entry_cache_dollars`` resolves it at CALL
112
+ # time instead of binding it as a default argument, so this module global is
113
+ # the one place the value is taken from.
117
114
  # Callers may still override via the ``tiered_threshold`` kwarg.
118
115
 
119
116
 
@@ -384,9 +381,9 @@ def _compute_entry_cache_dollars(
384
381
  warning for unknown models elsewhere.
385
382
 
386
383
  ``tiered_threshold=None`` resolves ``DEFAULT_TIERED_THRESHOLD`` HERE rather
387
- than in the signature (#714). A default argument is evaluated once, at
388
- function-definition time, so an in-process pricing reload would refresh the
389
- module global and leave every call pricing from the superseded revision.
384
+ than in the signature. A default argument is evaluated once, at
385
+ function-definition time, so a change to the module global would never
386
+ reach the calls.
390
387
  """
391
388
  if tiered_threshold is None:
392
389
  tiered_threshold = DEFAULT_TIERED_THRESHOLD
@@ -17,17 +17,12 @@ Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
17
17
  """
18
18
  from __future__ import annotations
19
19
 
20
- import contextlib as _contextlib
21
- import contextvars as _contextvars
22
20
  import dataclasses
23
21
  import datetime as dt
24
22
  import re
25
23
  import sys
26
- import threading as _threading
27
24
  from typing import Any
28
25
 
29
- _sys = sys
30
-
31
26
 
32
27
  def _eprint(*args: Any) -> None:
33
28
  print(*args, file=sys.stderr)
@@ -1448,36 +1443,20 @@ def _short_model_name(model: str) -> str:
1448
1443
 
1449
1444
 
1450
1445
  # ---------------------------------------------------------------------------
1451
- # #714 — the published pricing snapshot, and the reader that replaces it
1452
- #
1453
- # Every CLI invocation re-imports this module, so only a long-lived process —
1454
- # the dashboard — can hold pricing an installer has already replaced on disk.
1455
- # Two rules shape everything below.
1456
- #
1457
- # A PARTIAL SWAP IS WORSE THAN NO SWAP. `PRICING_SNAPSHOT_DATE` is what gates
1458
- # the materialized-cost write refusal in `_cctally_cache`, so replacing the
1459
- # date alone clears the refusal and resumes writing cost computed from the old
1460
- # tables. A candidate is therefore validated whole and adopted whole, or it is
1461
- # rejected and the live snapshot is retained unchanged.
1446
+ # The pricing snapshot every cost kernel reads.
1462
1447
  #
1463
- # NEVER `importlib.reload`. It executes arbitrary module code from a file an
1464
- # installer has just written; it mutates a live module dict incrementally, so a
1465
- # request in flight can see half a table; and it would not even fix the bug,
1466
- # because reloading this module rebinds none of the import-time copies listed
1467
- # in `PRICING_EXPORT_BINDINGS`. `ast.literal_eval` cannot execute code.
1468
-
1469
-
1470
- class PricingCandidateRejected(Exception):
1471
- """A candidate pricing file was not adopted. Carries the reason."""
1448
+ # Built once at import from the module globals above and never replaced. A
1449
+ # long-lived dashboard does not reload pricing in process: after a proven
1450
+ # install of a newer version it restarts itself (#868), and after a manual
1451
+ # install it keeps these tables until the user restarts it.
1472
1452
 
1473
1453
 
1474
1454
  class PricingSnapshot:
1475
1455
  """One complete, immutable pricing revision.
1476
1456
 
1477
- Every pricing-affecting value lives here — not only the two tables. A
1478
- tier threshold, the Codex fallback model, an alias or a fast multiplier
1479
- left behind by a partial swap prices part of an entry from the previous
1480
- revision, which is the same defect as a stale table and harder to see.
1457
+ Every pricing-affecting value lives here — not only the two tables: both
1458
+ tier thresholds, the Codex fallback model, the aliases, the cache-write
1459
+ multiplier and the fast multipliers.
1481
1460
 
1482
1461
  Hand-written frozen rather than ``@dataclasses.dataclass(frozen=True)``,
1483
1462
  for the reason `PricingFingerprintObservation` above states at length:
@@ -1522,81 +1501,6 @@ class PricingSnapshot:
1522
1501
  f"codex_models={len(self.codex_pricing)})")
1523
1502
 
1524
1503
 
1525
- #: The complete inventory of import-time copies, made explicit so it can be
1526
- #: tested rather than asserted. `(module name, attribute, field accessor)`.
1527
- #: `bin/cctally` copies the tables at import and both dashboard source modules
1528
- #: read those copies, so a swap that missed one would price from a stale table
1529
- #: with the refusal latch already cleared.
1530
- PRICING_EXPORT_BINDINGS = (
1531
- ("_lib_pricing", "PRICING_SNAPSHOT_DATE", lambda s: s.snapshot_date),
1532
- ("_lib_pricing", "CLAUDE_MODEL_PRICING", lambda s: s.claude_pricing),
1533
- ("_lib_pricing", "CODEX_MODEL_PRICING", lambda s: s.codex_pricing),
1534
- ("_lib_pricing", "CODEX_MODEL_ALIASES", lambda s: s.aliases),
1535
- ("_lib_pricing", "TIERED_THRESHOLD",
1536
- lambda s: s.tier_thresholds["claude"]),
1537
- ("_lib_pricing", "CODEX_TIERED_THRESHOLD",
1538
- lambda s: s.tier_thresholds["codex"]),
1539
- ("_lib_pricing", "CODEX_LEGACY_FALLBACK_MODEL", lambda s: s.fallback_model),
1540
- ("_lib_pricing", "CACHE_WRITE_1H_MULTIPLIER",
1541
- lambda s: s.cache_write_1h_multiplier),
1542
- ("_lib_pricing", "CLAUDE_FAST_MULTIPLIER_OVERRIDES",
1543
- lambda s: s.fast_multipliers["claude"]),
1544
- ("_lib_pricing", "CODEX_FAST_MULTIPLIER_OVERRIDES",
1545
- lambda s: s.fast_multipliers["codex"]),
1546
- ("_lib_pricing", "CODEX_FAST_MULTIPLIER_FALLBACK",
1547
- lambda s: s.fast_multipliers["codex_fallback"]),
1548
- ("cctally", "PRICING_SNAPSHOT_DATE", lambda s: s.snapshot_date),
1549
- ("cctally", "CLAUDE_MODEL_PRICING", lambda s: s.claude_pricing),
1550
- ("cctally", "CODEX_MODEL_PRICING", lambda s: s.codex_pricing),
1551
- ("cctally", "TIERED_THRESHOLD", lambda s: s.tier_thresholds["claude"]),
1552
- ("cctally", "CODEX_TIERED_THRESHOLD",
1553
- lambda s: s.tier_thresholds["codex"]),
1554
- ("cctally", "CODEX_LEGACY_FALLBACK_MODEL", lambda s: s.fallback_model),
1555
- ("cctally", "CACHE_WRITE_1H_MULTIPLIER",
1556
- lambda s: s.cache_write_1h_multiplier),
1557
- ("cctally", "CLAUDE_FAST_MULTIPLIER_OVERRIDES",
1558
- lambda s: s.fast_multipliers["claude"]),
1559
- ("cctally", "CODEX_FAST_MULTIPLIER_OVERRIDES",
1560
- lambda s: s.fast_multipliers["codex"]),
1561
- ("cctally", "CODEX_FAST_MULTIPLIER_FALLBACK",
1562
- lambda s: s.fast_multipliers["codex_fallback"]),
1563
- # `_cctally_cache` binds the date as a module global on purpose, so a test
1564
- # can monkeypatch it; that affordance is preserved and the binding is
1565
- # simply refreshed here too.
1566
- ("_cctally_cache", "PRICING_SNAPSHOT_DATE", lambda s: s.snapshot_date),
1567
- # `_lib_cache_report` unpacks the multiplier and the Claude tier threshold
1568
- # at import. The threshold used to be a literal `200_000` in that file with
1569
- # nothing keeping it equal to `TIERED_THRESHOLD` here, which made it a
1570
- # pricing value no reload could replace; it is read from this module now
1571
- # and refreshed through this entry.
1572
- ("_lib_cache_report", "CACHE_WRITE_1H_MULTIPLIER",
1573
- lambda s: s.cache_write_1h_multiplier),
1574
- ("_lib_cache_report", "DEFAULT_TIERED_THRESHOLD",
1575
- lambda s: s.tier_thresholds["claude"]),
1576
- )
1577
-
1578
- #: The names `read_pricing_snapshot_from_source` requires, each mapped to the
1579
- #: `PricingSnapshot` field it fills. A missing OR non-literal name rejects the
1580
- #: whole candidate.
1581
- _CANDIDATE_ASSIGNMENTS = (
1582
- "PRICING_SNAPSHOT_DATE",
1583
- "CLAUDE_MODEL_PRICING",
1584
- "CODEX_MODEL_PRICING",
1585
- "CODEX_MODEL_ALIASES",
1586
- "TIERED_THRESHOLD",
1587
- "CODEX_TIERED_THRESHOLD",
1588
- "CODEX_LEGACY_FALLBACK_MODEL",
1589
- "CACHE_WRITE_1H_MULTIPLIER",
1590
- "CLAUDE_FAST_MULTIPLIER_OVERRIDES",
1591
- "CODEX_FAST_MULTIPLIER_OVERRIDES",
1592
- "CODEX_FAST_MULTIPLIER_FALLBACK",
1593
- )
1594
-
1595
- _PRICING_PUBLISH_LOCK = _threading.Lock()
1596
- _PRICING_CONTEXT = _contextvars.ContextVar("cctally_pricing_snapshot",
1597
- default=None)
1598
-
1599
-
1600
1504
  def _snapshot_from_module_globals() -> PricingSnapshot:
1601
1505
  return PricingSnapshot(
1602
1506
  snapshot_date=PRICING_SNAPSHOT_DATE,
@@ -1619,161 +1523,6 @@ _LIVE_PRICING_SNAPSHOT = _snapshot_from_module_globals()
1619
1523
 
1620
1524
 
1621
1525
  def current_pricing_snapshot() -> PricingSnapshot:
1622
- """The snapshot this caller must price from.
1623
-
1624
- A caller inside `pricing_snapshot_context()` keeps the revision it entered
1625
- with, so a request, a dashboard snapshot build or a sync pass already
1626
- under way is never assembled from two revisions. Everyone else reads the
1627
- live pointer, which is one immutable object replaced by one assignment.
1628
- """
1629
- captured = _PRICING_CONTEXT.get()
1630
- return _LIVE_PRICING_SNAPSHOT if captured is None else captured
1631
-
1632
-
1633
- @_contextlib.contextmanager
1634
- def pricing_snapshot_context(snapshot: "PricingSnapshot | None" = None):
1635
- """Pin one revision for the duration of a request, build or sync pass."""
1636
- pinned = current_pricing_snapshot() if snapshot is None else snapshot
1637
- token = _PRICING_CONTEXT.set(pinned)
1638
- try:
1639
- yield pinned
1640
- finally:
1641
- _PRICING_CONTEXT.reset(token)
1642
-
1643
-
1644
- def publish_pricing_snapshot(snapshot: PricingSnapshot) -> None:
1645
- """Swap the live pointer and refresh every import-time copy.
1646
-
1647
- The pointer swap is one assignment of one frozen object, so a concurrent
1648
- reader observes the old revision whole or the new one whole. The binding
1649
- refresh that follows exists for call sites that still read a copied name;
1650
- every cost kernel in this module reads `current_pricing_snapshot()`
1651
- instead, so correctness does not depend on the refresh reaching a module
1652
- that has not been imported.
1653
- """
1654
- global _LIVE_PRICING_SNAPSHOT
1655
- with _PRICING_PUBLISH_LOCK:
1656
- _LIVE_PRICING_SNAPSHOT = snapshot
1657
- for module_name, attr, accessor in PRICING_EXPORT_BINDINGS:
1658
- module = _sys.modules.get(module_name)
1659
- if module is None:
1660
- continue
1661
- try:
1662
- setattr(module, attr, accessor(snapshot))
1663
- except Exception: # noqa: BLE001
1664
- # A module that refuses an attribute set must not stop the
1665
- # rest of the inventory being refreshed.
1666
- _eprint(f"[pricing] could not refresh {module_name}.{attr}")
1667
-
1668
-
1669
- def adopt_pricing_candidate(candidate: PricingSnapshot, *,
1670
- force: bool = False) -> bool:
1671
- """Publish `candidate` iff it is strictly newer than the live snapshot.
1672
-
1673
- `_lib_pricing` documents that a pricing revision always ADVANCES
1674
- `PRICING_SNAPSHOT_DATE`, so a candidate that does not is a rollback, a
1675
- corrupt read or the same revision read twice; each of those is a reason to
1676
- keep what is running. `force` exists for tests restoring a captured
1677
- snapshot and for nothing else.
1678
- """
1679
- if force:
1680
- publish_pricing_snapshot(candidate)
1681
- return True
1682
- live = _LIVE_PRICING_SNAPSHOT
1683
- new_date = parse_pricing_fingerprint(candidate.snapshot_date)
1684
- live_date = parse_pricing_fingerprint(live.snapshot_date)
1685
- if new_date is None:
1686
- return False
1687
- if live_date is not None and new_date <= live_date:
1688
- return False
1689
- publish_pricing_snapshot(candidate)
1690
- return True
1691
-
1692
-
1693
- def _file_signature(path) -> tuple:
1694
- st = path.stat()
1695
- return (st.st_dev, st.st_ino, st.st_size, st.st_mtime_ns)
1696
-
1697
-
1698
- def read_pricing_snapshot_from_source(path) -> PricingSnapshot:
1699
- """Build a candidate from the literal assignments in a pricing file.
1700
-
1701
- `ast.parse` plus `ast.literal_eval`, never `importlib.reload` and never
1702
- `exec`: the file is one an installer has just written and this process
1703
- must not run it.
1704
-
1705
- Raises `PricingCandidateRejected` — never returns a partial snapshot — on
1706
- a missing name, a name whose value is not a literal, a date that does not
1707
- parse, or a file whose inode/size/mtime signature changes while it is
1708
- being read, which is an installer replacing it mid-parse.
1709
- """
1710
- import ast
1711
- import pathlib as _pathlib
1712
-
1713
- path = _pathlib.Path(path)
1714
- try:
1715
- before = _file_signature(path)
1716
- source = path.read_bytes()
1717
- after = _file_signature(path)
1718
- except OSError as exc:
1719
- raise PricingCandidateRejected(
1720
- f"pricing candidate could not be read: {exc}") from exc
1721
- if before != after:
1722
- raise PricingCandidateRejected(
1723
- "pricing candidate signature changed while it was being read")
1724
-
1725
- try:
1726
- tree = ast.parse(source.decode("utf-8"), filename=str(path))
1727
- except (SyntaxError, UnicodeDecodeError) as exc:
1728
- raise PricingCandidateRejected(
1729
- f"pricing candidate does not parse: {exc}") from exc
1730
-
1731
- found: "dict[str, Any]" = {}
1732
- for node in tree.body:
1733
- targets = []
1734
- if isinstance(node, ast.Assign):
1735
- targets = [t for t in node.targets if isinstance(t, ast.Name)]
1736
- value = node.value
1737
- elif isinstance(node, ast.AnnAssign) and isinstance(node.target,
1738
- ast.Name):
1739
- targets = [node.target]
1740
- value = node.value
1741
- else:
1742
- continue
1743
- for target in targets:
1744
- if target.id not in _CANDIDATE_ASSIGNMENTS or value is None:
1745
- continue
1746
- try:
1747
- found[target.id] = ast.literal_eval(value)
1748
- except (ValueError, SyntaxError, TypeError) as exc:
1749
- raise PricingCandidateRejected(
1750
- f"{target.id} is no longer a literal, so the in-process "
1751
- f"pricing reload cannot read it: {exc}") from exc
1752
-
1753
- missing = [name for name in _CANDIDATE_ASSIGNMENTS if name not in found]
1754
- if missing:
1755
- raise PricingCandidateRejected(
1756
- "pricing candidate is incomplete; adopting a partial revision "
1757
- "would clear the write refusal and price from the old tables. "
1758
- f"Missing: {', '.join(missing)}")
1759
-
1760
- if parse_pricing_fingerprint(found["PRICING_SNAPSHOT_DATE"]) is None:
1761
- raise PricingCandidateRejected(
1762
- "pricing candidate has no orderable PRICING_SNAPSHOT_DATE: "
1763
- f"{found['PRICING_SNAPSHOT_DATE']!r}")
1764
-
1765
- return PricingSnapshot(
1766
- snapshot_date=found["PRICING_SNAPSHOT_DATE"],
1767
- claude_pricing=found["CLAUDE_MODEL_PRICING"],
1768
- codex_pricing=found["CODEX_MODEL_PRICING"],
1769
- aliases=found["CODEX_MODEL_ALIASES"],
1770
- tier_thresholds={"claude": found["TIERED_THRESHOLD"],
1771
- "codex": found["CODEX_TIERED_THRESHOLD"]},
1772
- fallback_model=found["CODEX_LEGACY_FALLBACK_MODEL"],
1773
- cache_write_1h_multiplier=found["CACHE_WRITE_1H_MULTIPLIER"],
1774
- fast_multipliers={
1775
- "claude": found["CLAUDE_FAST_MULTIPLIER_OVERRIDES"],
1776
- "codex": found["CODEX_FAST_MULTIPLIER_OVERRIDES"],
1777
- "codex_fallback": found["CODEX_FAST_MULTIPLIER_FALLBACK"],
1778
- },
1779
- )
1526
+ """The snapshot every cost kernel prices from; it never changes after
1527
+ import."""
1528
+ return _LIVE_PRICING_SNAPSHOT
package/bin/cctally CHANGED
@@ -351,6 +351,7 @@ UPDATE_LOCK_PATH = _cctally_core.UPDATE_LOCK_PATH
351
351
  UPDATE_LOG_PATH = _cctally_core.UPDATE_LOG_PATH
352
352
  UPDATE_LOG_ROTATED_PATH = _cctally_core.UPDATE_LOG_ROTATED_PATH
353
353
  UPDATE_CHECK_LAST_FETCH_PATH = _cctally_core.UPDATE_CHECK_LAST_FETCH_PATH
354
+ INSTALL_SUCCESS_RECORD_PATH = _cctally_core.INSTALL_SUCCESS_RECORD_PATH
354
355
 
355
356
  CLAUDE_SETTINGS_PATH = _cctally_core.CLAUDE_SETTINGS_PATH
356
357
 
@@ -1356,6 +1357,10 @@ _persist_npm_prefix_to_state = _cctally_update._persist_npm_prefix_to_state
1356
1357
  _detect_install_method = _cctally_update._detect_install_method
1357
1358
  _persist_install_method_to_state = _cctally_update._persist_install_method_to_state
1358
1359
  _stamp_install_success_to_state = _cctally_update._stamp_install_success_to_state
1360
+ InstallSuccessRecord = _cctally_update.InstallSuccessRecord
1361
+ _write_install_success_record = _cctally_update._write_install_success_record
1362
+ _read_install_success_record = _cctally_update._read_install_success_record
1363
+ _update_lock_is_held = _cctally_update._update_lock_is_held
1359
1364
  _self_heal_current_version = _cctally_update._self_heal_current_version
1360
1365
  _BREW_VERSION_RE_LIST = _cctally_update._BREW_VERSION_RE_LIST
1361
1366
  _update_user_agent = _cctally_update._update_user_agent
@@ -1388,8 +1393,17 @@ _preflight_install = _cctally_update._preflight_install
1388
1393
  _build_update_steps = _cctally_update._build_update_steps
1389
1394
  _run_streaming = _cctally_update._run_streaming
1390
1395
  _do_update_install = _cctally_update._do_update_install
1396
+ _install_root_for_entrypoint = _cctally_update._install_root_for_entrypoint
1397
+ _read_changelog_version_at = _cctally_update._read_changelog_version_at
1398
+ _install_layout = _cctally_update._install_layout
1391
1399
  _resolve_execvp_target = _cctally_update._resolve_execvp_target
1400
+ _execvp_target_problem = _cctally_update._execvp_target_problem
1401
+ AutomaticRestartInProgress = _cctally_update.AutomaticRestartInProgress
1392
1402
  UpdateWorker = _cctally_update.UpdateWorker
1403
+ InstallBootState = _cctally_update.InstallBootState
1404
+ capture_install_boot_state = _cctally_update.capture_install_boot_state
1405
+ RestartCandidate = _cctally_update.RestartCandidate
1406
+ InstallWatch = _cctally_update.InstallWatch
1393
1407
  _DashboardUpdateCheckThread = _cctally_update._DashboardUpdateCheckThread
1394
1408
  cmd_update = _cctally_update.cmd_update
1395
1409
  _args_emit_json = _cctally_update._args_emit_json
@@ -2661,14 +2675,13 @@ def _warn_budget_bad_config_once(exc: Exception) -> None:
2661
2675
  # server boot in cmd_dashboard. The moved _resolve_execvp_target
2662
2676
  # uses them to return (entrypoint, exec_argv) for os.execvp:
2663
2677
  #
2664
- # - npm: entrypoint = <prefix>/bin/cctally → Node shim, which
2665
- # re-resolves CCTALLY_PYTHON before re-spawning Python (so a
2666
- # custom interpreter setting survives the restart).
2678
+ # - npm: the interpreter the Node shim would use (CCTALLY_PYTHON,
2679
+ # else python3 on PATH) on the install's bin/cctally, so a restart
2680
+ # adds no Node process (#868).
2667
2681
  # - brew: entrypoint = <brew>/bin/cctally → symlink into the
2668
2682
  # post-upgrade Python script with its rewritten shebang.
2669
2683
  # - Fallback when shutil.which("cctally") returned None: use
2670
- # sys.argv[0] directly. Loses the npm shim layer; we accept the
2671
- # degraded edge case rather than guess.
2684
+ # sys.argv[0] directly.
2672
2685
  ORIGINAL_SYS_ARGV: list[str] = []
2673
2686
  ORIGINAL_ENTRYPOINT: "str | None" = None
2674
2687
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cctally",
3
- "version": "1.110.0",
3
+ "version": "1.111.0",
4
4
  "description": "Claude Code usage tracker and local dashboard for Pro/Max subscription limits - weekly cost-per-percent trend, quota forecasts, threshold alerts. ccusage-compatible.",
5
5
  "homepage": "https://github.com/omrikais/cctally",
6
6
  "repository": {