cctally 1.78.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,19 @@ 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
+
8
21
  ## [1.78.0] - 2026-07-21
9
22
 
10
23
  ### Changed
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",
@@ -4435,6 +4435,7 @@ _GET_ROUTES = (
4435
4435
  ("prefix", "/api/session/", "_handle_get_session_detail", None, True),
4436
4436
  ("prefix", "/api/project/", "_handle_get_project_detail", None, False),
4437
4437
  ("prefix", "/api/block/", "_handle_get_block_detail", None, True),
4438
+ ("prefix", "/api/milestones/", "_handle_get_milestones_week", None, True),
4438
4439
  ("exact", "/api/update/status", "_handle_get_update_status", None, False),
4439
4440
  ("prefix", "/api/update/stream/", "_handle_get_update_stream", None, True),
4440
4441
  ("exact", "/api/share/templates", "_handle_share_templates_get", None, False),
@@ -5228,6 +5229,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5228
5229
  # compatible. `enabled` must be a JSON bool; `ttl_hours` an int
5229
5230
  # (bools rejected — see _validate_update_check_ttl_hours_value).
5230
5231
  update_check_validated: "dict | None" = None
5232
+ update_channel_validated: "str | None" = None
5231
5233
  if "update" in payload:
5232
5234
  update_in = payload["update"]
5233
5235
  if not isinstance(update_in, dict):
@@ -5236,13 +5238,28 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5236
5238
  )
5237
5239
  return
5238
5240
  for inner in update_in.keys():
5239
- if inner != "check":
5241
+ if inner not in ("check", "channel"):
5240
5242
  self._respond_json(
5241
5243
  400,
5242
5244
  {"error": f"unknown update settings key: {inner}",
5243
5245
  "field": f"update.{inner}"},
5244
5246
  )
5245
5247
  return
5248
+ # Release channel opt-in (beta-channel, spec 2026-07-21 §3). Enum
5249
+ # {stable,beta}; 400 on invalid. Mirrors the config-key validator.
5250
+ if "channel" in update_in:
5251
+ try:
5252
+ update_channel_validated = (
5253
+ _cctally()._validate_update_channel_value(
5254
+ update_in["channel"]
5255
+ )
5256
+ )
5257
+ except ValueError as exc:
5258
+ self._respond_json(
5259
+ 400,
5260
+ {"error": str(exc), "field": "update.channel"},
5261
+ )
5262
+ return
5246
5263
  check_in = update_in.get("check", {})
5247
5264
  if not isinstance(check_in, dict):
5248
5265
  self._respond_json(
@@ -5398,7 +5415,10 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5398
5415
  self._respond_json(400, {"error": str(exc)})
5399
5416
  return
5400
5417
 
5401
- if update_check_validated is not None:
5418
+ if (
5419
+ update_check_validated is not None
5420
+ or update_channel_validated is not None
5421
+ ):
5402
5422
  # Same hand-edited-junk guard as alerts: a non-dict
5403
5423
  # `update` or `update.check` block in config.json should
5404
5424
  # surface as a recoverable 400, not a 500.
@@ -5412,18 +5432,22 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5412
5432
  )
5413
5433
  return
5414
5434
  merged_update = dict(existing_update or {})
5415
- existing_check = merged_update.get("check")
5416
- if existing_check is not None and not isinstance(
5417
- existing_check, dict
5418
- ):
5419
- self._respond_json(
5420
- 400, {"error": "update.check must be an object",
5421
- "field": "update.check"}
5422
- )
5423
- return
5424
- merged_check = dict(existing_check or {})
5425
- merged_check.update(update_check_validated)
5426
- merged_update["check"] = merged_check
5435
+ if update_check_validated is not None:
5436
+ existing_check = merged_update.get("check")
5437
+ if existing_check is not None and not isinstance(
5438
+ existing_check, dict
5439
+ ):
5440
+ self._respond_json(
5441
+ 400, {"error": "update.check must be an object",
5442
+ "field": "update.check"}
5443
+ )
5444
+ return
5445
+ merged_check = dict(existing_check or {})
5446
+ merged_check.update(update_check_validated)
5447
+ merged_update["check"] = merged_check
5448
+ if update_channel_validated is not None:
5449
+ # Partial-PUT: preserve the sibling `update.check` block.
5450
+ merged_update["channel"] = update_channel_validated
5427
5451
  merged["update"] = merged_update
5428
5452
 
5429
5453
  if cache_report_validated is not None:
@@ -5531,11 +5555,17 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5531
5555
  _cctally()._reconcile_codex_budget_on_config_write(
5532
5556
  validated_budget
5533
5557
  )
5534
- if update_check_validated is not None:
5535
- # Echo the full merged check block (cooked defaults included)
5536
- # so the SettingsOverlay can repaint without a follow-up GET.
5537
- out["update"] = {
5538
- "check": {
5558
+ if (
5559
+ update_check_validated is not None
5560
+ or update_channel_validated is not None
5561
+ ):
5562
+ # Echo the touched update leaves (cooked defaults included) so the
5563
+ # SettingsOverlay can repaint without a follow-up GET. The channel
5564
+ # echo uses the read-surface name `configured_channel` (spec §3
5565
+ # naming), mirrored from the merged config via the single resolver.
5566
+ out_update: dict = {}
5567
+ if update_check_validated is not None:
5568
+ out_update["check"] = {
5539
5569
  "enabled": _config_known_value(
5540
5570
  merged, "update.check.enabled"
5541
5571
  ),
@@ -5543,7 +5573,11 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5543
5573
  merged, "update.check.ttl_hours"
5544
5574
  ),
5545
5575
  }
5546
- }
5576
+ if update_channel_validated is not None:
5577
+ out_update["configured_channel"] = (
5578
+ _cctally().resolve_update_channel(merged)
5579
+ )
5580
+ out["update"] = out_update
5547
5581
  if cache_report_validated is not None:
5548
5582
  # Echo the full cooked block (resolved defaults included) so
5549
5583
  # the dashboard composer can repaint without a follow-up GET
@@ -6272,6 +6306,117 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
6272
6306
  self.end_headers()
6273
6307
  self.wfile.write(body)
6274
6308
 
6309
+ def _send_milestones_json(self, status: int, body: dict) -> None:
6310
+ payload = encode_dashboard_json_bytes(body, ensure_ascii=False)
6311
+ self.send_response(status)
6312
+ self.send_header("Content-Type", "application/json; charset=utf-8")
6313
+ self.send_header("Content-Length", str(len(payload)))
6314
+ self.send_header("Cache-Control", "no-cache")
6315
+ self.end_headers()
6316
+ self.wfile.write(payload)
6317
+
6318
+ def _handle_get_milestones_week(self, path: str) -> None:
6319
+ """GET /api/milestones/<source>/week/<key> — one week's/cycle's
6320
+ complete milestone-history payload (spec §2, hero-modal history).
6321
+
6322
+ Dedicated route (deliberately NOT the /api/source/ family, whose
6323
+ not-found semantics are scoped to the current frozen bundle). Wire is
6324
+ snake_case via ``encode_dashboard_json_bytes`` (the dashboard
6325
+ convention — NOT the CLI camelCase envelope). Read-only, per-request
6326
+ connections, no sync/migration writes from the assembly; the multi-read
6327
+ assembly runs inside one transaction per DB. 400 malformed key/source;
6328
+ 404 ``{code: "unknown_key", reason}`` for keys that don't resolve.
6329
+ """
6330
+ import re as _re
6331
+ import types as _types
6332
+ import urllib.parse as _urlparse
6333
+ from _cctally_cache import open_cache_db
6334
+
6335
+ tail = path[len("/api/milestones/"):]
6336
+ parts = tail.split("/week/", 1)
6337
+ if len(parts) != 2 or not parts[1]:
6338
+ self._send_milestones_json(400, {"error": "invalid path"})
6339
+ return
6340
+ source, raw_key = parts[0], parts[1]
6341
+ if source not in ("claude", "codex"):
6342
+ self._send_milestones_json(400, {"error": "invalid source"})
6343
+ return
6344
+ key = _urlparse.unquote(raw_key)
6345
+ c = sys.modules["cctally"]
6346
+ try:
6347
+ if source == "claude":
6348
+ # Claude key is a week_start_date (YYYY-MM-DD).
6349
+ if not _re.fullmatch(r"\d{4}-\d{2}-\d{2}", key):
6350
+ self._send_milestones_json(400, {"error": "invalid week key"})
6351
+ return
6352
+ conn = open_db()
6353
+ try:
6354
+ conn.execute("BEGIN")
6355
+ detail = c.build_claude_week_detail(conn, key)
6356
+ conn.commit()
6357
+ finally:
6358
+ conn.close()
6359
+ if detail is None:
6360
+ self._send_milestones_json(404, {
6361
+ "error": "unknown week", "code": "unknown_key",
6362
+ "reason": "unknown",
6363
+ })
6364
+ return
6365
+ self._send_milestones_json(200, {**detail, "source": "claude", "key": key})
6366
+ return
6367
+
6368
+ # Codex: opaque server-issued cycle key. Resolve across all codex
6369
+ # roots (keys are unique per cycle, so a client-supplied index key
6370
+ # matches its exact cycle without needing the hero-cycle identity —
6371
+ # this also keeps a just-closed former-current cycle fetchable when
6372
+ # the live hero cycle is momentarily unavailable, spec §2).
6373
+ from _cctally_dashboard_sources import resolve_dashboard_source_semantics
6374
+ speed = resolve_dashboard_source_semantics(
6375
+ load_config(), display_tz_name="UTC",
6376
+ ).speed
6377
+ now_utc = _command_as_of()
6378
+ stats_conn = open_db()
6379
+ cache_conn = open_cache_db()
6380
+ try:
6381
+ # Stable read across BOTH DBs for a single response (spec §2):
6382
+ # the cache conn supplies the physical cost correlation, so it
6383
+ # gets the same BEGIN/commit envelope as the stats conn.
6384
+ stats_conn.execute("BEGIN")
6385
+ cache_conn.execute("BEGIN")
6386
+ roots = tuple(sorted({
6387
+ str(r[0]) for r in stats_conn.execute(
6388
+ "SELECT DISTINCT source_root_key FROM quota_window_blocks "
6389
+ "WHERE source='codex'"
6390
+ )
6391
+ }))
6392
+ identity = _types.SimpleNamespace(source_root_keys=roots, resets_at=None)
6393
+ result = c.build_codex_cycle_detail(
6394
+ stats_conn, cache_conn, identity=identity, key=key,
6395
+ speed=speed, now_utc=now_utc,
6396
+ )
6397
+ stats_conn.commit()
6398
+ cache_conn.commit()
6399
+ finally:
6400
+ stats_conn.close()
6401
+ cache_conn.close()
6402
+ if isinstance(result, tuple):
6403
+ _none, reason = result
6404
+ self._send_milestones_json(404, {
6405
+ "error": "unknown cycle", "code": "unknown_key",
6406
+ "reason": reason,
6407
+ })
6408
+ return
6409
+ if result is None:
6410
+ self._send_milestones_json(404, {
6411
+ "error": "unknown cycle", "code": "unknown_key",
6412
+ "reason": "unknown",
6413
+ })
6414
+ return
6415
+ self._send_milestones_json(200, {**result, "source": "codex", "key": key})
6416
+ except Exception as exc: # noqa: BLE001
6417
+ self.log_error("/api/milestones failed: %r", exc)
6418
+ self.send_error(500, "milestones detail failed")
6419
+
6275
6420
  def _serve_api_events(self) -> None:
6276
6421
  import queue as _queue
6277
6422
  self.send_response(200)
@@ -6496,9 +6641,19 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
6496
6641
  _w.status() if _w is not None
6497
6642
  else {"current_run_id": None}
6498
6643
  )
6644
+ # Beta-channel (spec 2026-07-21 §3): carry the configured release
6645
+ # channel (from config, not cached state) so the badge/modal keep the
6646
+ # `(beta)` marker + exact-version command after an execvp refresh, when
6647
+ # the client polls /api/update/status instead of the SSE envelope.
6648
+ try:
6649
+ _c = _cctally()
6650
+ configured_channel = _c.resolve_update_channel(_c.load_config())
6651
+ except Exception:
6652
+ configured_channel = "stable"
6499
6653
  body = {
6500
6654
  "state": state,
6501
6655
  "suppress": suppress,
6656
+ "configured_channel": configured_channel,
6502
6657
  **worker_status,
6503
6658
  }
6504
6659
  self._respond_json(200, body)
@@ -1057,7 +1057,7 @@ def _handle_get_conversation_payload_impl(handler, path: str) -> None:
1057
1057
  q = _u.parse_qs(handler.path.partition("?")[2])
1058
1058
  if session_id.startswith("v1."):
1059
1059
  # Qualified payload readback (§3.4): Codex uses block_key + which={call,
1060
- # output}; a v1.claude key keeps the tool_use_id + which={input,result}
1060
+ # output,event}; a v1.claude key keeps the tool_use_id + which={input,result}
1061
1061
  # selector. neutral_payload picks per the resolved source; gone → 410.
1062
1062
  which_q = _qs_str(q, "which", "")
1063
1063
  tool_use_id_q = _qs_str(q, "tool_use_id", "") or None
@@ -1287,6 +1287,12 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
1287
1287
  update_envelope = {
1288
1288
  "state": _update_state_envelope,
1289
1289
  "suppress": _update_suppress_envelope,
1290
+ # Beta-channel (spec 2026-07-21 §3): the configured release channel,
1291
+ # derived DIRECTLY from config (never from cached state — a cached
1292
+ # producer field can't seed the settings toggle after a failed fetch
1293
+ # or on brew). The resolved-target fields (latest_version /
1294
+ # resolved_dist_tag / latest_version_channel) ride inside `state`.
1295
+ "configured_channel": sys.modules["cctally"].resolve_update_channel(config),
1290
1296
  }
1291
1297
 
1292
1298
  # Doctor aggregate block (spec §5.5). Only the small severity tree
@@ -1427,6 +1433,12 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
1427
1433
  # bound or the data source crashed during sync
1428
1434
  # (recorded on ``last_sync_error``).
1429
1435
  "five_hour_milestones": getattr(snap, "five_hour_milestones", []) or [],
1436
+ # Hero-modal historical-milestone navigation index (spec §3).
1437
+ # Additive/optional: newest-first per-week entries built on the
1438
+ # non-idle rebuild and stored on the snapshot; ``getattr``
1439
+ # default keeps legacy/positional fixture snapshots serializing
1440
+ # (they emit ``[]``). Historical rows never ride the envelope.
1441
+ "week_index": getattr(snap, "week_index", []) or [],
1430
1442
  },
1431
1443
 
1432
1444
  "forecast":
@@ -2080,7 +2080,21 @@ def build_codex_source_state(
2080
2080
  now_utc=context.now_utc,
2081
2081
  display_tz_name=context.display_tz_name,
2082
2082
  )
2083
- quota = {**quota, "blocks": quota_blocks}
2083
+ # Hero-modal historical-milestone navigation index (spec §1c, §3). Built
2084
+ # here on the non-idle codex source rebuild (idle ticks reuse the stored
2085
+ # bundle) over the durable projection — a pure serializer never touches it.
2086
+ # Guarded: an index failure must never fail the codex source build.
2087
+ cycle_index: tuple = ()
2088
+ if cycle is not None and not hero_failure:
2089
+ try:
2090
+ cycle_index = tuple(
2091
+ sys.modules["cctally"].build_codex_cycle_index(
2092
+ context.stats_conn, identity=cycle, now_utc=context.now_utc,
2093
+ )
2094
+ )
2095
+ except sqlite3.Error:
2096
+ cycle_index = ()
2097
+ quota = {**quota, "blocks": quota_blocks, "cycle_index": cycle_index}
2084
2098
  budget_rows = _budget_wire(context.stats_conn)
2085
2099
  projected_budget_rows = _projected_budget_wire(context.stats_conn)
2086
2100
  budget_cost_events = _codex_budget_cost_events(context, budget_entries)
@@ -800,14 +800,28 @@ def doctor_gather_state(
800
800
  # on corruption — both behaviors would hide diagnostic state
801
801
  # (codex H1).
802
802
  config_json_error = None
803
+ config_parsed: dict = {}
803
804
  try:
804
805
  if _cctally_core.CONFIG_PATH.exists():
805
- json.loads(_cctally_core.CONFIG_PATH.read_text(encoding="utf-8"))
806
+ config_parsed = json.loads(
807
+ _cctally_core.CONFIG_PATH.read_text(encoding="utf-8")
808
+ )
806
809
  except json.JSONDecodeError as exc:
807
810
  config_json_error = f"{type(exc).__name__}: {exc}"
808
811
  except OSError as exc:
809
812
  config_json_error = f"OSError: {exc}"
810
813
 
814
+ # Configured update (release) channel (beta-channel, spec 2026-07-21 §3):
815
+ # derived from the SAME raw read (never load_config, which auto-creates on
816
+ # first run). Fail-soft to "stable" — resolve_update_channel already
817
+ # tolerates a non-dict block / junk value.
818
+ try:
819
+ update_channel = c.resolve_update_channel(
820
+ config_parsed if isinstance(config_parsed, dict) else {}
821
+ )
822
+ except Exception:
823
+ update_channel = "stable"
824
+
811
825
  update_state = None
812
826
  update_state_error = None
813
827
  try:
@@ -880,6 +894,7 @@ def doctor_gather_state(
880
894
  codex_jsonl_present=codex_jsonl_present,
881
895
  codex_project_metadata_health=codex_project_metadata_health,
882
896
  codex_project_metadata_error=codex_project_metadata_error,
897
+ update_channel=update_channel,
883
898
  dashboard_bind_stored=dashboard_bind_stored,
884
899
  runtime_bind=runtime_bind,
885
900
  # Conversation viewer (Plan 2, spec §5): only consulted on a LAN bind.