cctally 1.77.0 → 1.78.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,21 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.78.0] - 2026-07-21
9
+
10
+ ### Changed
11
+ - 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)
12
+ - 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)
13
+ - 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)
14
+ - 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)
15
+ - 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)
16
+
17
+ ### Fixed
18
+ - 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)
19
+ - 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)
20
+ - 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)
21
+ - 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.
22
+
8
23
  ## [1.77.0] - 2026-07-21
9
24
 
10
25
  ### Added
@@ -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
 
@@ -356,6 +356,10 @@ from _lib_blocks import _group_entries_into_blocks
356
356
  # #279 S2 F3: stdlib-logging chokepoint — server errors reach stderr via
357
357
  # the real log_error override below (leaf import, no cycle).
358
358
  import _lib_log
359
+ from _lib_dashboard_json import (
360
+ encode_dashboard_json,
361
+ encode_dashboard_json_bytes,
362
+ )
359
363
  from _cctally_config import save_config, _load_config_unlocked
360
364
  from _cctally_db import _render_migration_error_banner
361
365
  from _cctally_cache import (
@@ -800,12 +804,20 @@ def _codex_partial_source_detail(snapshot, row: Mapping, *, resource: str,
800
804
  return detail
801
805
 
802
806
 
803
- def _source_safe_claude_session_detail(detail, *, key: str) -> dict[str, Any]:
807
+ def _source_safe_claude_session_detail(
808
+ detail, *, key: str, project_key: str | None = None, label: str | None = None,
809
+ ) -> dict[str, Any]:
804
810
  """Adapt the existing Claude session detail builder to S4's safe route."""
805
811
  payload = _session_detail_to_envelope(detail)
806
812
  return {
807
813
  "detail_kind": "claude_session",
808
814
  "key": key,
815
+ "label": label or "Claude session",
816
+ "project_label": payload["project_label"],
817
+ "project_key": (
818
+ dashboard_resource_key("project", "claude", project_key)
819
+ if project_key else None
820
+ ),
809
821
  "started_utc": payload["started_utc"],
810
822
  "last_activity_utc": payload["last_activity_utc"],
811
823
  "duration_min": payload["duration_min"],
@@ -817,20 +829,32 @@ def _source_safe_claude_session_detail(detail, *, key: str) -> dict[str, Any]:
817
829
  "cache_hit_pct": payload["cache_hit_pct"],
818
830
  "cost_per_model": payload["cost_per_model"],
819
831
  "cost_total_usd": payload["cost_total_usd"],
832
+ "privacy_note": (
833
+ "Native session identity and source files are withheld to preserve "
834
+ "opaque identity. Cache rebuild history belongs to the private "
835
+ "conversation-detail surface and is intentionally not exposed by "
836
+ "this qualified accounting route."
837
+ ),
820
838
  }
821
839
 
822
840
 
823
- def _source_safe_claude_project_detail(detail: Mapping, *, key: str) -> dict[str, Any]:
841
+ def _source_safe_claude_project_detail(
842
+ detail: Mapping, *, key: str, label: str,
843
+ ) -> dict[str, Any]:
824
844
  """Drop legacy raw bucket/session identifiers from a Claude project drill."""
825
845
  return {
826
846
  "detail_kind": "claude_project",
827
847
  "key": key,
848
+ "label": label,
828
849
  "window_weeks": detail.get("window_weeks"),
829
850
  "window_cost_usd": detail.get("window_cost_usd"),
830
851
  "window_attributed_pct": detail.get("window_attributed_pct"),
831
852
  "models": detail.get("models", []),
832
853
  "sessions": [
833
854
  {
855
+ "key": dashboard_resource_key(
856
+ "session", "claude", item.get("session_id"),
857
+ ),
834
858
  "started_at": item.get("started_at"),
835
859
  "last_activity_at": item.get("last_activity_at"),
836
860
  "primary_model": item.get("primary_model"),
@@ -859,13 +883,73 @@ def _source_safe_claude_block_detail(detail: Mapping, *, key: str) -> dict[str,
859
883
  }
860
884
 
861
885
 
862
- def _claude_session_id_for_source_key(snapshot, key: str) -> str | None:
886
+ def _claude_session_row_for_source_key(snapshot, key: str):
863
887
  for row in getattr(snapshot, "sessions", ()) or ():
864
888
  session_id = getattr(row, "session_id", None)
865
889
  if isinstance(session_id, str) and session_id and (
866
890
  dashboard_resource_key("session", "claude", session_id) == key
867
891
  ):
868
- return session_id
892
+ return row
893
+ return None
894
+
895
+
896
+ def _claude_session_id_for_source_key(snapshot, key: str) -> str | None:
897
+ """Resolve a qualified session key without exposing its native identity.
898
+
899
+ The published source bundle contains the bounded dashboard rows. A project
900
+ drill can legitimately link to a recent session outside that panel slice,
901
+ so fall back to the compact ``session_files`` identity index and compare
902
+ opaque hashes. This never scans transcript entries or returns a raw ID to
903
+ the client.
904
+ """
905
+ row = _claude_session_row_for_source_key(snapshot, key)
906
+ if row is not None:
907
+ return getattr(row, "session_id", None)
908
+ try:
909
+ conn = open_cache_db()
910
+ except (sqlite3.DatabaseError, OSError):
911
+ return None
912
+ try:
913
+ rows = conn.execute(
914
+ "SELECT DISTINCT session_id FROM session_files "
915
+ "WHERE session_id IS NOT NULL AND session_id <> '' "
916
+ "ORDER BY session_id"
917
+ )
918
+ for (session_id,) in rows:
919
+ if (
920
+ isinstance(session_id, str)
921
+ and dashboard_resource_key(
922
+ "session", "claude", session_id,
923
+ ) == key
924
+ ):
925
+ return session_id
926
+ except sqlite3.Error:
927
+ return None
928
+ finally:
929
+ conn.close()
930
+ return None
931
+
932
+
933
+ def _claude_project_key_for_path(snapshot, project_path: object) -> str | None:
934
+ """Map one private project path back to its public display key."""
935
+ if not isinstance(project_path, str) or not project_path:
936
+ return None
937
+ env = getattr(snapshot, "projects_envelope", None)
938
+ if not isinstance(env, Mapping):
939
+ return None
940
+ candidates: list[object] = []
941
+ current = env.get("current_week")
942
+ if isinstance(current, Mapping):
943
+ candidates.extend(current.get("rows") or ())
944
+ trend = env.get("trend")
945
+ if isinstance(trend, Mapping):
946
+ candidates.extend(trend.get("projects") or ())
947
+ for row in candidates:
948
+ if not isinstance(row, Mapping) or row.get("bucket_path") != project_path:
949
+ continue
950
+ project_key = row.get("key")
951
+ if isinstance(project_key, str) and project_key:
952
+ return project_key
869
953
  return None
870
954
 
871
955
 
@@ -906,19 +990,39 @@ def _claude_block_start_for_source_key(snapshot, key: str) -> dt.datetime | None
906
990
  return None
907
991
 
908
992
 
909
- def _build_claude_source_detail(snapshot, *, resource: str, key: str) -> dict[str, Any]:
993
+ def _build_claude_source_detail(
994
+ snapshot, *, resource: str, key: str, row: Mapping,
995
+ window_weeks: int = 4,
996
+ ) -> dict[str, Any]:
910
997
  """Map a bounded legacy row to its existing, cache-only detail builder."""
911
998
  now_utc = getattr(snapshot, "generated_at", None) or _command_as_of()
912
999
  if resource == "session":
913
- session_id = _claude_session_id_for_source_key(snapshot, key)
914
- if session_id is None:
1000
+ session_row = _claude_session_row_for_source_key(snapshot, key)
1001
+ session_id = (
1002
+ getattr(session_row, "session_id", None)
1003
+ if session_row is not None
1004
+ else _claude_session_id_for_source_key(snapshot, key)
1005
+ )
1006
+ if not isinstance(session_id, str) or not session_id:
915
1007
  raise SourceResourceNotFound()
916
1008
  detail = sys.modules["cctally"]._tui_build_session_detail(
917
1009
  session_id, now_utc=now_utc,
918
1010
  )
919
1011
  if detail is None:
920
1012
  raise SourceResourceNotFound()
921
- return _source_safe_claude_session_detail(detail, key=key)
1013
+ project_key = (
1014
+ getattr(session_row, "project_key", None)
1015
+ if session_row is not None
1016
+ else _claude_project_key_for_path(
1017
+ snapshot, getattr(detail, "project_path", None),
1018
+ )
1019
+ )
1020
+ return _source_safe_claude_session_detail(
1021
+ detail,
1022
+ key=key,
1023
+ project_key=project_key,
1024
+ label=str(row.get("title") or row.get("label") or "Claude session"),
1025
+ )
922
1026
 
923
1027
  if resource == "project":
924
1028
  project_key = _claude_project_key_for_source_key(snapshot, key)
@@ -932,7 +1036,7 @@ def _build_claude_source_detail(snapshot, *, resource: str, key: str) -> dict[st
932
1036
  detail = _project_detail_for_window(
933
1037
  conn,
934
1038
  project_key=project_key,
935
- weeks_back=1,
1039
+ weeks_back=window_weeks,
936
1040
  now_utc=now_utc,
937
1041
  current_week=getattr(snapshot, "current_week", None),
938
1042
  projects_envelope=getattr(snapshot, "projects_envelope", None),
@@ -947,7 +1051,9 @@ def _build_claude_source_detail(snapshot, *, resource: str, key: str) -> dict[st
947
1051
  conn.close()
948
1052
  if detail is None:
949
1053
  raise SourceResourceNotFound()
950
- return _source_safe_claude_project_detail(detail, key=key)
1054
+ return _source_safe_claude_project_detail(
1055
+ detail, key=key, label=project_key,
1056
+ )
951
1057
 
952
1058
  start_at = _claude_block_start_for_source_key(snapshot, key)
953
1059
  if start_at is None:
@@ -984,7 +1090,7 @@ def _build_claude_source_detail(snapshot, *, resource: str, key: str) -> dict[st
984
1090
 
985
1091
 
986
1092
  def build_source_detail(*, snapshot, source: str, resource: str,
987
- key: str) -> dict[str, Any]:
1093
+ key: str, window_weeks: int = 4) -> dict[str, Any]:
988
1094
  """Resolve one qualified opaque key to a provider-native read-only detail.
989
1095
 
990
1096
  The frozen published bundle is the first bounded key index. Claude then
@@ -992,9 +1098,24 @@ def build_source_detail(*, snapshot, source: str, resource: str,
992
1098
  the native S1-S3 adapter detail without a Claude fallback or a rollout
993
1099
  parse. Every branch preserves the generic handler errors above it.
994
1100
  """
995
- row = source_detail_lookup(snapshot.source_bundle, source, resource, key)
1101
+ try:
1102
+ row = source_detail_lookup(snapshot.source_bundle, source, resource, key)
1103
+ except SourceResourceNotFound:
1104
+ # A Claude project drill can expose a recent session outside the
1105
+ # dashboard panel's bounded published slice. The provider adapter
1106
+ # resolves that opaque key against session_files below; all other
1107
+ # resources retain the strict frozen-bundle membership gate.
1108
+ if source != "claude" or resource != "session":
1109
+ raise
1110
+ row = {}
996
1111
  if source == "claude":
997
- return _build_claude_source_detail(snapshot, resource=resource, key=key)
1112
+ return _build_claude_source_detail(
1113
+ snapshot,
1114
+ resource=resource,
1115
+ key=key,
1116
+ row=row,
1117
+ window_weeks=window_weeks,
1118
+ )
998
1119
  try:
999
1120
  detail = _build_codex_source_detail(snapshot, resource=resource, key=key)
1000
1121
  if resource == "session":
@@ -3934,7 +4055,7 @@ def _handle_get_project_detail_impl(handler, *,
3934
4055
  except (TypeError, ValueError):
3935
4056
  weeks = None
3936
4057
  if weeks not in {1, 4, 8, 12}:
3937
- body = json.dumps({"error": "invalid weeks param"}).encode("utf-8")
4058
+ body = encode_dashboard_json_bytes({"error": "invalid weeks param"})
3938
4059
  handler.send_response(400)
3939
4060
  handler.send_header("Content-Type", "application/json; charset=utf-8")
3940
4061
  handler.send_header("Content-Length", str(len(body)))
@@ -3977,9 +4098,9 @@ def _handle_get_project_detail_impl(handler, *,
3977
4098
  )
3978
4099
  except Exception as exc:
3979
4100
  handler.log_error("/api/project failed: %r", exc)
3980
- body = json.dumps(
4101
+ body = encode_dashboard_json_bytes(
3981
4102
  {"error": "project detail failed"}
3982
- ).encode("utf-8")
4103
+ )
3983
4104
  handler.send_response(500)
3984
4105
  handler.send_header("Content-Type", "application/json; charset=utf-8")
3985
4106
  handler.send_header("Content-Length", str(len(body)))
@@ -3987,16 +4108,16 @@ def _handle_get_project_detail_impl(handler, *,
3987
4108
  handler.wfile.write(body)
3988
4109
  return
3989
4110
  if detail is None:
3990
- body = json.dumps(
4111
+ body = encode_dashboard_json_bytes(
3991
4112
  {"error": "project not found", "key": project_key},
3992
- ).encode("utf-8")
4113
+ )
3993
4114
  handler.send_response(404)
3994
4115
  handler.send_header("Content-Type", "application/json; charset=utf-8")
3995
4116
  handler.send_header("Content-Length", str(len(body)))
3996
4117
  handler.end_headers()
3997
4118
  handler.wfile.write(body)
3998
4119
  return
3999
- body = json.dumps(detail, ensure_ascii=False).encode("utf-8")
4120
+ body = encode_dashboard_json_bytes(detail, ensure_ascii=False)
4000
4121
  handler.send_response(200)
4001
4122
  handler.send_header("Content-Type", "application/json; charset=utf-8")
4002
4123
  handler.send_header("Content-Length", str(len(body)))
@@ -4456,7 +4577,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
4456
4577
  _respond_json plus an explicit Allow header.
4457
4578
  """
4458
4579
  if self.path.split("?", 1)[0] == "/api/settings":
4459
- encoded = json.dumps({"error": "method not allowed"}).encode("utf-8")
4580
+ encoded = encode_dashboard_json_bytes(
4581
+ {"error": "method not allowed"}
4582
+ )
4460
4583
  self.send_response(405)
4461
4584
  self.send_header("Allow", "POST")
4462
4585
  self.send_header("Content-Type", "application/json")
@@ -5689,7 +5812,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5689
5812
  # ---- helpers ----
5690
5813
 
5691
5814
  def _respond_json(self, status: int, body: dict) -> None:
5692
- encoded = json.dumps(body).encode("utf-8")
5815
+ encoded = encode_dashboard_json_bytes(body)
5693
5816
  self.send_response(status)
5694
5817
  self.send_header("Content-Type", "application/json")
5695
5818
  self.send_header("Content-Length", str(len(encoded)))
@@ -5760,7 +5883,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5760
5883
  # transcriptsEnabled=false, never enabled-then-403 (the pass-2 P2
5761
5884
  # finding) — one predicate, two consumers, desync impossible.
5762
5885
  env["transcriptsEnabled"] = visible
5763
- body = json.dumps(env, ensure_ascii=False).encode("utf-8")
5886
+ body = encode_dashboard_json_bytes(env, ensure_ascii=False)
5764
5887
  self.send_response(200)
5765
5888
  self.send_header("Content-Type", "application/json; charset=utf-8")
5766
5889
  self.send_header("Content-Length", str(len(body)))
@@ -5795,9 +5918,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5795
5918
  runtime_bind=type(self).cctally_host,
5796
5919
  )
5797
5920
  report = _ld.run_checks(state)
5798
- body = json.dumps(
5921
+ body = encode_dashboard_json_bytes(
5799
5922
  _ld.serialize_json(report), ensure_ascii=False,
5800
- ).encode("utf-8")
5923
+ )
5801
5924
  self.send_response(200)
5802
5925
  self.send_header("Content-Type", "application/json; charset=utf-8")
5803
5926
  self.send_header("Content-Length", str(len(body)))
@@ -5845,9 +5968,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5845
5968
  if detail is None:
5846
5969
  self.send_error(404, "session not found")
5847
5970
  return
5848
- body = json.dumps(
5971
+ body = encode_dashboard_json_bytes(
5849
5972
  _session_detail_to_envelope(detail), ensure_ascii=False
5850
- ).encode("utf-8")
5973
+ )
5851
5974
  self.send_response(200)
5852
5975
  self.send_header("Content-Type", "application/json; charset=utf-8")
5853
5976
  self.send_header("Content-Length", str(len(body)))
@@ -5894,14 +6017,28 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5894
6017
  "error": "source capability unavailable",
5895
6018
  })
5896
6019
  return
5897
- try:
5898
- snap = self.snapshot_ref.get()
5899
- detail = build_source_detail(
5900
- snapshot=snap,
5901
- source=source,
5902
- resource=resource,
5903
- key=key,
6020
+ detail_kwargs = {
6021
+ "source": source,
6022
+ "resource": resource,
6023
+ "key": key,
6024
+ }
6025
+ if resource == "project":
6026
+ query = _urlparse.parse_qs(
6027
+ _urlparse.urlsplit(self.path).query,
6028
+ keep_blank_values=True,
5904
6029
  )
6030
+ raw_weeks = query.get("weeks")
6031
+ if raw_weeks is not None:
6032
+ if len(raw_weeks) != 1 or raw_weeks[0] not in {"1", "4", "8", "12"}:
6033
+ self._respond_json(400, {
6034
+ "code": "source_capability_unavailable",
6035
+ "error": "source capability unavailable",
6036
+ })
6037
+ return
6038
+ detail_kwargs["window_weeks"] = int(raw_weeks[0])
6039
+ try:
6040
+ detail_kwargs["snapshot"] = self.snapshot_ref.get()
6041
+ detail = build_source_detail(**detail_kwargs)
5905
6042
  except SourceResourceNotFound:
5906
6043
  self._respond_json(404, {
5907
6044
  "code": "source_resource_not_found",
@@ -6049,7 +6186,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
6049
6186
  try:
6050
6187
  start_at = parse_iso_datetime(decoded, "start_at")
6051
6188
  except ValueError:
6052
- body = json.dumps({"error": "invalid start_at"}).encode("utf-8")
6189
+ body = encode_dashboard_json_bytes({"error": "invalid start_at"})
6053
6190
  self.send_response(400)
6054
6191
  self.send_header("Content-Type", "application/json; charset=utf-8")
6055
6192
  self.send_header("Content-Length", str(len(body)))
@@ -6097,7 +6234,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
6097
6234
  None,
6098
6235
  )
6099
6236
  if target is None:
6100
- body = json.dumps({"error": "block not found"}).encode("utf-8")
6237
+ body = encode_dashboard_json_bytes({"error": "block not found"})
6101
6238
  self.send_response(404)
6102
6239
  self.send_header("Content-Type", "application/json; charset=utf-8")
6103
6240
  self.send_header("Content-Length", str(len(body)))
@@ -6127,7 +6264,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
6127
6264
  self.log_error("/api/block failed: %r", exc)
6128
6265
  self.send_error(500, "block detail failed")
6129
6266
  return
6130
- body = json.dumps(detail, ensure_ascii=False).encode("utf-8")
6267
+ body = encode_dashboard_json_bytes(detail, ensure_ascii=False)
6131
6268
  self.send_response(200)
6132
6269
  self.send_header("Content-Type", "application/json; charset=utf-8")
6133
6270
  self.send_header("Content-Length", str(len(body)))
@@ -6189,7 +6326,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
6189
6326
  env["transcriptsEnabled"] = transcripts_enabled
6190
6327
  msg = (
6191
6328
  "event: update\n"
6192
- + "data: " + json.dumps(env, ensure_ascii=False) + "\n\n"
6329
+ + "data: "
6330
+ + encode_dashboard_json(env, ensure_ascii=False)
6331
+ + "\n\n"
6193
6332
  )
6194
6333
  self.wfile.write(msg.encode("utf-8"))
6195
6334
  self.wfile.flush()
@@ -6387,7 +6526,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
6387
6526
  try:
6388
6527
  for ev in worker.stream(run_id):
6389
6528
  ev_type = ev.get("type", "message")
6390
- ev_data = json.dumps(
6529
+ ev_data = encode_dashboard_json(
6391
6530
  {k: v for k, v in ev.items() if k != "type"},
6392
6531
  ensure_ascii=False,
6393
6532
  )
@@ -38,7 +38,6 @@ edits — the forwarders hit ``sys.modules["_cctally_dashboard"]`` at call time
38
38
  """
39
39
  from __future__ import annotations
40
40
 
41
- import json
42
41
  import re
43
42
  import socket
44
43
  import sqlite3
@@ -51,6 +50,7 @@ from _cctally_cache import (
51
50
  sync_codex_conversations,
52
51
  _codex_provider_roots,
53
52
  )
53
+ from _lib_dashboard_json import encode_dashboard_json
54
54
 
55
55
  # Live-tail watch-loop tuning — used ONLY by _handle_get_conversation_events_impl
56
56
  # below, so moved here with the events handler (spec §4.1 / §6).
@@ -709,7 +709,8 @@ def _run_conversation_events_stream(
709
709
  import time as _time
710
710
  watch = sys.modules["cctally"]._load_sibling("_lib_conversation_watch")
711
711
  tail_frame = (
712
- "event: tail\ndata: " + json.dumps(tail_data) + "\n\n").encode("utf-8")
712
+ "event: tail\ndata: " + encode_dashboard_json(tail_data) + "\n\n"
713
+ ).encode("utf-8")
713
714
 
714
715
  if passive:
715
716
  # Frozen-data contract: no ingest, no emit. Keep-alive only.
@@ -49,7 +49,10 @@ from _lib_jsonl import CodexEntry, codex_model_scoped_quota_pool
49
49
  from _lib_fmt import stable_sum
50
50
  from _lib_aggregators import _aggregate_codex_buckets
51
51
  from _lib_five_hour import _FIVE_HOUR_JITTER_FLOOR_SECONDS
52
- from _lib_source_analytics import build_codex_project_result
52
+ from _lib_source_analytics import (
53
+ build_codex_project_result,
54
+ collision_safe_project_label_map,
55
+ )
53
56
  from _lib_view_models import (
54
57
  CodexWeeklyView,
55
58
  build_codex_daily_view,
@@ -1783,12 +1786,17 @@ def _partial_projects_wire(
1783
1786
  model_totals[field] += value
1784
1787
  session_totals[field] += value
1785
1788
 
1789
+ label_map = collision_safe_project_label_map(
1790
+ (f"{root_key}\0{project_key}", str(group["label"]))
1791
+ for (root_key, project_key), group in groups.items()
1792
+ )
1786
1793
  rows = []
1787
1794
  for (root_key, _project_key), group in groups.items():
1795
+ internal_identity = f"{root_key}\0{group['project_key']}"
1788
1796
  rows.append({
1789
1797
  "key": dashboard_resource_key("project", "codex", root_key, group["project_key"]),
1790
1798
  "source": "codex",
1791
- "label": group["label"],
1799
+ "label": label_map[internal_identity],
1792
1800
  "session_count": len(group["sessions"]),
1793
1801
  "first_seen": group["first_seen"].astimezone(UTC).isoformat(),
1794
1802
  "last_seen": group["last_seen"].astimezone(UTC).isoformat(),
@@ -27,6 +27,7 @@ import argparse
27
27
  import datetime as dt
28
28
  import fcntl
29
29
  import json
30
+ import math
30
31
  import pathlib
31
32
  import shutil
32
33
  import sqlite3
@@ -35,6 +36,7 @@ import sys
35
36
  import _cctally_core
36
37
  import _lib_changelog
37
38
  from _cctally_core import _now_utc, eprint, now_utc_iso, parse_iso_datetime
39
+ from _lib_dashboard_json import encode_dashboard_json
38
40
 
39
41
 
40
42
  def _cctally():
@@ -53,11 +55,25 @@ def _gather_statusline_pipeline(c, *, now_utc: dt.datetime) -> dict:
53
55
  "tombstones": {"fiveHour": "absent", "sevenDay": "absent"},
54
56
  }
55
57
  try:
56
- result["transport_age_seconds"] = c._statusline_transport_age_seconds()
58
+ age = c._statusline_transport_age_seconds()
59
+ result["transport_age_seconds"] = (
60
+ age
61
+ if isinstance(age, (int, float))
62
+ and not isinstance(age, bool)
63
+ and math.isfinite(float(age))
64
+ else None
65
+ )
57
66
  except Exception:
58
67
  pass
59
68
  try:
60
- result["selected_age_seconds"] = c._statusline_observe_age_seconds()
69
+ age = c._statusline_observe_age_seconds()
70
+ result["selected_age_seconds"] = (
71
+ age
72
+ if isinstance(age, (int, float))
73
+ and not isinstance(age, bool)
74
+ and math.isfinite(float(age))
75
+ else None
76
+ )
61
77
  except Exception:
62
78
  pass
63
79
  try:
@@ -943,7 +959,7 @@ def cmd_doctor(args: argparse.Namespace) -> int:
943
959
  state = c.doctor_gather_state(deep=True)
944
960
  report = _lib_doctor.run_checks(state)
945
961
  if getattr(args, "json", False):
946
- print(json.dumps(
962
+ print(encode_dashboard_json(
947
963
  _lib_doctor.serialize_json(report), indent=2, sort_keys=True,
948
964
  ))
949
965
  else: