cctally 1.44.3 → 1.46.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 +10 -0
- package/bin/_cctally_cache.py +94 -19
- package/bin/_cctally_config.py +150 -0
- package/bin/_cctally_dashboard.py +223 -3
- package/bin/_lib_conversation_query.py +143 -4
- package/bin/_lib_conversation_watch.py +67 -0
- package/dashboard/static/assets/index-DHfwfOHz.js +68 -0
- package/dashboard/static/assets/{index-Drpkfv6k.css → index-Dgq5Vvml.css} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-L_FSvwQH.js +0 -68
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,16 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.46.0] - 2026-06-15
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Conversation viewer: the open reader now live-tails an active conversation in near-real time — new turns appear within about a second of the session's file changing (instead of waiting for the periodic 5-second dashboard tick) via a dedicated per-conversation SSE stream that watches only the open session's transcript; the live-tail is on by default and can be turned off with the new `dashboard.live_tail` config key (or the dashboard's Settings → "Conversation viewer" toggle), and `--no-sync` keeps it passive so frozen-data debugging is unaffected.
|
|
12
|
+
|
|
13
|
+
## [1.45.0] - 2026-06-15
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- Conversation viewer: the dashboard now marks any assistant turn that suffered a **prompt-cache failure** — where Claude re-created the bulk of its cached prompt prefix instead of reading it (most often on the first turn after you send a new message — usually well inside the cache TTL, not just on long idle gaps), re-billing those tokens at the higher cache-write rate. The failing turn gets an amber `⚡ CACHE REBUILT · <tokens> · +$<wasted>` chip in its header, and the conversation outline gains a matching amber landmark, a `⚡` quick-jump button (`c` / `C` keys), and a "Cache" count in the at-a-glance stats card. Detection is conservative (per model + thread: `cache_read` collapses below half its running high-water mark **and** ≥75% of the turn's context is freshly re-created, with the unavoidable first cache prime, model switches, and context compaction all excluded), so a typical session shows about one marker. The markers are on by default and can be turned off via the new `dashboard.cache_failure_markers` config key (or the dashboard's Settings → "Conversation viewer" toggle). The wasted-cost figure is a display-only estimate recomputed per read — no new database tables and no migration.
|
|
17
|
+
|
|
8
18
|
## [1.44.3] - 2026-06-15
|
|
9
19
|
|
|
10
20
|
### Fixed
|
package/bin/_cctally_cache.py
CHANGED
|
@@ -465,6 +465,20 @@ class IngestStats:
|
|
|
465
465
|
# new inserts.
|
|
466
466
|
rows_changed: int = 0
|
|
467
467
|
lock_contended: bool = False
|
|
468
|
+
# Targeted (only_paths) live-tail fast-path fields. Default-clean so the
|
|
469
|
+
# only_paths=None callers (every existing caller) read targeted_clean=True
|
|
470
|
+
# and are otherwise unaffected.
|
|
471
|
+
files_failed: int = 0
|
|
472
|
+
deferred_reason: "str | None" = None
|
|
473
|
+
|
|
474
|
+
@property
|
|
475
|
+
def targeted_clean(self) -> bool:
|
|
476
|
+
"""True ⇔ a targeted ingest fully applied: not contended, not deferred,
|
|
477
|
+
and no per-file failure. The watch loop emits + advances `seen` only
|
|
478
|
+
when this is True."""
|
|
479
|
+
return (not self.lock_contended
|
|
480
|
+
and self.deferred_reason is None
|
|
481
|
+
and self.files_failed == 0)
|
|
468
482
|
|
|
469
483
|
|
|
470
484
|
def _progress_stderr(stats: IngestStats, *, force: bool = False) -> None:
|
|
@@ -563,11 +577,43 @@ def _ensure_session_files_row(conn: sqlite3.Connection, source_path: str) -> Non
|
|
|
563
577
|
conn.commit()
|
|
564
578
|
|
|
565
579
|
|
|
580
|
+
# Flags whose presence means the cache is mid-migration / mid-reingest. A
|
|
581
|
+
# targeted (only_paths) ingest DECLINES when any is set and defers to the next
|
|
582
|
+
# full background sync — inserting through a half-migrated FTS shape or skipping
|
|
583
|
+
# a pending backfill would diverge from what a full sync produces (spec §
|
|
584
|
+
# "Targeted ingest contract"). Enumerated against the flag-consumption blocks
|
|
585
|
+
# guarded by the `if not rebuild and not targeted:` branch in sync_cache (the
|
|
586
|
+
# full-sync-only `_consume_*` calls); keep this tuple in sync with those.
|
|
587
|
+
_TARGETED_DECLINE_FLAGS = (
|
|
588
|
+
"conversation_backfill_pending",
|
|
589
|
+
"ai_titles_backfill_pending",
|
|
590
|
+
"conversation_reingest_pending",
|
|
591
|
+
"conversation_source_tool_use_reingest_pending",
|
|
592
|
+
"conversation_reingest_enrichment_pending",
|
|
593
|
+
"conversation_media_reingest_pending",
|
|
594
|
+
"conversation_search_split_pending",
|
|
595
|
+
"conversation_promote_command_args_pending",
|
|
596
|
+
"conversation_sessions_backfill_pending",
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _targeted_has_pending_global_work(conn) -> bool:
|
|
601
|
+
placeholders = ",".join("?" for _ in _TARGETED_DECLINE_FLAGS)
|
|
602
|
+
try:
|
|
603
|
+
row = conn.execute(
|
|
604
|
+
f"SELECT 1 FROM cache_meta WHERE key IN ({placeholders}) LIMIT 1",
|
|
605
|
+
_TARGETED_DECLINE_FLAGS).fetchone()
|
|
606
|
+
except sqlite3.OperationalError:
|
|
607
|
+
return False
|
|
608
|
+
return row is not None
|
|
609
|
+
|
|
610
|
+
|
|
566
611
|
def sync_cache(
|
|
567
612
|
conn: sqlite3.Connection,
|
|
568
613
|
*,
|
|
569
614
|
progress: Callable[[IngestStats], None] | None = None,
|
|
570
615
|
rebuild: bool = False,
|
|
616
|
+
only_paths: "set[str] | None" = None,
|
|
571
617
|
) -> IngestStats:
|
|
572
618
|
"""Read-through delta ingest. Acquires an exclusive fcntl.flock; if
|
|
573
619
|
another process holds it, returns immediately with lock_contended=True
|
|
@@ -592,6 +638,14 @@ def sync_cache(
|
|
|
592
638
|
stats.lock_contended = True
|
|
593
639
|
return stats
|
|
594
640
|
|
|
641
|
+
targeted = only_paths is not None
|
|
642
|
+
if targeted:
|
|
643
|
+
if rebuild:
|
|
644
|
+
raise ValueError("sync_cache: only_paths is incompatible with rebuild")
|
|
645
|
+
if _targeted_has_pending_global_work(conn):
|
|
646
|
+
stats.deferred_reason = "pending_global_flags"
|
|
647
|
+
return stats
|
|
648
|
+
|
|
595
649
|
# Walk-complete sentinel gating (cctally-dev#93, D5b/D6b). Capture
|
|
596
650
|
# whether cache 001 was already applied at the moment this sync
|
|
597
651
|
# acquired the lock. The end-of-loop marker write is gated on this so
|
|
@@ -742,7 +796,7 @@ def sync_cache(
|
|
|
742
796
|
# path, which already cleared the flag and repopulates via the normal
|
|
743
797
|
# walk). A path-less/:memory: conn has no cache_meta only if the schema
|
|
744
798
|
# was never applied; the try/except tolerates that.
|
|
745
|
-
if not rebuild:
|
|
799
|
+
if not rebuild and not targeted:
|
|
746
800
|
try:
|
|
747
801
|
_pending = conn.execute(
|
|
748
802
|
"SELECT 1 FROM cache_meta "
|
|
@@ -865,7 +919,10 @@ def sync_cache(
|
|
|
865
919
|
# HUMAN(text=args); the split-FTS UPDATE triggers re-index the args.
|
|
866
920
|
_consume_promote_command_args(conn)
|
|
867
921
|
|
|
868
|
-
|
|
922
|
+
if targeted:
|
|
923
|
+
paths = [pathlib.Path(p) for p in only_paths if pathlib.Path(p).is_file()]
|
|
924
|
+
else:
|
|
925
|
+
paths = list(_iter_claude_jsonl_files())
|
|
869
926
|
stats.files_total = len(paths)
|
|
870
927
|
|
|
871
928
|
# This SELECT does NOT open an implicit transaction (Python's
|
|
@@ -910,22 +967,29 @@ def sync_cache(
|
|
|
910
967
|
# commit lands BEFORE the per-file read+parse loop, so no write
|
|
911
968
|
# lock is held into that loop (same discipline as the truncation
|
|
912
969
|
# escalation just below).
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
970
|
+
# Targeted (only_paths) sync narrows `paths` to the requested file(s),
|
|
971
|
+
# so the orphan scan below — which infers "deleted from disk" from a
|
|
972
|
+
# tracked path's absence in `paths` — would mistake EVERY other tracked
|
|
973
|
+
# file for an orphan and nuke the walk-complete marker. Skip it entirely
|
|
974
|
+
# for targeted: the live-tail fast path never prunes orphans (the full
|
|
975
|
+
# background sync owns that).
|
|
976
|
+
if not targeted:
|
|
977
|
+
on_disk_paths = {str(jp) for jp in paths}
|
|
978
|
+
orphaned_tracked_paths = [
|
|
979
|
+
p for p, (size_bytes, _, _) in existing.items()
|
|
980
|
+
if size_bytes and p not in on_disk_paths
|
|
981
|
+
]
|
|
982
|
+
if orphaned_tracked_paths:
|
|
983
|
+
eprint(
|
|
984
|
+
f"[cache] {len(orphaned_tracked_paths)} tracked file(s) no "
|
|
985
|
+
f"longer on disk; invalidating walk-complete marker "
|
|
986
|
+
f"(run `cache-sync --rebuild` to prune orphaned entries)"
|
|
987
|
+
)
|
|
988
|
+
conn.execute(
|
|
989
|
+
"DELETE FROM cache_meta WHERE key='claude_ingest_walk_complete'"
|
|
990
|
+
)
|
|
991
|
+
conn.commit()
|
|
992
|
+
walk_clean = False # orphaned rows -> cache doesn't mirror disk (D5a)
|
|
929
993
|
|
|
930
994
|
# Pre-scan for any truncation among tracked files. Under the
|
|
931
995
|
# ccusage-parity ON CONFLICT DO UPDATE, source_path is PINNED to
|
|
@@ -958,6 +1022,14 @@ def sync_cache(
|
|
|
958
1022
|
truncated_paths.add(str(jp))
|
|
959
1023
|
|
|
960
1024
|
if truncated_paths:
|
|
1025
|
+
if targeted:
|
|
1026
|
+
# The targeted fast path must NEVER trigger the global
|
|
1027
|
+
# full-cache wipe-and-re-ingest escalation below — that would
|
|
1028
|
+
# turn a 1s live-tail tick into a multi-minute rebuild and drop
|
|
1029
|
+
# every other session's rows. Decline and defer to the next full
|
|
1030
|
+
# background sync, which owns the truncation escalation.
|
|
1031
|
+
stats.deferred_reason = "truncation"
|
|
1032
|
+
return stats
|
|
961
1033
|
eprint(
|
|
962
1034
|
f"[cache-sync] truncation detected on {len(truncated_paths)} "
|
|
963
1035
|
f"file(s) — re-ingesting all files (safe under ccusage-parity "
|
|
@@ -1035,6 +1107,7 @@ def sync_cache(
|
|
|
1035
1107
|
except OSError as exc:
|
|
1036
1108
|
eprint(f"[cache] stat failed for {jp}: {exc}")
|
|
1037
1109
|
walk_clean = False # skipped a file without ingesting (D5a)
|
|
1110
|
+
stats.files_failed += 1
|
|
1038
1111
|
continue
|
|
1039
1112
|
|
|
1040
1113
|
size = st.st_size
|
|
@@ -1118,6 +1191,7 @@ def sync_cache(
|
|
|
1118
1191
|
except OSError as exc:
|
|
1119
1192
|
eprint(f"[cache] could not read {jp}: {exc}")
|
|
1120
1193
|
walk_clean = False # skipped a file without ingesting (D5a)
|
|
1194
|
+
stats.files_failed += 1
|
|
1121
1195
|
continue
|
|
1122
1196
|
|
|
1123
1197
|
# Python's sqlite3 module starts an implicit transaction on the
|
|
@@ -1240,6 +1314,7 @@ def sync_cache(
|
|
|
1240
1314
|
eprint(f"[cache] db error on {jp}: {exc}")
|
|
1241
1315
|
conn.rollback()
|
|
1242
1316
|
walk_clean = False # rolled back this file without ingesting (D5a)
|
|
1317
|
+
stats.files_failed += 1
|
|
1243
1318
|
continue
|
|
1244
1319
|
|
|
1245
1320
|
if progress is not None:
|
|
@@ -1280,7 +1355,7 @@ def sync_cache(
|
|
|
1280
1355
|
# completeness. A lock-contended sync returned early above and never
|
|
1281
1356
|
# reaches here. Presence (not the timestamp) is the gate signal; the
|
|
1282
1357
|
# value stores the completion instant for doctor/debugging.
|
|
1283
|
-
if walk_clean and applied_at_start:
|
|
1358
|
+
if walk_clean and applied_at_start and not targeted:
|
|
1284
1359
|
conn.execute(
|
|
1285
1360
|
"INSERT INTO cache_meta(key, value) "
|
|
1286
1361
|
"VALUES('claude_ingest_walk_complete', ?) "
|
package/bin/_cctally_config.py
CHANGED
|
@@ -302,6 +302,8 @@ ALLOWED_CONFIG_KEYS = (
|
|
|
302
302
|
"alerts.command_template",
|
|
303
303
|
"dashboard.bind",
|
|
304
304
|
"dashboard.expose_transcripts",
|
|
305
|
+
"dashboard.cache_failure_markers",
|
|
306
|
+
"dashboard.live_tail",
|
|
305
307
|
"update.check.enabled",
|
|
306
308
|
"update.check.ttl_hours",
|
|
307
309
|
"statusline.visual_burn_rate",
|
|
@@ -478,6 +480,46 @@ def _config_known_value(config: dict, key: str) -> "object":
|
|
|
478
480
|
except ValueError:
|
|
479
481
|
return False
|
|
480
482
|
return False
|
|
483
|
+
if key == "dashboard.cache_failure_markers":
|
|
484
|
+
# Boolean opt-OUT (spec §5). Default TRUE — absence is treated as ON
|
|
485
|
+
# (unlike dashboard.expose_transcripts, an opt-IN default of False).
|
|
486
|
+
# A hand-edited junk value surfaces the True default rather than
|
|
487
|
+
# crashing (mirrors dashboard.bind / expose_transcripts).
|
|
488
|
+
block = config.get("dashboard") if isinstance(config, dict) else None
|
|
489
|
+
if not isinstance(block, dict):
|
|
490
|
+
block = {}
|
|
491
|
+
stored = block.get("cache_failure_markers")
|
|
492
|
+
if stored is None:
|
|
493
|
+
return True
|
|
494
|
+
if isinstance(stored, bool):
|
|
495
|
+
return stored
|
|
496
|
+
# Only str spellings are normalizable; any other JSON scalar surfaces
|
|
497
|
+
# the default (the shared normalizer's .strip() would AttributeError on
|
|
498
|
+
# a bare int — uncaught by `except ValueError`).
|
|
499
|
+
if isinstance(stored, str):
|
|
500
|
+
try:
|
|
501
|
+
return c._normalize_alerts_enabled_value(stored)
|
|
502
|
+
except ValueError:
|
|
503
|
+
return True
|
|
504
|
+
return True
|
|
505
|
+
if key == "dashboard.live_tail":
|
|
506
|
+
# Boolean opt-OUT (spec §4.2). Default TRUE — absence is ON. A
|
|
507
|
+
# hand-edited junk value surfaces the True default. Mirrors
|
|
508
|
+
# dashboard.cache_failure_markers exactly.
|
|
509
|
+
block = config.get("dashboard") if isinstance(config, dict) else None
|
|
510
|
+
if not isinstance(block, dict):
|
|
511
|
+
block = {}
|
|
512
|
+
stored = block.get("live_tail")
|
|
513
|
+
if stored is None:
|
|
514
|
+
return True
|
|
515
|
+
if isinstance(stored, bool):
|
|
516
|
+
return stored
|
|
517
|
+
if isinstance(stored, str):
|
|
518
|
+
try:
|
|
519
|
+
return c._normalize_alerts_enabled_value(stored)
|
|
520
|
+
except ValueError:
|
|
521
|
+
return True
|
|
522
|
+
return True
|
|
481
523
|
if key in ("update.check.enabled", "update.check.ttl_hours"):
|
|
482
524
|
# Defaults mirror `_is_update_check_due` (True / 24 hours).
|
|
483
525
|
# Hand-edited junk surfaces as the default — matches dashboard.bind.
|
|
@@ -864,6 +906,83 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
864
906
|
f"{'true' if canonical else 'false'}"
|
|
865
907
|
)
|
|
866
908
|
return 0
|
|
909
|
+
if key == "dashboard.cache_failure_markers":
|
|
910
|
+
# Same read-modify-write posture as dashboard.expose_transcripts:
|
|
911
|
+
# validate first, then write under config_writer_lock with
|
|
912
|
+
# _load_config_unlocked (load_config inside the writer-lock
|
|
913
|
+
# self-deadlocks — fcntl.flock is per-fd). Preserves sibling
|
|
914
|
+
# dashboard.bind / dashboard.expose_transcripts. Reuse the shared
|
|
915
|
+
# bool-normalizer; catch + re-message with the actual key name (it
|
|
916
|
+
# hardcodes "alerts.enabled" in its ValueError text).
|
|
917
|
+
try:
|
|
918
|
+
canonical = c._normalize_alerts_enabled_value(raw)
|
|
919
|
+
except ValueError:
|
|
920
|
+
print(
|
|
921
|
+
f"cctally: invalid boolean value for "
|
|
922
|
+
f"dashboard.cache_failure_markers: "
|
|
923
|
+
f"{raw!r} (expected true|false|yes|no|1|0|on|off)",
|
|
924
|
+
file=sys.stderr,
|
|
925
|
+
)
|
|
926
|
+
return 2
|
|
927
|
+
with config_writer_lock():
|
|
928
|
+
config = _load_config_unlocked()
|
|
929
|
+
existing = config.get("dashboard")
|
|
930
|
+
if existing is not None and not isinstance(existing, dict):
|
|
931
|
+
print(
|
|
932
|
+
"cctally: dashboard config error: dashboard must be an object",
|
|
933
|
+
file=sys.stderr,
|
|
934
|
+
)
|
|
935
|
+
return 2
|
|
936
|
+
block = dict(existing or {})
|
|
937
|
+
block["cache_failure_markers"] = canonical
|
|
938
|
+
config["dashboard"] = block
|
|
939
|
+
save_config(config)
|
|
940
|
+
if getattr(args, "emit_json", False):
|
|
941
|
+
print(
|
|
942
|
+
json.dumps(
|
|
943
|
+
{"dashboard": {"cache_failure_markers": canonical}}, indent=2
|
|
944
|
+
)
|
|
945
|
+
)
|
|
946
|
+
else:
|
|
947
|
+
print(
|
|
948
|
+
f"dashboard.cache_failure_markers="
|
|
949
|
+
f"{'true' if canonical else 'false'}"
|
|
950
|
+
)
|
|
951
|
+
return 0
|
|
952
|
+
if key == "dashboard.live_tail":
|
|
953
|
+
# Mirror dashboard.cache_failure_markers exactly: validate the bool
|
|
954
|
+
# first, then read-modify-write under config_writer_lock with
|
|
955
|
+
# _load_config_unlocked (load_config under the lock self-deadlocks).
|
|
956
|
+
# Preserves sibling dashboard.bind / expose_transcripts /
|
|
957
|
+
# cache_failure_markers. Re-message the shared normalizer's ValueError
|
|
958
|
+
# with the actual key name.
|
|
959
|
+
try:
|
|
960
|
+
canonical = c._normalize_alerts_enabled_value(raw)
|
|
961
|
+
except ValueError:
|
|
962
|
+
print(
|
|
963
|
+
f"cctally: invalid boolean value for dashboard.live_tail: "
|
|
964
|
+
f"{raw!r} (expected true|false|yes|no|1|0|on|off)",
|
|
965
|
+
file=sys.stderr,
|
|
966
|
+
)
|
|
967
|
+
return 2
|
|
968
|
+
with config_writer_lock():
|
|
969
|
+
config = _load_config_unlocked()
|
|
970
|
+
existing = config.get("dashboard")
|
|
971
|
+
if existing is not None and not isinstance(existing, dict):
|
|
972
|
+
print(
|
|
973
|
+
"cctally: dashboard config error: dashboard must be an object",
|
|
974
|
+
file=sys.stderr,
|
|
975
|
+
)
|
|
976
|
+
return 2
|
|
977
|
+
block = dict(existing or {})
|
|
978
|
+
block["live_tail"] = canonical
|
|
979
|
+
config["dashboard"] = block
|
|
980
|
+
save_config(config)
|
|
981
|
+
if getattr(args, "emit_json", False):
|
|
982
|
+
print(json.dumps({"dashboard": {"live_tail": canonical}}, indent=2))
|
|
983
|
+
else:
|
|
984
|
+
print(f"dashboard.live_tail={'true' if canonical else 'false'}")
|
|
985
|
+
return 0
|
|
867
986
|
if key in (
|
|
868
987
|
"statusline.visual_burn_rate",
|
|
869
988
|
"statusline.cost_source",
|
|
@@ -1236,6 +1355,37 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
|
|
|
1236
1355
|
save_config(config)
|
|
1237
1356
|
# idempotent: silent on missing key
|
|
1238
1357
|
return 0
|
|
1358
|
+
if key == "dashboard.cache_failure_markers":
|
|
1359
|
+
# Mirror the dashboard.expose_transcripts unset branch: drop only the
|
|
1360
|
+
# cache_failure_markers leaf; if the dashboard block ends up empty, drop
|
|
1361
|
+
# the parent too so config.json stays tidy. Sibling dashboard.bind /
|
|
1362
|
+
# expose_transcripts survive. Unsetting restores the True (opt-out)
|
|
1363
|
+
# default at read time.
|
|
1364
|
+
with config_writer_lock():
|
|
1365
|
+
config = _load_config_unlocked()
|
|
1366
|
+
block = config.get("dashboard")
|
|
1367
|
+
if isinstance(block, dict) and "cache_failure_markers" in block:
|
|
1368
|
+
del block["cache_failure_markers"]
|
|
1369
|
+
if not block:
|
|
1370
|
+
config.pop("dashboard", None)
|
|
1371
|
+
save_config(config)
|
|
1372
|
+
# idempotent: silent on missing key
|
|
1373
|
+
return 0
|
|
1374
|
+
if key == "dashboard.live_tail":
|
|
1375
|
+
# Mirror the cache_failure_markers unset branch: drop only the
|
|
1376
|
+
# live_tail leaf; if the dashboard block ends up empty, drop the parent
|
|
1377
|
+
# too. Sibling dashboard.bind / expose_transcripts / cache_failure_markers
|
|
1378
|
+
# survive. Unsetting restores the True (opt-out) default at read time.
|
|
1379
|
+
with config_writer_lock():
|
|
1380
|
+
config = _load_config_unlocked()
|
|
1381
|
+
block = config.get("dashboard")
|
|
1382
|
+
if isinstance(block, dict) and "live_tail" in block:
|
|
1383
|
+
del block["live_tail"]
|
|
1384
|
+
if not block:
|
|
1385
|
+
config.pop("dashboard", None)
|
|
1386
|
+
save_config(config)
|
|
1387
|
+
# idempotent: silent on missing key
|
|
1388
|
+
return 0
|
|
1239
1389
|
if key in (
|
|
1240
1390
|
"statusline.visual_burn_rate",
|
|
1241
1391
|
"statusline.cost_source",
|