cctally 1.87.1 → 1.88.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 +11 -0
- package/README.md +2 -2
- package/bin/_cctally_cache.py +134 -2
- package/bin/_cctally_dashboard_envelope.py +3 -2
- package/bin/_cctally_dashboard_sources.py +306 -180
- package/bin/_cctally_db.py +122 -0
- package/bin/_cctally_doctor.py +25 -1
- package/bin/_cctally_transcript.py +7 -2
- package/bin/_cctally_tui.py +20 -7
- package/bin/_lib_codex_conversation.py +17 -0
- package/bin/_lib_codex_conversation_query.py +16 -2
- package/bin/_lib_dashboard_sources.py +8 -1
- package/bin/_lib_doctor.py +57 -0
- package/bin/_lib_jsonl.py +35 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,17 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.88.0] - 2026-07-31
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- The Codex hero's Snapshot chip no longer reports stale evidence while the dashboard's own freshness state for the same quota window reads fresh. One `/api/data` response could carry two different capture timestamps for a single quota window, and the chip aged off the older one: the initial build stamped the active row with the timestamp of the last reading whose VALUE changed, while that same row's freshness came from the last reading actually received — and because OpenAI repeats an unchanged percentage while you are idle, the two drift apart in exactly the common case. `active[].captured_at` now always means evidence recency, the same observation behind the row's freshness, and the percentage it reports is unchanged. The idle clock additionally refreshes every per-account view rather than only the merged one, so a focused account no longer reads freshness frozen at page load, and the hero's own copy of the quota summary is re-derived on each tick instead of keeping its build-time value. Retained quota windows and their live rows are now capped together, so an install tracking more than 250 windows can no longer publish a live row whose history was dropped or a summary percentage belonging to a window that was. The source envelope's `source_schema_version` moves to 2, because a published value changed meaning and a dashboard tab left open across an in-place upgrade does meet the new server over its existing event stream. That tab keeps working either way — nothing reads the version yet — so the bump is a correctness signal for future readers rather than the thing that preserves compatibility. (#429)
|
|
12
|
+
- Codex sessions started outside the Desktop app — through the Codex MCP server, the CLI, `exec`, subagents, and older Desktop builds — now appear in the Conversation Viewer and resolve their project in Recent Sessions. Codex is part-way through rolling out the field cctally used to identify a conversation, and until now a session whose log omitted it was given no identity at all: its transcript was never indexed, and the sessions card could only report that project metadata was unavailable. cctally now infers the missing value, so those sessions are ordinary sessions with no visible difference from any other. On upgrade, existing history is re-read once in the background to repair it; a session's spend stays attributed to the account it was always attributed to, and a session whose log carries no usable identifier at all is left alone rather than guessed at. Because the repair re-reads the session logs themselves, totals are unchanged for every session still on disk — but a session whose log Codex has since deleted is no longer counted, so long-run totals can fall slightly. Sessions with no working directory recorded still appear, and still honestly report their project as unavailable. One visible consequence of that re-read: it briefly restores transcripts older than your configured retention window, so the ordinary retention sweep runs straight afterwards and those older conversations leave the Conversation Viewer on upgrade instead of lingering past their window.
|
|
13
|
+
|
|
14
|
+
## [1.87.2] - 2026-07-30
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- The dashboard no longer freezes provider data while Claude or Codex sessions are continuously active. A completed read now publishes the coherent cache snapshot it actually built even when newer session bytes arrive before the build finishes, so Codex quota percentages and Recent Sessions keep advancing instead of remaining pinned to an older generation. Stats-side changes during a build still fail closed to the prior complete bundle.
|
|
18
|
+
|
|
8
19
|
## [1.87.1] - 2026-07-30
|
|
9
20
|
|
|
10
21
|
### Fixed
|
package/README.md
CHANGED
|
@@ -30,9 +30,9 @@ Your Claude Code plan meters you with a percentage that creeps up all week. ccta
|
|
|
30
30
|
</p>
|
|
31
31
|
|
|
32
32
|
<!-- cctally:latest-stable:begin -->
|
|
33
|
-
**Latest stable: v1.
|
|
33
|
+
**Latest stable: v1.87.1** (2026-07-30)
|
|
34
34
|
|
|
35
|
-
-
|
|
35
|
+
- Codex session names now remain visible in Recent Sessions when an individual account is selected, whenever transcript visibility is enabled. Selecting an account no longer replaces every session name with an em dash; disabling transcript visibility still hides names in both the all-accounts and focused-account views.
|
|
36
36
|
<!-- cctally:latest-stable:end -->
|
|
37
37
|
|
|
38
38
|
## Quick start
|
package/bin/_cctally_cache.py
CHANGED
|
@@ -511,6 +511,38 @@ _codex_conversation_fts_full_clear = _cctally_db_sib._codex_conversation_fts_ful
|
|
|
511
511
|
# writes (#179) so the ON CONFLICT idiom lives in one place. Caller commits.
|
|
512
512
|
_set_cache_meta = _cctally_db_sib._set_cache_meta
|
|
513
513
|
|
|
514
|
+
# Byte-zero Codex replay markers (spec
|
|
515
|
+
# docs/superpowers/specs/2026-07-30-codex-thread-source-inference-design.md
|
|
516
|
+
# §4.3). Cache migration 035 / conversations migration 002 write them and clear
|
|
517
|
+
# NO table; the sync functions consume them, because only the sync owns the
|
|
518
|
+
# replay semantics that keep the repair safe:
|
|
519
|
+
#
|
|
520
|
+
# * `sync_codex_cache` ORs the cache-side marker into its own `rebuild`, so
|
|
521
|
+
# the rebuild path captures `rebuild_known_identities` before clearing. A
|
|
522
|
+
# migration clearing `codex_session_files` directly would leave the next
|
|
523
|
+
# ordinary sync with an empty snapshot, sending every re-read rollout to the
|
|
524
|
+
# live-`auth.json` branch and re-attributing historical spend (§4.1).
|
|
525
|
+
# * `sync_codex_conversations` defers on the CONVERSATIONS marker until the
|
|
526
|
+
# cache-side one has cleared, because `_recompute_codex_rollups` reads the
|
|
527
|
+
# thread row from cache.db and a missing one stamps a materialized
|
|
528
|
+
# "(unassigned)" project the read path then prefers permanently (§4.2).
|
|
529
|
+
#
|
|
530
|
+
# The conversations key is deliberately DISTINCT from
|
|
531
|
+
# `conversation_rebuild_codex_pending`: `_ensure_codex_conversation_contract`
|
|
532
|
+
# consumes that one by replaying normalization over already-retained events —
|
|
533
|
+
# which preserves their NULL conversation keys — and then deletes it, silently
|
|
534
|
+
# discarding the repair.
|
|
535
|
+
#
|
|
536
|
+
# The keys themselves are defined in the pure kernel `_lib_codex_conversation`
|
|
537
|
+
# and re-exported here, so the read-side authority probe and the doctor shell
|
|
538
|
+
# bind the same names instead of repeating a SQL string literal.
|
|
539
|
+
CODEX_REPLAY_FROM_ZERO_KEY = (
|
|
540
|
+
_lib_codex_conversation.CODEX_REPLAY_FROM_ZERO_KEY)
|
|
541
|
+
CODEX_CONVERSATION_REPLAY_FROM_ZERO_KEY = (
|
|
542
|
+
_lib_codex_conversation.CODEX_CONVERSATION_REPLAY_FROM_ZERO_KEY)
|
|
543
|
+
CODEX_REPLAY_BLOCKED_KEY = (
|
|
544
|
+
_lib_codex_conversation.CODEX_REPLAY_BLOCKED_KEY)
|
|
545
|
+
|
|
514
546
|
|
|
515
547
|
# cache.db WAL hardening (#297). See
|
|
516
548
|
# docs/superpowers/specs/2026-07-13-cache-db-wal-hardening-design.md.
|
|
@@ -4903,6 +4935,24 @@ def sync_codex_cache(
|
|
|
4903
4935
|
# and bypasses every whole-tree operation (orphan prune, root prune,
|
|
4904
4936
|
# global quota reconcile) — see the guards threaded through below.
|
|
4905
4937
|
targeted = only_paths is not None
|
|
4938
|
+
|
|
4939
|
+
# A pending byte-zero replay is consumed HERE, not by the migration that
|
|
4940
|
+
# armed it, so the rebuild path below captures `rebuild_known_identities`
|
|
4941
|
+
# before clearing. A migration that cleared `codex_session_files`
|
|
4942
|
+
# directly would leave the next ordinary sync with an empty snapshot,
|
|
4943
|
+
# sending every re-read rollout to the live-auth branch and
|
|
4944
|
+
# re-attributing historical spend to whoever is authenticated now.
|
|
4945
|
+
replay_pending = conn.execute(
|
|
4946
|
+
"SELECT 1 FROM cache_meta WHERE key=?",
|
|
4947
|
+
(CODEX_REPLAY_FROM_ZERO_KEY,),
|
|
4948
|
+
).fetchone() is not None
|
|
4949
|
+
if replay_pending and targeted:
|
|
4950
|
+
# A live-tail tick must DEFER, never raise through the
|
|
4951
|
+
# `targeted and rebuild` guard below.
|
|
4952
|
+
stats.deferred_reason = "replay_pending"
|
|
4953
|
+
return stats
|
|
4954
|
+
rebuild = rebuild or replay_pending
|
|
4955
|
+
|
|
4906
4956
|
if targeted and rebuild:
|
|
4907
4957
|
raise ValueError(
|
|
4908
4958
|
"sync_codex_cache: only_paths is incompatible with rebuild")
|
|
@@ -5742,6 +5792,35 @@ def sync_codex_cache(
|
|
|
5742
5792
|
else:
|
|
5743
5793
|
conn.execute("DELETE FROM cache_meta WHERE key = ?",
|
|
5744
5794
|
("codex_torn_auth_deferred",))
|
|
5795
|
+
# Consume the byte-zero replay marker only after a clean full walk,
|
|
5796
|
+
# and only when THIS call observed it. A contended call returned
|
|
5797
|
+
# long before here, and a walk that failed or deferred a file leaves
|
|
5798
|
+
# the marker standing — a surviving marker is what makes the repair
|
|
5799
|
+
# retry on the next sync, and it is also what keeps
|
|
5800
|
+
# `sync_codex_conversations` deferred until the cache side genuinely
|
|
5801
|
+
# holds the replayed thread rows (§4.2). The `replay_pending` guard
|
|
5802
|
+
# is defense in depth: `open_cache_db` and this walk share an
|
|
5803
|
+
# exclusive lock today, so nothing can arm the marker in between —
|
|
5804
|
+
# but the conversations side has no such exclusion, and the two
|
|
5805
|
+
# clears must keep the same shape.
|
|
5806
|
+
if stats.files_failed == 0 and stats.files_deferred_torn == 0:
|
|
5807
|
+
if replay_pending:
|
|
5808
|
+
conn.execute("DELETE FROM cache_meta WHERE key = ?",
|
|
5809
|
+
(CODEX_REPLAY_FROM_ZERO_KEY,))
|
|
5810
|
+
conn.execute("DELETE FROM cache_meta WHERE key = ?",
|
|
5811
|
+
(CODEX_REPLAY_BLOCKED_KEY,))
|
|
5812
|
+
elif replay_pending:
|
|
5813
|
+
# A full walk ran and could NOT consume the marker, so the
|
|
5814
|
+
# replay — and with it every Codex transcript ingest, which
|
|
5815
|
+
# defers behind this marker — is stalled rather than merely
|
|
5816
|
+
# not-yet-run. `doctor` reads this; the deferral itself stays,
|
|
5817
|
+
# because running ahead is what stamps "(unassigned)" (§4.2).
|
|
5818
|
+
_set_cache_meta(conn, CODEX_REPLAY_BLOCKED_KEY, json.dumps({
|
|
5819
|
+
"at": dt.datetime.now(dt.timezone.utc).isoformat(
|
|
5820
|
+
timespec="seconds").replace("+00:00", "Z"),
|
|
5821
|
+
"files_failed": stats.files_failed,
|
|
5822
|
+
"files_deferred_torn": stats.files_deferred_torn,
|
|
5823
|
+
}, sort_keys=True))
|
|
5745
5824
|
conn.commit()
|
|
5746
5825
|
# Window-scoped spend adoption (spec
|
|
5747
5826
|
# docs/superpowers/specs/2026-07-30-codex-window-scoped-spend-adoption.md).
|
|
@@ -7747,6 +7826,15 @@ def _ensure_codex_conversation_contract(conn: sqlite3.Connection) -> bool:
|
|
|
7747
7826
|
JSONL. They still must remain usable after an upgrade, so replay only the
|
|
7748
7827
|
already-retained physical events under the provider-local conversation lock.
|
|
7749
7828
|
Empty stores keep their existing rebuild marker for the next real sync.
|
|
7829
|
+
|
|
7830
|
+
This must NEVER consume ``CODEX_CONVERSATION_REPLAY_FROM_ZERO_KEY`` (§4.3).
|
|
7831
|
+
Do not merge the two keys during a tidy-up: this replay runs over
|
|
7832
|
+
already-retained events, which preserves their NULL conversation keys, and
|
|
7833
|
+
then deletes the flag it consumed — so a ``dashboard --no-sync`` or qualified
|
|
7834
|
+
CLI read landing between the migration and the next real sync would silently
|
|
7835
|
+
discard the byte-zero repair. Only a re-read from offset zero can mint the
|
|
7836
|
+
missing identities, which is why that marker belongs to
|
|
7837
|
+
``sync_codex_conversations`` alone.
|
|
7750
7838
|
"""
|
|
7751
7839
|
current = _lib_codex_conversation.CODEX_CONVERSATION_CONTRACT_VERSION
|
|
7752
7840
|
|
|
@@ -8145,6 +8233,24 @@ def sync_claude_conversations(
|
|
|
8145
8233
|
return stats
|
|
8146
8234
|
|
|
8147
8235
|
|
|
8236
|
+
def _cache_side_replay_pending(conn: sqlite3.Connection) -> bool:
|
|
8237
|
+
"""Whether cache.db still has a byte-zero Codex replay pending (§4.3).
|
|
8238
|
+
|
|
8239
|
+
Read through the ``cache_db`` attachment conversation connections already
|
|
8240
|
+
carry, and qualified: ``cache_meta`` exists in BOTH stores, so an unqualified
|
|
8241
|
+
name would resolve to conversations.db's own table and never see the
|
|
8242
|
+
cache-side marker. A bare or legacy connection without the attachment
|
|
8243
|
+
reports False rather than raising, so it cannot wedge the conversations sync.
|
|
8244
|
+
"""
|
|
8245
|
+
try:
|
|
8246
|
+
return conn.execute(
|
|
8247
|
+
"SELECT 1 FROM cache_db.cache_meta WHERE key=?",
|
|
8248
|
+
(CODEX_REPLAY_FROM_ZERO_KEY,),
|
|
8249
|
+
).fetchone() is not None
|
|
8250
|
+
except sqlite3.OperationalError:
|
|
8251
|
+
return False
|
|
8252
|
+
|
|
8253
|
+
|
|
8148
8254
|
def _clear_codex_conversation_store(conn: sqlite3.Connection) -> None:
|
|
8149
8255
|
"""Clear only the re-derivable Codex transcript families."""
|
|
8150
8256
|
conn.execute("DELETE FROM codex_conversation_events")
|
|
@@ -8191,10 +8297,25 @@ def sync_codex_conversations(
|
|
|
8191
8297
|
!= _lib_codex_conversation.CODEX_CONVERSATION_CONTRACT_VERSION
|
|
8192
8298
|
)
|
|
8193
8299
|
)
|
|
8194
|
-
|
|
8300
|
+
codex_replay_pending = conn.execute(
|
|
8301
|
+
"SELECT 1 FROM cache_meta WHERE key=?",
|
|
8302
|
+
(CODEX_CONVERSATION_REPLAY_FROM_ZERO_KEY,),
|
|
8303
|
+
).fetchone() is not None
|
|
8304
|
+
# Ordering (§4.2): the cache replay must finish first.
|
|
8305
|
+
# `_recompute_codex_rollups` reads `codex_conversation_threads` from
|
|
8306
|
+
# cache.db, and a missing thread row does not yield NULL — it stamps a
|
|
8307
|
+
# materialized "(unassigned)" project that the read path then PREFERS,
|
|
8308
|
+
# permanently, for any conversation with no later activity. The two
|
|
8309
|
+
# stores are synced by independent paths (the dashboard runs conversation
|
|
8310
|
+
# sync in its own worker), so nothing else orders them.
|
|
8311
|
+
if _cache_side_replay_pending(conn):
|
|
8312
|
+
stats.deferred_reason = "cache_replay_pending"
|
|
8313
|
+
return stats
|
|
8314
|
+
if (pending_rebuild or contract_rebuild or codex_replay_pending) and targeted:
|
|
8195
8315
|
stats.deferred_reason = "rebuild_pending"
|
|
8196
8316
|
return stats
|
|
8197
|
-
rebuild =
|
|
8317
|
+
rebuild = (
|
|
8318
|
+
rebuild or pending_rebuild or contract_rebuild or codex_replay_pending)
|
|
8198
8319
|
if rebuild:
|
|
8199
8320
|
conn.execute(
|
|
8200
8321
|
"INSERT OR REPLACE INTO cache_meta(key,value) VALUES(?,?)",
|
|
@@ -8494,6 +8615,17 @@ def sync_codex_conversations(
|
|
|
8494
8615
|
"DELETE FROM cache_meta "
|
|
8495
8616
|
"WHERE key='conversation_rebuild_codex_pending'"
|
|
8496
8617
|
)
|
|
8618
|
+
# Clear ONLY the marker this call observed. The dispatcher that
|
|
8619
|
+
# arms it holds `CONVERSATIONS_LOCK_MAINTENANCE_PATH` shared while
|
|
8620
|
+
# this walk serializes on `CONVERSATIONS_LOCK_CODEX_PATH`, so a
|
|
8621
|
+
# marker armed after the read above belongs to a replay this walk
|
|
8622
|
+
# never performed — and deleting it would strand the repair for
|
|
8623
|
+
# good, since the migration is stamped and never re-arms.
|
|
8624
|
+
if codex_replay_pending:
|
|
8625
|
+
conn.execute(
|
|
8626
|
+
"DELETE FROM cache_meta WHERE key=?",
|
|
8627
|
+
(CODEX_CONVERSATION_REPLAY_FROM_ZERO_KEY,),
|
|
8628
|
+
)
|
|
8497
8629
|
conn.commit()
|
|
8498
8630
|
_report_conversation_progress(progress, "checkpoint", stats)
|
|
8499
8631
|
_harden_conversation_sidecars()
|
|
@@ -53,6 +53,7 @@ from _cctally_core import (
|
|
|
53
53
|
_get_budget_config,
|
|
54
54
|
)
|
|
55
55
|
from _lib_dashboard_sources import (
|
|
56
|
+
SOURCE_SCHEMA_VERSION,
|
|
56
57
|
dashboard_resource_key as _dashboard_resource_key,
|
|
57
58
|
)
|
|
58
59
|
from _lib_display_tz import _compute_display_block, format_display_dt
|
|
@@ -720,7 +721,7 @@ def _source_bundle_to_envelope(bundle: object | None) -> dict:
|
|
|
720
721
|
unavailable = {source: _unavailable_source_wire() for source in ("claude", "codex", "all")}
|
|
721
722
|
if bundle is None:
|
|
722
723
|
return {
|
|
723
|
-
"source_schema_version":
|
|
724
|
+
"source_schema_version": SOURCE_SCHEMA_VERSION,
|
|
724
725
|
"default_source": "claude",
|
|
725
726
|
"source_order": ["claude", "codex", "all"],
|
|
726
727
|
"sources": unavailable,
|
|
@@ -741,7 +742,7 @@ def _source_bundle_to_envelope(bundle: object | None) -> dict:
|
|
|
741
742
|
}
|
|
742
743
|
except Exception: # Never turn an unexpected snapshot shape into request-thread I/O.
|
|
743
744
|
return {
|
|
744
|
-
"source_schema_version":
|
|
745
|
+
"source_schema_version": SOURCE_SCHEMA_VERSION,
|
|
745
746
|
"default_source": "claude",
|
|
746
747
|
"source_order": ["claude", "codex", "all"],
|
|
747
748
|
"sources": unavailable,
|