cctally 1.87.2 → 1.88.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,20 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.88.1] - 2026-07-31
9
+
10
+ ### Fixed
11
+ - Session names no longer disappear from the Recent Sessions card while cctally is pruning old transcripts. The prune held the transcript store's maintenance lock exclusively for its entire pass, and the Sessions card's name lookup is deliberately built to give up instantly rather than wait on that lock, so every Claude session rendered a blank name until the prune finished — minutes on a large store, and for most installs on the v1.88.0 upgrade in particular, whose one-off transcript re-read is followed by an unthrottled sweep. The prune still claims the lock exclusively, which is what keeps it serialized against a second prune or a `cctally db vacuum`, but now holds it shared for the pass itself so read-only lookups are no longer locked out. What gets pruned, and when, is unchanged.
12
+
13
+ ### Security
14
+ - Update the build-time `postcss` dependency to 8.5.25, clearing a path-traversal advisory (GHSA-r28c-9q8g-f849) in its source-map auto-loading. It is a development dependency used only when building the dashboard bundle from source, so it is absent from npm, Homebrew, and every other install, and the shipped dashboard is byte-for-byte unchanged.
15
+
16
+ ## [1.88.0] - 2026-07-31
17
+
18
+ ### Fixed
19
+ - 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)
20
+ - 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.
21
+
8
22
  ## [1.87.2] - 2026-07-30
9
23
 
10
24
  ### Fixed
@@ -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
- if (pending_rebuild or contract_rebuild) and targeted:
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 = rebuild or pending_rebuild or contract_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": 1,
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": 1,
745
+ "source_schema_version": SOURCE_SCHEMA_VERSION,
745
746
  "default_source": "claude",
746
747
  "source_order": ["claude", "codex", "all"],
747
748
  "sources": unavailable,