cctally 1.77.0 → 1.79.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,34 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.79.0] - 2026-07-22
9
+
10
+ ### Added
11
+ - The dashboard hero modal (Current Week / Current Cycle) now navigates history: the week/cycle chip's `‹`/`›` (or `ArrowUp`/`ArrowDown`) steps between previous 7-day billing cycles and a block navigator (or `ArrowLeft`/`ArrowRight`) scrolls a selected cycle's known 5-hour blocks, for both Claude subscription weeks and Codex native quota cycles; the default view stays on the current cycle with its active block. Weeks with a re-anchoring credit render the full chronological ledger with cumulative cost restarting per segment and a credit divider between segments, and empty weeks show an explicit empty state. A new `GET /api/milestones/<source>/week/<key>` route serves each selected week's complete milestone payload on demand, while a compact navigation index rides the live envelope (built only on non-idle snapshot rebuilds). Historic weeks are read-only; the Share action is hidden while a historic week is shown.
12
+ - Qualified Codex reasoning now uses a provider-native title/summary/body treatment instead of Claude Thinking chrome, while empty reasoning stays absent and Claude Thinking is unchanged. Evidence-preserved lifecycle fallbacks render as concise Codex task states, and recognized Git/memory harness markers become privacy-safe system actions with on-demand raw payload access; authored lookalikes remain literal prose. (#334)
13
+ - Qualified Codex conversation detail now preserves and renders plans, web searches, MCP completions, and agent-control operations as bounded provider-native cards with honest request/result/error state, safe web links, and on-demand raw payloads. Proven spawns gain a privacy-safe link to the exact retained child conversation only when same-root parent and canonical task-path evidence is unique; ambiguous, cross-root, malformed, and future shapes stay unlinked with the generic fallback. Local or placeholder Markdown image targets degrade to an explicit unavailable badge without a browser request or rendered path disclosure. (#332)
14
+ - Qualified Codex conversation detail now exposes bounded native terminal, output, and patch cards, and the dashboard renders supported `exec` calls as clean terminals plus `apply_patch`/patch-completion records as faithful diff cards. Provider names remain visible, diff-less events stay honest, raw payloads remain available on demand, and unsupported future wrappers keep the generic fallback. (#331)
15
+ - Beta release channel. Every release now ships to a beta channel first (npm `beta` dist-tag, GitHub prerelease); the maintainer later promotes chosen releases to stable (npm `latest`, GitHub "Latest", the Homebrew tap). Opt in with `cctally config set update.channel beta`: `cctally update` then tracks SemVer-max(beta, latest) and installs the exact resolved version (never a bare `@beta`), a `(beta)` marker appears on the CLI banner and the dashboard update badge/modal, the dashboard settings gain an Update-channel toggle, and `cctally doctor` reports the configured channel (warning on beta + Homebrew, which tracks stable only). Stable stays the default and flipping back to it never silently downgrades.
16
+
17
+ ### Fixed
18
+ - The compact qualified conversation reader now keeps provider notification labels intact and the complete Codex input/output/cached-input/reasoning-output subtitle visible without clipping or horizontal overflow. Retained Codex world-state, inter-agent metadata, turn-context control fields, and unknown future record families stay replay-safe but no longer risk becoming fabricated transcript noise or exposing private harness state. (#335)
19
+ - The dashboard's qualified conversation reader now keeps Codex reasoning, tool calls/results, injected metadata labels, and lifecycle events in their native semantic roles instead of collapsing them into generic background rows. Skill-invocation titles no longer expose local `SKILL.md` paths, the outline prioritizes real prompts, logical assistant responses, and compactions while retaining full detail payload, token, model, and cost data, and an overflow menu opened immediately after reader mount is no longer closed by delayed initialization. (#330)
20
+
21
+ ## [1.78.0] - 2026-07-21
22
+
23
+ ### Changed
24
+ - Dashboard Codex Daily, Weekly, and Monthly details now retain native Input, Cached input, Output, Reasoning, and Total counters; Codex Weekly uses reset-cycle vocabulary, All Weekly uses neutral provider-period wording, and partial-metadata projects with the same basename remain collision-safe without exposing identities. (#329)
25
+ - Dashboard Codex session, project, and quota-block details now use bounded native hierarchies, localized timestamps, retained quota progression, and accessible prompt expansion across desktop and mobile. (#325)
26
+ - Dashboard All-mode Claude project and session rows now open the same useful detail hierarchy and cross-navigation as the canonical Claude views while keeping native session IDs and source paths private. (#325)
27
+ - Dashboard All-mode Forecast and Cache Report cards and details now compose separate, provider-labelled Claude and Codex reports with provider-local values, confidence, and degraded or unavailable reasons instead of presenting Codex-only data under an All heading. (#324)
28
+ - Dashboard All-mode Weekly and `$ / 1% Trend` views now keep Claude and Codex quota histories in separate provider-labelled sections and series, while explicitly combining only compatible cost totals. (#324)
29
+
30
+ ### Fixed
31
+ - Dashboard narrow layouts now keep degraded source status readable, hide the optional Codex five-hour slot when no native 300-minute window is reported, label real All-mode five-hour rows by provider, and bound unavailable combined-total warnings without losing their accessible detail. (#329)
32
+ - Dashboard source-wide freshness and read-model warnings now reach Daily and Projects without hiding the fixed ten-card board; Current Usage and Share stay bound to the source that opened them, and All shows separate Claude and Codex current-cycle sections instead of silently falling back to Claude. (#328)
33
+ - Dashboard HTTP and live-event JSON now convert non-finite numeric values to `null` at one strict outbound boundary, so missing Doctor freshness markers no longer emit browser-invalid `Infinity` tokens and the full Doctor report opens normally. (#327)
34
+ - Claude 5-hour and 7-day usage now receive one account-wide authoritative OAuth confirmation per 30-second status-line cycle, so a stale Claude Code `rate_limits` payload can no longer leave dashboard milestones behind for up to five minutes. The confirmation is detached from rendering, shares the existing hook throttle lock, and preserves `Retry-After` plus exponential `429` backoff.
35
+
8
36
  ## [1.77.0] - 2026-07-21
9
37
 
10
38
  ### Added
package/README.md CHANGED
@@ -49,6 +49,8 @@ cctally dashboard # opens http://127.0.0.1:8789
49
49
 
50
50
  For status-line integration and tuning, see [docs/installation.md](docs/installation.md) and [docs/configuration.md](docs/configuration.md).
51
51
 
52
+ **Beta channel (opt-in).** Every release ships to a beta channel first; the maintainer promotes the ones that prove out to stable, which is the default. To ride along with the newest builds as they land, opt in with `cctally config set update.channel beta` (npm or source installs — Homebrew tracks stable). `cctally update` then installs the exact latest beta version, and `cctally config set update.channel stable` flips you back cleanly with no silent downgrade. See [docs/commands/update.md](docs/commands/update.md#beta-channel).
53
+
52
54
  ## The live dashboard
53
55
 
54
56
  `cctally dashboard` serves a web app at `localhost:8789` that updates live as you work - no refresh, no polling. Eleven panels cover the whole picture: **current week**, **forecast**, **$/1% trend**, **recent sessions**, **weekly**, **monthly**, **5-hour blocks**, **daily heatmap**, **projects**, **cache report**, and **recent alerts**. Any panel expands into a focused view, sessions are filterable and searchable, and a settings drawer tunes alerts and display options on the fly. It runs only on your own machine by default; one flag opens it to the other devices on your network when you want that.
@@ -1044,6 +1044,52 @@ def _replay_codex_normalization(conn: sqlite3.Connection) -> None:
1044
1044
  _recompute_codex_rollups(conn, affected)
1045
1045
 
1046
1046
 
1047
+ def _repair_codex_turn_ids_for_source(
1048
+ conn: sqlite3.Connection, source_path: str,
1049
+ ) -> set[str]:
1050
+ """Reconcile stored normalized rows after a late native turn anchor arrives.
1051
+
1052
+ Active Codex streams can emit the response before a later completion, patch,
1053
+ or abort record exposes its turn id. Run this bounded per-file repair only
1054
+ when such a native proof arrives; ordinary append ticks remain delta-only.
1055
+ The physical log stays authoritative.
1056
+ """
1057
+ events = [
1058
+ _lib_jsonl.CodexPhysicalEvent(*row)
1059
+ for row in conn.execute(
1060
+ "SELECT source_path, line_offset, source_root_key, conversation_key, "
1061
+ "native_thread_id, root_thread_id, parent_thread_id, timestamp_utc, "
1062
+ "record_type, event_type, turn_id, call_id, payload_json "
1063
+ "FROM codex_conversation_events WHERE source_path=? ORDER BY line_offset",
1064
+ (source_path,),
1065
+ )
1066
+ ]
1067
+ inferred, _terminal = _lib_codex_conversation.infer_codex_event_turns(events)
1068
+ expected = {event.line_offset: turn for event, turn in zip(events, inferred)}
1069
+ affected = {
1070
+ row[0]
1071
+ for row in conn.execute(
1072
+ "SELECT DISTINCT conversation_key FROM codex_conversation_messages "
1073
+ "WHERE source_path=?",
1074
+ (source_path,),
1075
+ )
1076
+ if row[0]
1077
+ }
1078
+ for line_offset, stored_turn in conn.execute(
1079
+ "SELECT line_offset, turn_id FROM codex_conversation_messages "
1080
+ "WHERE source_path=?",
1081
+ (source_path,),
1082
+ ):
1083
+ inferred_turn = expected.get(line_offset)
1084
+ if stored_turn != inferred_turn:
1085
+ conn.execute(
1086
+ "UPDATE codex_conversation_messages SET turn_id=? "
1087
+ "WHERE source_path=? AND line_offset=?",
1088
+ (inferred_turn, source_path, line_offset),
1089
+ )
1090
+ return affected
1091
+
1092
+
1047
1093
  def _collect_inactive_codex_paths_and_roots(
1048
1094
  conn: sqlite3.Connection,
1049
1095
  current_file_identities: set[tuple[str, str]],
@@ -4849,6 +4895,7 @@ def open_conversations_db(*, attach_cache: bool = True) -> sqlite3.Connection:
4849
4895
  cache_uri = _cctally_core.CACHE_DB_PATH.resolve().as_uri() + "?mode=ro"
4850
4896
  conn.execute("ATTACH DATABASE ? AS cache_db", (cache_uri,))
4851
4897
  _import_legacy_conversation_rows(conn)
4898
+ _ensure_codex_conversation_contract(conn)
4852
4899
  _harden_conversation_sidecars()
4853
4900
  return conn
4854
4901
 
@@ -4900,6 +4947,68 @@ def _import_legacy_conversation_rows(conn: sqlite3.Connection) -> None:
4900
4947
  conn.commit()
4901
4948
 
4902
4949
 
4950
+ def _ensure_codex_conversation_contract(conn: sqlite3.Connection) -> bool:
4951
+ """Converge retained Codex events on first read after a contract bump.
4952
+
4953
+ Qualified CLI reads and ``dashboard --no-sync`` intentionally do not ingest
4954
+ JSONL. They still must remain usable after an upgrade, so replay only the
4955
+ already-retained physical events under the provider-local conversation lock.
4956
+ Empty stores keep their existing rebuild marker for the next real sync.
4957
+ """
4958
+ current = _lib_codex_conversation.CODEX_CONVERSATION_CONTRACT_VERSION
4959
+
4960
+ def needs_replay() -> bool:
4961
+ version = conn.execute(
4962
+ "SELECT value FROM cache_meta "
4963
+ "WHERE key='codex_conversation_contract_version'"
4964
+ ).fetchone()
4965
+ pending = conn.execute(
4966
+ "SELECT 1 FROM cache_meta "
4967
+ "WHERE key='conversation_rebuild_codex_pending'"
4968
+ ).fetchone() is not None
4969
+ has_events = conn.execute(
4970
+ "SELECT 1 FROM codex_conversation_events LIMIT 1"
4971
+ ).fetchone() is not None
4972
+ return has_events and (pending or version is None or version[0] != current)
4973
+
4974
+ if not needs_replay():
4975
+ return False
4976
+
4977
+ _cctally_core.CONVERSATIONS_LOCK_CODEX_PATH.touch()
4978
+ lock_fh = open(_cctally_core.CONVERSATIONS_LOCK_CODEX_PATH, "w")
4979
+ try:
4980
+ if not _acquire_cache_flock(lock_fh, timeout=15.0):
4981
+ return False
4982
+ if not needs_replay():
4983
+ return False
4984
+ try:
4985
+ conn.execute("BEGIN IMMEDIATE")
4986
+ _codex_conversation_fts_full_clear(conn)
4987
+ _replay_codex_normalization(conn)
4988
+ conn.execute(
4989
+ "INSERT OR REPLACE INTO cache_meta(key,value) VALUES(?,?)",
4990
+ ("codex_conversation_contract_version", current),
4991
+ )
4992
+ conn.execute(
4993
+ "DELETE FROM cache_meta "
4994
+ "WHERE key='conversation_rebuild_codex_pending'"
4995
+ )
4996
+ conn.commit()
4997
+ return True
4998
+ except (sqlite3.DatabaseError, OSError, ValueError) as exc:
4999
+ conn.rollback()
5000
+ eprint(
5001
+ f"[codex-conversations] retained-event contract replay failed: {exc}"
5002
+ )
5003
+ return False
5004
+ finally:
5005
+ try:
5006
+ fcntl.flock(lock_fh, fcntl.LOCK_UN)
5007
+ except OSError:
5008
+ pass
5009
+ lock_fh.close()
5010
+
5011
+
4903
5012
  def _prepare_claude_conversation_maintenance(
4904
5013
  conn: sqlite3.Connection,
4905
5014
  *,
@@ -5240,10 +5349,25 @@ def sync_codex_conversations(
5240
5349
  "SELECT 1 FROM cache_meta "
5241
5350
  "WHERE key='conversation_rebuild_codex_pending'"
5242
5351
  ).fetchone() is not None
5243
- if pending_rebuild and targeted:
5352
+ contract_row = conn.execute(
5353
+ "SELECT value FROM cache_meta "
5354
+ "WHERE key='codex_conversation_contract_version'"
5355
+ ).fetchone()
5356
+ has_contract_state = conn.execute(
5357
+ "SELECT 1 FROM codex_conversation_events LIMIT 1"
5358
+ ).fetchone() is not None
5359
+ contract_rebuild = (
5360
+ has_contract_state
5361
+ and (
5362
+ contract_row is None
5363
+ or contract_row[0]
5364
+ != _lib_codex_conversation.CODEX_CONVERSATION_CONTRACT_VERSION
5365
+ )
5366
+ )
5367
+ if (pending_rebuild or contract_rebuild) and targeted:
5244
5368
  stats.deferred_reason = "rebuild_pending"
5245
5369
  return stats
5246
- rebuild = rebuild or pending_rebuild
5370
+ rebuild = rebuild or pending_rebuild or contract_rebuild
5247
5371
  if rebuild:
5248
5372
  _clear_codex_conversation_store(conn)
5249
5373
  conn.commit()
@@ -5464,6 +5588,13 @@ def sync_codex_conversations(
5464
5588
  affected_keys.update(
5465
5589
  row.conversation_key for row in normalized.rows
5466
5590
  )
5591
+ if any(
5592
+ _lib_codex_conversation.codex_event_is_late_turn_anchor(event)
5593
+ for event in events
5594
+ ):
5595
+ affected_keys.update(
5596
+ _repair_codex_turn_ids_for_source(conn, path_str)
5597
+ )
5467
5598
  _recompute_codex_rollups(conn, affected_keys)
5468
5599
  terminal = state.thread
5469
5600
  conn.execute(
@@ -5511,6 +5642,13 @@ def sync_codex_conversations(
5511
5642
  stats.files_failed += 1
5512
5643
 
5513
5644
  if only_paths is None and stats.files_failed == 0:
5645
+ conn.execute(
5646
+ "INSERT OR REPLACE INTO cache_meta(key,value) VALUES(?,?)",
5647
+ (
5648
+ "codex_conversation_contract_version",
5649
+ _lib_codex_conversation.CODEX_CONVERSATION_CONTRACT_VERSION,
5650
+ ),
5651
+ )
5514
5652
  conn.execute(
5515
5653
  "DELETE FROM cache_meta "
5516
5654
  "WHERE key='conversation_rebuild_codex_pending'"
@@ -309,6 +309,7 @@ ALLOWED_CONFIG_KEYS = (
309
309
  "dashboard.live_tail",
310
310
  "update.check.enabled",
311
311
  "update.check.ttl_hours",
312
+ "update.channel",
312
313
  "statusline.visual_burn_rate",
313
314
  "statusline.cost_source",
314
315
  "statusline.cctally_extensions",
@@ -883,6 +884,12 @@ def _config_known_value(config: dict, key: str) -> "object":
883
884
  return c._validate_update_check_ttl_hours_value(stored)
884
885
  except ValueError:
885
886
  return c.UPDATE_DEFAULT_TTL_HOURS
887
+ if key == "update.channel":
888
+ # Release channel opt-in (beta-channel, spec 2026-07-21 §3). Default
889
+ # "stable"; hand-edited junk surfaces the default — mirrors
890
+ # dashboard.bind / update.check.*. The single resolver keeps the CLI,
891
+ # check pipeline, doctor, and dashboard envelope in lockstep.
892
+ return c.resolve_update_channel(config)
886
893
  if key.startswith(_CODEX_BUDGET_LEAF_PREFIX):
887
894
  return _config_codex_leaf_value(config, key)
888
895
  if key in (
@@ -1564,6 +1571,35 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
1564
1571
  rendered = str(normalized)
1565
1572
  print(f"{key}={rendered}")
1566
1573
  return 0
1574
+ if key == "update.channel":
1575
+ # Release channel opt-in (beta-channel, spec 2026-07-21 §3). Enum
1576
+ # {stable,beta}; validate first so rejection short-circuits before
1577
+ # lock acquisition. Read-modify-write preserving the sibling
1578
+ # `update.check` block (mirrors update.check.* preserving nothing it
1579
+ # doesn't own).
1580
+ try:
1581
+ canonical = c._validate_update_channel_value(raw)
1582
+ except ValueError as exc:
1583
+ print(f"cctally config: {exc}", file=sys.stderr)
1584
+ return 2
1585
+ with config_writer_lock():
1586
+ config = _load_config_unlocked()
1587
+ existing_update = config.get("update")
1588
+ if existing_update is not None and not isinstance(existing_update, dict):
1589
+ print(
1590
+ "cctally: update config error: update must be an object",
1591
+ file=sys.stderr,
1592
+ )
1593
+ return 2
1594
+ update_block = dict(existing_update or {})
1595
+ update_block["channel"] = canonical
1596
+ config["update"] = update_block
1597
+ save_config(config)
1598
+ if getattr(args, "emit_json", False):
1599
+ print(json.dumps({"update": {"channel": canonical}}, indent=2))
1600
+ else:
1601
+ print(f"update.channel={canonical}")
1602
+ return 0
1567
1603
  if key in (
1568
1604
  "budget.weekly_usd",
1569
1605
  "budget.alerts_enabled",
@@ -1990,6 +2026,20 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
1990
2026
  save_config(config)
1991
2027
  # idempotent: silent on missing key
1992
2028
  return 0
2029
+ if key == "update.channel":
2030
+ # Drop the channel leaf; preserve a sibling update.check block, and
2031
+ # prune an empty `update` so config.json stays tidy. Mirrors the
2032
+ # update.check.* unset branch above.
2033
+ with config_writer_lock():
2034
+ config = _load_config_unlocked()
2035
+ update_block = config.get("update")
2036
+ if isinstance(update_block, dict) and "channel" in update_block:
2037
+ del update_block["channel"]
2038
+ if not update_block:
2039
+ config.pop("update", None)
2040
+ save_config(config)
2041
+ # idempotent: silent on missing key
2042
+ return 0
1993
2043
  if key in (
1994
2044
  "budget.weekly_usd",
1995
2045
  "budget.alerts_enabled",
@@ -252,9 +252,11 @@ def _real_prod_data_dir() -> pathlib.Path:
252
252
  # Internal (no config UI — YAGNI, spec §Out of scope); test injection only.
253
253
  # Spec 2026-07-17-usage-statusline-fallback-design.
254
254
  #
255
- # OAUTH_BACKFILL_STALE_SECONDS: the OAuth poll only backfills once the
256
- # selected-freshness marker is at least this stale. The 30-second statusline
257
- # transport tick does not run OAuth.
255
+ # STATUSLINE_OAUTH_POLL_SECONDS: the account-wide authoritative confirmation
256
+ # cadence driven by Claude Code's 30-second statusline timer. Keep it below
257
+ # the timer so scheduling jitter does not skip every other tick.
258
+ # OAUTH_BACKFILL_STALE_SECONDS: the matching selected-freshness gate shared by
259
+ # statusline-driven and hook-driven automatic OAuth refreshes.
258
260
  # OAUTH_BACKOFF_BASE_SECONDS / OAUTH_BACKOFF_CAP_SECONDS: the headerless
259
261
  # exponential 429 backoff (base * 2**consecutive_429, capped).
260
262
  STATUSLINE_CANDIDATE_TTL_SECONDS = 90
@@ -270,7 +272,8 @@ STATUSLINE_TOMBSTONE_DOCUMENT_MAX_BYTES = 1024
270
272
  # session waits on a long subagent (event-driven updates go quiet then). MUST
271
273
  # Add-when-absent only; a user-set value is never mutated.
272
274
  STATUSLINE_REFRESH_INTERVAL_DEFAULT = 30
273
- OAUTH_BACKFILL_STALE_SECONDS = 300.0
275
+ STATUSLINE_OAUTH_POLL_SECONDS = 25.0
276
+ OAUTH_BACKFILL_STALE_SECONDS = STATUSLINE_OAUTH_POLL_SECONDS
274
277
  OAUTH_BACKOFF_BASE_SECONDS = 60.0
275
278
  OAUTH_BACKOFF_CAP_SECONDS = 3600.0
276
279