cctally 1.80.4 → 1.82.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/bin/_cctally_account.py +397 -0
  3. package/bin/_cctally_alerts.py +58 -2
  4. package/bin/_cctally_cache.py +696 -166
  5. package/bin/_cctally_cache_report.py +21 -3
  6. package/bin/_cctally_config.py +119 -8
  7. package/bin/_cctally_core.py +343 -48
  8. package/bin/_cctally_dashboard.py +32 -7
  9. package/bin/_cctally_dashboard_conversation.py +6 -2
  10. package/bin/_cctally_dashboard_envelope.py +49 -0
  11. package/bin/_cctally_dashboard_share.py +78 -1
  12. package/bin/_cctally_dashboard_sources.py +434 -48
  13. package/bin/_cctally_db.py +659 -152
  14. package/bin/_cctally_diff.py +9 -0
  15. package/bin/_cctally_doctor.py +201 -26
  16. package/bin/_cctally_five_hour.py +110 -69
  17. package/bin/_cctally_forecast.py +112 -43
  18. package/bin/_cctally_journal.py +591 -80
  19. package/bin/_cctally_milestones.py +180 -67
  20. package/bin/_cctally_parser.py +87 -0
  21. package/bin/_cctally_percent_breakdown.py +24 -15
  22. package/bin/_cctally_project.py +12 -1
  23. package/bin/_cctally_quota.py +150 -39
  24. package/bin/_cctally_record.py +491 -141
  25. package/bin/_cctally_reporting.py +66 -14
  26. package/bin/_cctally_setup.py +9 -1
  27. package/bin/_cctally_source_analytics.py +56 -7
  28. package/bin/_cctally_statusline.py +56 -8
  29. package/bin/_cctally_store.py +20 -8
  30. package/bin/_cctally_sync_week.py +30 -10
  31. package/bin/_cctally_tui.py +99 -18
  32. package/bin/_cctally_weekrefs.py +38 -15
  33. package/bin/_lib_accounts.py +325 -0
  34. package/bin/_lib_alerts_payload.py +25 -4
  35. package/bin/_lib_cache_writer_lock.py +77 -0
  36. package/bin/_lib_codex_hooks.py +40 -1
  37. package/bin/_lib_diff_kernel.py +28 -14
  38. package/bin/_lib_doctor.py +160 -0
  39. package/bin/_lib_journal.py +65 -2
  40. package/bin/_lib_pricing.py +27 -3
  41. package/bin/_lib_quota.py +81 -4
  42. package/bin/_lib_render.py +19 -5
  43. package/bin/_lib_share.py +25 -0
  44. package/bin/_lib_snapshot_cache.py +8 -0
  45. package/bin/_lib_subscription_weeks.py +16 -2
  46. package/bin/_lib_view_models.py +18 -9
  47. package/bin/cctally +43 -4
  48. package/dashboard/static/assets/index-DJP4gEB7.js +92 -0
  49. package/dashboard/static/assets/index-Dk1nplOz.css +1 -0
  50. package/dashboard/static/dashboard.html +2 -2
  51. package/package.json +4 -1
  52. package/dashboard/static/assets/index-BzWujBS3.js +0 -92
  53. package/dashboard/static/assets/index-Cc2ykqF_.css +0 -1
@@ -29,6 +29,7 @@ from _cctally_source_analytics import (
29
29
  load_qualified_codex_entries,
30
30
  )
31
31
  import _lib_log
32
+ import _lib_accounts
32
33
  from _lib_dashboard_sources import (
33
34
  CapabilityRecord,
34
35
  ProjectionCoherence,
@@ -69,6 +70,47 @@ DASHBOARD_QUOTA_OBSERVATION_LIMIT = 1000
69
70
  DASHBOARD_QUOTA_RECENT_DAYS = 35
70
71
 
71
72
 
73
+ def accounts_identity_digest(stats_conn: sqlite3.Connection) -> str:
74
+ """Digest of the account registry + the providers' live active-account state
75
+ (#341 spec §4 finding 9 — the identity-only invalidation signal).
76
+
77
+ Empty when NO account has ever been observed (the ``accounts`` registry is
78
+ empty): every <=1-account install and every pre-accounts fixture is
79
+ byte-neutral (the caller never appends an empty digest to a version/idle
80
+ signature), and no identity-file read happens on the idle path. Once accounts
81
+ exist it folds together (a) each registry row's ``account_key`` / provider /
82
+ label / label_source — catching a new account observed or a label edit — and
83
+ (b) the CURRENTLY-active account keys (``resolve_active_account_keys`` reads
84
+ ``~/.claude.json`` + each Codex root's ``auth.json``, stable-read + best
85
+ effort) — catching an in-place account SWITCH with zero new ingested rows.
86
+ Folded into both the outer dispatch/idle signature and each physical source's
87
+ ``data_version``, so a switch rebuilds the source state (flipping the
88
+ ``active`` marker) on the very next tick without any new rows.
89
+ """
90
+ try:
91
+ row = stats_conn.execute("SELECT COUNT(*) FROM accounts").fetchone()
92
+ except sqlite3.Error:
93
+ return ""
94
+ if not row or not row[0]:
95
+ return "" # no account ever observed -> byte-stable, no identity I/O
96
+ parts: list[str] = []
97
+ try:
98
+ for r in stats_conn.execute(
99
+ "SELECT account_key, provider, label, label_source FROM accounts "
100
+ "ORDER BY provider, account_key"
101
+ ):
102
+ parts.append(f"{r[0]}|{r[1]}|{r[2] or ''}|{r[3] or ''}")
103
+ except sqlite3.Error:
104
+ pass
105
+ try:
106
+ import _cctally_account
107
+ active = sorted(_cctally_account.resolve_active_account_keys())
108
+ except Exception:
109
+ active = []
110
+ parts.append("active\x1f" + ",".join(active))
111
+ return hashlib.sha256("\x00".join(parts).encode("utf-8")).hexdigest()[:16]
112
+
113
+
72
114
  class CodexCycleUnavailable(RuntimeError):
73
115
  """No single active native seven-day boundary can bound hero accounting."""
74
116
 
@@ -128,50 +170,78 @@ def _is_model_scoped_codex_quota(logical_limit_key: object) -> bool:
128
170
  def _resolve_codex_weekly_cycle(
129
171
  observations: Iterable[object],
130
172
  now_utc: dt.datetime,
131
- ) -> CodexCycleBoundary:
132
- """Select exactly one active account-level 10,080-minute native cycle."""
133
- boundaries: dict[tuple[int, dt.datetime], list[tuple[object, object]]] = {}
134
- stale_weekly_evidence = False
173
+ ) -> list[CodexCycleBoundary]:
174
+ """Resolve one active 10,080-minute native cycle PER ACCOUNT (#341 spec §4).
175
+
176
+ Returns a list with one :class:`CodexCycleBoundary` per account that has
177
+ exactly one active weekly boundary — N simultaneously-active account cycles
178
+ are each valid instead of collapsing to ``CodexCycleUnavailable("conflicting")``.
179
+ ``conflicting`` now fires only for a genuine conflict WITHIN one account.
180
+ Raises ``CodexCycleUnavailable`` only when NO account yields a live cycle. A
181
+ single-account install returns a 1-element list = today's boundary
182
+ byte-for-byte (the hero path is unchanged, spec R8).
183
+ """
184
+ per_account: dict[str, dict[tuple[int, dt.datetime], list[tuple[object, object]]]] = {}
185
+ accounts_seen: set[str] = set()
186
+ stale_by_account: dict[str, bool] = {}
135
187
  for history in build_history(tuple(observations)):
136
188
  if history.identity.window_minutes != 10_080:
137
189
  continue
138
190
  if _is_model_scoped_codex_quota(history.identity.logical_limit_key):
139
191
  continue
192
+ account = history.identity.account_key
193
+ accounts_seen.add(account)
140
194
  baseline = select_baseline(history.observations, now_utc)
141
195
  if baseline is None or baseline.resets_at <= now_utc:
142
196
  continue
143
197
  if quota_freshness(history.physical_observations, now_utc).state != "fresh":
144
- stale_weekly_evidence = True
198
+ stale_by_account[account] = True
145
199
  continue
146
200
  boundary = (history.identity.window_minutes, baseline.resets_at)
147
- boundaries.setdefault(boundary, []).append((history, baseline))
148
- if len(boundaries) != 1:
149
- if not boundaries:
150
- raise CodexCycleUnavailable("stale" if stale_weekly_evidence else "missing")
151
- raise CodexCycleUnavailable("conflicting")
152
- (window_minutes, resets_at), candidates = next(iter(boundaries.items()))
153
- # Preserve the existing hero's max-used-percent choice, then pin every
154
- # remaining tie deterministically. The selected full identity—not the
155
- # union of sibling roots/slots/limits—owns the hero cycle.
156
- history, baseline = max(
157
- candidates,
158
- key=lambda item: (
159
- float(item[1].used_percent),
160
- item[1].captured_at.astimezone(UTC),
161
- item[0].identity.source_root_key,
162
- item[0].identity.logical_limit_key,
163
- item[0].identity.observed_slot,
164
- ),
165
- )
166
- selected_identity = history.identity
167
- return CodexCycleBoundary(
168
- window_minutes=window_minutes,
169
- start_at=resets_at - dt.timedelta(minutes=window_minutes),
170
- resets_at=resets_at,
171
- source_root_keys=(selected_identity.source_root_key,),
172
- used_percent=float(baseline.used_percent),
173
- quota_identity=selected_identity,
174
- )
201
+ per_account.setdefault(account, {}).setdefault(boundary, []).append((history, baseline))
202
+ cycles: list[CodexCycleBoundary] = []
203
+ reasons: list[str] = []
204
+ for account in sorted(accounts_seen | set(per_account)):
205
+ boundaries = per_account.get(account, {})
206
+ if len(boundaries) != 1:
207
+ # Within one account: 0 boundaries -> stale/missing; >=2 -> conflicting.
208
+ reasons.append(
209
+ "conflicting" if boundaries
210
+ else ("stale" if stale_by_account.get(account) else "missing")
211
+ )
212
+ continue
213
+ (window_minutes, resets_at), candidates = next(iter(boundaries.items()))
214
+ # Preserve the existing hero's max-used-percent choice, then pin every
215
+ # remaining tie deterministically. The selected full identity—not the
216
+ # union of sibling roots/slots/limits—owns the account's hero cycle.
217
+ history, baseline = max(
218
+ candidates,
219
+ key=lambda item: (
220
+ float(item[1].used_percent),
221
+ item[1].captured_at.astimezone(UTC),
222
+ item[0].identity.source_root_key,
223
+ item[0].identity.logical_limit_key,
224
+ item[0].identity.observed_slot,
225
+ ),
226
+ )
227
+ selected_identity = history.identity
228
+ cycles.append(CodexCycleBoundary(
229
+ window_minutes=window_minutes,
230
+ start_at=resets_at - dt.timedelta(minutes=window_minutes),
231
+ resets_at=resets_at,
232
+ source_root_keys=(selected_identity.source_root_key,),
233
+ used_percent=float(baseline.used_percent),
234
+ quota_identity=selected_identity,
235
+ ))
236
+ if not cycles:
237
+ # Aggregate reason: for a single account this is exactly the old reason
238
+ # (byte-stable); across accounts, conflicting > stale > missing.
239
+ if "conflicting" in reasons:
240
+ raise CodexCycleUnavailable("conflicting")
241
+ if "stale" in reasons:
242
+ raise CodexCycleUnavailable("stale")
243
+ raise CodexCycleUnavailable("missing")
244
+ return cycles
175
245
 
176
246
 
177
247
  def _codex_weekly_periods(
@@ -432,13 +502,27 @@ def _public_copy(value: object) -> object:
432
502
  return value
433
503
 
434
504
 
435
- def source_detail_lookup(bundle: object, source: str, resource: str, key: str) -> dict[str, object]:
505
+ def source_detail_lookup(
506
+ bundle: object, source: str, resource: str, key: str,
507
+ account: "str | None" = None,
508
+ ) -> dict[str, object]:
436
509
  """Find one provider-owned opaque row without I/O, ingest, or fallback.
437
510
 
438
511
  The handler has already parsed the fixed route grammar. This adapter only
439
512
  reads the frozen bundle published by the dashboard owner thread, so a
440
513
  request cannot accidentally trigger cache sync, rollout parsing, or a
441
514
  Claude fallback for a Codex key.
515
+
516
+ Server-side account row-ownership (#341 Task 4, spec §4 finding 10).
517
+ ``account`` is the qualifier the modal captured with ``(source, account)`` at
518
+ open. When it is provided AND any row matching ``key`` carries an
519
+ ``account_key`` (the decorated wire shape — two accounts can share one opaque
520
+ resource key), the server VERIFIES ownership: it returns the row owned by
521
+ ``account`` and NEVER a different account's row — a key resolving only to
522
+ another account raises ``SourceResourceNotFound`` rather than leaking it.
523
+ Account-agnostic rows (no ``account_key``: sessions/projects per Decision R4,
524
+ or any undecorated source) ignore the qualifier and match by key alone, so
525
+ the undecorated path stays byte-identical.
442
526
  """
443
527
  if source not in ("claude", "codex") or resource not in _RESOURCE_ROWS:
444
528
  raise SourceCapabilityUnavailable()
@@ -454,10 +538,23 @@ def source_detail_lookup(bundle: object, source: str, resource: str, key: str) -
454
538
  rows = data[domain][rows_key]
455
539
  except (KeyError, TypeError) as exc:
456
540
  raise SourceCapabilityUnavailable() from exc
457
- for row in rows:
458
- if isinstance(row, Mapping) and row.get("key") == key:
459
- return _public_copy(row) # type: ignore[return-value]
460
- raise SourceResourceNotFound()
541
+ key_matches = [
542
+ row for row in rows
543
+ if isinstance(row, Mapping) and row.get("key") == key
544
+ ]
545
+ if not key_matches:
546
+ raise SourceResourceNotFound()
547
+ if account is not None:
548
+ # Any key-match that carries an account_key is account-scoped: enforce
549
+ # ownership so a fetch qualified to account X can never fall through to
550
+ # account Y's row sharing the same opaque key.
551
+ scoped = [row for row in key_matches if "account_key" in row]
552
+ if scoped:
553
+ owned = [row for row in scoped if row.get("account_key") == account]
554
+ if not owned:
555
+ raise SourceResourceNotFound()
556
+ return _public_copy(owned[0]) # type: ignore[return-value]
557
+ return _public_copy(key_matches[0]) # type: ignore[return-value]
461
558
 
462
559
 
463
560
  def codex_projection_coherence(
@@ -1200,6 +1297,18 @@ def _quota_read_model(
1200
1297
  history_rows: list[dict[str, object]] = []
1201
1298
  milestone_rows: list[dict[str, object]] = []
1202
1299
  active_rows: list[dict[str, object]] = []
1300
+ # R8 (#341 Task 4): the per-account `account_key` is serialized onto each
1301
+ # history row ONLY when the Codex provider has >1 REAL account, so the idle
1302
+ # clock (`_clock_cycle_validity`) can scope weekly-cycle validity per account
1303
+ # instead of degrading a genuine multi-account state to `conflicting`. A
1304
+ # <=1-real-account install (all fixtures) stays byte-identical (no key added).
1305
+ _codex_decorated = False
1306
+ try:
1307
+ import _cctally_account
1308
+ _codex_decorated = _cctally_account.provider_is_decorated(
1309
+ context.stats_conn, "codex")
1310
+ except Exception:
1311
+ _codex_decorated = False
1203
1312
  for history in histories:
1204
1313
  identity = history.identity
1205
1314
  key_parts = (
@@ -1214,6 +1323,7 @@ def _quota_read_model(
1214
1323
  history_rows.append({
1215
1324
  "key": dashboard_resource_key("quota", "codex", *key_parts),
1216
1325
  "source": "codex",
1326
+ **({"account_key": identity.account_key} if _codex_decorated else {}),
1217
1327
  "label": _native_limit_label(identity.limit_name, identity.window_minutes),
1218
1328
  "observed_slot": identity.observed_slot,
1219
1329
  "window_minutes": identity.window_minutes,
@@ -1395,9 +1505,23 @@ def _clock_cycle_validity(
1395
1505
  histories: Iterable[object],
1396
1506
  now_utc: dt.datetime,
1397
1507
  ) -> tuple[bool, str]:
1398
- """Re-evaluate frozen weekly evidence without touching cache or rollouts."""
1399
- boundaries: set[dt.datetime] = set()
1400
- stale_weekly_evidence = False
1508
+ """Re-evaluate frozen weekly evidence without touching cache or rollouts.
1509
+
1510
+ Per-account (#341 Task 4): boundaries are grouped by the history row's
1511
+ ``account_key`` (serialized only when the Codex provider is DECORATED, i.e.
1512
+ >1 real account — R8). The idle clock mirrors the build-time
1513
+ ``_resolve_codex_weekly_cycle`` resolution: an account yields a live cycle iff
1514
+ it has EXACTLY ONE fresh future boundary, and the hero is valid iff AT LEAST
1515
+ ONE account does (raising the aggregate ``conflicting > stale > missing``
1516
+ reason only when NO account yields one). When ``account_key`` is absent
1517
+ (<=1-real-account install — no decoration) every row falls into one global
1518
+ bucket, so this reduces EXACTLY to the prior single-boundary logic
1519
+ (byte-stable). This removes the documented placeholder degrade where two real
1520
+ accounts with distinct weekly cycles collapsed the whole hero to
1521
+ ``conflicting`` on the idle clock.
1522
+ """
1523
+ per_account: dict[str, set[dt.datetime]] = {}
1524
+ stale_by_account: dict[str, bool] = {}
1401
1525
  for raw_history in histories:
1402
1526
  if not isinstance(raw_history, Mapping):
1403
1527
  continue
@@ -1415,15 +1539,29 @@ def _clock_cycle_validity(
1415
1539
  continue
1416
1540
  if resets_at <= now_utc:
1417
1541
  continue
1542
+ # None account_key (undecorated / <=1 real account) -> one global bucket
1543
+ # == today's behavior. A real key buckets per account.
1544
+ acct = raw_history.get("account_key")
1545
+ bucket = acct if acct is not None else "__all__"
1418
1546
  if raw_history.get("freshness") != "fresh":
1419
- stale_weekly_evidence = True
1547
+ stale_by_account[bucket] = True
1420
1548
  continue
1421
- boundaries.add(resets_at)
1422
- if len(boundaries) == 1:
1423
- return True, "ok"
1424
- if not boundaries and stale_weekly_evidence:
1549
+ per_account.setdefault(bucket, set()).add(resets_at)
1550
+ accounts = set(per_account) | set(stale_by_account)
1551
+ reasons: list[str] = []
1552
+ for acct in accounts:
1553
+ boundaries = per_account.get(acct, set())
1554
+ if len(boundaries) == 1:
1555
+ return True, "ok" # at least one account yields a live cycle
1556
+ reasons.append(
1557
+ "conflicting" if boundaries
1558
+ else ("stale" if stale_by_account.get(acct) else "missing")
1559
+ )
1560
+ if "conflicting" in reasons:
1561
+ return False, "conflicting"
1562
+ if "stale" in reasons:
1425
1563
  return False, "stale"
1426
- return False, "missing" if not boundaries else "conflicting"
1564
+ return False, "missing"
1427
1565
 
1428
1566
 
1429
1567
  def _refresh_budget_status_clock(
@@ -1953,6 +2091,218 @@ def _build_codex_native_weekly_view(
1953
2091
  )
1954
2092
 
1955
2093
 
2094
+ def _codex_account_five_hour_percent(
2095
+ observations: Iterable[object],
2096
+ now_utc: dt.datetime,
2097
+ ) -> dict[str, float]:
2098
+ """Per-account current five-hour (300-minute) used-percent (#341 Task 4).
2099
+
2100
+ Account key -> the highest active-window used-percent among that account's
2101
+ fresh sibling 300-minute windows. Used to render the per-account hero card's
2102
+ 5h bar; account-blind physical breakdown readers are untouched.
2103
+ """
2104
+ result: dict[str, float] = {}
2105
+ for history in build_history(tuple(observations)):
2106
+ if history.identity.window_minutes != 300:
2107
+ continue
2108
+ baseline = select_baseline(history.observations, now_utc)
2109
+ if baseline is None or baseline.resets_at <= now_utc:
2110
+ continue
2111
+ acct = history.identity.account_key
2112
+ pct = float(baseline.used_percent)
2113
+ if acct not in result or pct > result[acct]:
2114
+ result[acct] = pct
2115
+ return result
2116
+
2117
+
2118
+ def _codex_accounts_wire(
2119
+ context: DashboardReadContext,
2120
+ *,
2121
+ quota_observations: Iterable[object],
2122
+ cycles: list["CodexCycleBoundary"],
2123
+ accounting_start: dt.datetime,
2124
+ accounting_end: dt.datetime,
2125
+ ) -> tuple[list[dict[str, object]], list[dict[str, object]]]:
2126
+ """Return ``(accounts_wire, hero_cycles_wire)`` for a decorated Codex source.
2127
+
2128
+ Caller must gate on ``provider_is_decorated(stats_conn, "codex")`` — this
2129
+ builds nothing for a <=1-real-account install (the whole surface is absent,
2130
+ so the envelope stays byte-identical, spec R8). Each account carries
2131
+ ``{accountKey, label, plan, active, weeklyPercent, fiveHourPercent, resetsAt,
2132
+ spendUsd, inputTokens, cachedInputTokens, outputTokens,
2133
+ reasoningOutputTokens, totalTokens, unattributed?}``; ``hero_cycles_wire`` is
2134
+ the thin per-account cycle-boundary list the hero renders (``cycles[]``).
2135
+ """
2136
+ import _cctally_account
2137
+ active_keys = _cctally_account.resolve_active_account_keys()
2138
+ five_hour = _codex_account_five_hour_percent(quota_observations, context.now_utc)
2139
+ cycle_by_account: dict[str, "CodexCycleBoundary"] = {}
2140
+ for cyc in cycles:
2141
+ acct = (
2142
+ cyc.quota_identity.account_key if cyc.quota_identity is not None
2143
+ else _lib_accounts.UNATTRIBUTED
2144
+ )
2145
+ cycle_by_account.setdefault(acct, cyc)
2146
+ # Registry accounts (real, deterministically ordered) + the unattributed
2147
+ # sentinel when it has any retained accounting (it renders dimmed, totals
2148
+ # only). Registry rows never include the sentinel.
2149
+ reg = _cctally_account.load_accounts(context.stats_conn, "codex")
2150
+ plan_by_key = {r["account_key"]: r.get("plan_type") for r in reg}
2151
+ ordered_keys = [r["account_key"] for r in reg]
2152
+ # Include unattributed last iff it has cycle/5h/spend evidence.
2153
+ unattributed_rows = load_cached_rooted_codex_accounting_entries(
2154
+ accounting_start, accounting_end, speed=context.speed,
2155
+ cache_conn=context.cache_conn, account_key=_lib_accounts.UNATTRIBUTED,
2156
+ )
2157
+ if (
2158
+ unattributed_rows
2159
+ or _lib_accounts.UNATTRIBUTED in cycle_by_account
2160
+ or _lib_accounts.UNATTRIBUTED in five_hour
2161
+ ):
2162
+ ordered_keys.append(_lib_accounts.UNATTRIBUTED)
2163
+
2164
+ def _totals(rows: tuple[object, ...]) -> dict[str, object]:
2165
+ entries = _codex_entries_from_accounting(rows)
2166
+ cost = build_codex_daily_view(
2167
+ entries, now_utc=context.now_utc, tz_name=context.display_tz_name,
2168
+ speed=context.speed,
2169
+ ).total_cost_usd if entries else 0.0
2170
+ return {
2171
+ "spendUsd": cost,
2172
+ "inputTokens": sum(e.input_tokens for e in entries),
2173
+ "cachedInputTokens": sum(e.cached_input_tokens for e in entries),
2174
+ "outputTokens": sum(e.output_tokens for e in entries),
2175
+ "reasoningOutputTokens": sum(e.reasoning_output_tokens for e in entries),
2176
+ "totalTokens": sum(e.total_tokens for e in entries),
2177
+ }
2178
+
2179
+ accounts_wire: list[dict[str, object]] = []
2180
+ hero_cycles_wire: list[dict[str, object]] = []
2181
+ for key in ordered_keys:
2182
+ cyc = cycle_by_account.get(key)
2183
+ is_unattributed = key == _lib_accounts.UNATTRIBUTED
2184
+ if cyc is not None and not is_unattributed:
2185
+ cycle_end = min(accounting_end, cyc.resets_at)
2186
+ rows = load_cached_rooted_codex_accounting_entries(
2187
+ cyc.start_at, cycle_end, speed=context.speed,
2188
+ cache_conn=context.cache_conn,
2189
+ source_root_keys=cyc.source_root_keys, account_key=key,
2190
+ )
2191
+ totals = _totals(rows)
2192
+ elif is_unattributed:
2193
+ totals = _totals(unattributed_rows)
2194
+ else:
2195
+ # A real account without a live weekly cycle: totals over the
2196
+ # accounting range so the card still shows spend (no bars/reset).
2197
+ rows = load_cached_rooted_codex_accounting_entries(
2198
+ accounting_start, accounting_end, speed=context.speed,
2199
+ cache_conn=context.cache_conn, account_key=key,
2200
+ )
2201
+ totals = _totals(rows)
2202
+ card: dict[str, object] = {
2203
+ "accountKey": key,
2204
+ "label": _cctally_account.account_label(context.stats_conn, key),
2205
+ "plan": plan_by_key.get(key),
2206
+ "active": key in active_keys,
2207
+ "weeklyPercent": (
2208
+ None if is_unattributed or cyc is None else cyc.used_percent
2209
+ ),
2210
+ "fiveHourPercent": (None if is_unattributed else five_hour.get(key)),
2211
+ "resetsAt": (
2212
+ None if is_unattributed or cyc is None
2213
+ else cyc.resets_at.astimezone(UTC).isoformat()
2214
+ ),
2215
+ **totals,
2216
+ }
2217
+ if is_unattributed:
2218
+ card["unattributed"] = True
2219
+ accounts_wire.append(card)
2220
+ if cyc is not None and not is_unattributed:
2221
+ hero_cycles_wire.append({
2222
+ "accountKey": key,
2223
+ "window_minutes": cyc.window_minutes,
2224
+ "start_at": cyc.start_at.astimezone(UTC).isoformat(),
2225
+ "resets_at": cyc.resets_at.astimezone(UTC).isoformat(),
2226
+ "used_percent": cyc.used_percent,
2227
+ "cost_usd": totals["spendUsd"],
2228
+ "total_tokens": totals["totalTokens"],
2229
+ })
2230
+ return accounts_wire, hero_cycles_wire
2231
+
2232
+
2233
+ def _claude_accounts_wire(
2234
+ stats_conn: sqlite3.Connection,
2235
+ *,
2236
+ now_utc: dt.datetime,
2237
+ ) -> list[dict[str, object]]:
2238
+ """Per-account Claude hero cards (#341 Task 4, Ruling C).
2239
+
2240
+ Symmetric with ``_codex_accounts_wire``: the caller gates on
2241
+ ``provider_is_decorated(stats_conn, "claude")`` (>1 REAL account, R8), so a
2242
+ <=1-real-account install builds nothing and its envelope stays byte-identical
2243
+ on BOTH goldens. Each card carries
2244
+ ``{accountKey, label, plan, active, weeklyPercent, fiveHourPercent, resetsAt,
2245
+ spendUsd, unattributed?}`` drawn from the ALREADY-account-scoped stats
2246
+ snapshots (``weekly_usage_snapshots``/``weekly_cost_snapshots`` both hold
2247
+ ``account_key`` — Section 6 scope matrix), taking each account's latest
2248
+ captured row as its current-cycle state. spendUsd is the snapshotted weekly
2249
+ cost (the ``report`` semantics), account-scoped. The unattributed bucket
2250
+ renders last, dimmed/totals-only, iff it has any retained snapshot.
2251
+ """
2252
+ import _cctally_account
2253
+ active_keys = _cctally_account.resolve_active_account_keys()
2254
+ reg = _cctally_account.load_accounts(stats_conn, "claude")
2255
+ plan_by_key = {r["account_key"]: r.get("plan_type") for r in reg}
2256
+ ordered_keys = [r["account_key"] for r in reg]
2257
+
2258
+ def _latest_usage(key: str):
2259
+ return stats_conn.execute(
2260
+ "SELECT weekly_percent, five_hour_percent, week_end_at "
2261
+ "FROM weekly_usage_snapshots WHERE account_key=? "
2262
+ "ORDER BY captured_at_utc DESC LIMIT 1",
2263
+ (key,),
2264
+ ).fetchone()
2265
+
2266
+ def _latest_cost(key: str) -> float:
2267
+ row = stats_conn.execute(
2268
+ "SELECT cost_usd FROM weekly_cost_snapshots WHERE account_key=? "
2269
+ "ORDER BY captured_at_utc DESC LIMIT 1",
2270
+ (key,),
2271
+ ).fetchone()
2272
+ return float(row[0]) if row is not None and row[0] is not None else 0.0
2273
+
2274
+ # Include the unattributed bucket last iff it retained any snapshot.
2275
+ unattr_usage = _latest_usage(_lib_accounts.UNATTRIBUTED)
2276
+ unattr_cost = stats_conn.execute(
2277
+ "SELECT 1 FROM weekly_cost_snapshots WHERE account_key=? LIMIT 1",
2278
+ (_lib_accounts.UNATTRIBUTED,),
2279
+ ).fetchone()
2280
+ if unattr_usage is not None or unattr_cost is not None:
2281
+ ordered_keys.append(_lib_accounts.UNATTRIBUTED)
2282
+
2283
+ cards: list[dict[str, object]] = []
2284
+ for key in ordered_keys:
2285
+ is_unattributed = key == _lib_accounts.UNATTRIBUTED
2286
+ usage = _latest_usage(key)
2287
+ weekly_pct = usage[0] if usage is not None else None
2288
+ five_hour_pct = usage[1] if usage is not None else None
2289
+ resets_at = usage[2] if usage is not None else None
2290
+ card: dict[str, object] = {
2291
+ "accountKey": key,
2292
+ "label": _cctally_account.account_label(stats_conn, key),
2293
+ "plan": plan_by_key.get(key),
2294
+ "active": key in active_keys,
2295
+ "weeklyPercent": None if is_unattributed else weekly_pct,
2296
+ "fiveHourPercent": None if is_unattributed else five_hour_pct,
2297
+ "resetsAt": None if is_unattributed else resets_at,
2298
+ "spendUsd": _latest_cost(key),
2299
+ }
2300
+ if is_unattributed:
2301
+ card["unattributed"] = True
2302
+ cards.append(card)
2303
+ return cards
2304
+
2305
+
1956
2306
  def build_codex_source_state(
1957
2307
  context: DashboardReadContext,
1958
2308
  *,
@@ -2037,8 +2387,16 @@ def build_codex_source_state(
2037
2387
  )
2038
2388
  budget_entries = _codex_entries_from_accounting(accounting_entries)
2039
2389
  cycle_reason: str | None = None
2390
+ cycles_all: list[CodexCycleBoundary] = []
2040
2391
  try:
2041
- cycle = _resolve_codex_weekly_cycle(quota_observations, context.now_utc)
2392
+ # Per-account list (#341 Task 2). ``cycles_all`` drives the per-account
2393
+ # hero cards (Task 4, gated on decoration); ``cycle`` stays the first
2394
+ # account's boundary (sorted by account_key) as the interim single hero —
2395
+ # for a single-account install this IS today's single boundary
2396
+ # (byte-stable), and a multi-account install no longer degrades to
2397
+ # `conflicting`.
2398
+ cycles_all = _resolve_codex_weekly_cycle(quota_observations, context.now_utc)
2399
+ cycle = cycles_all[0] if cycles_all else None
2042
2400
  except CodexCycleUnavailable as exc:
2043
2401
  cycle = None
2044
2402
  cycle_reason = exc.reason
@@ -2176,6 +2534,32 @@ def build_codex_source_state(
2176
2534
  "Codex native reset cycle is unavailable.",
2177
2535
  "hero",
2178
2536
  ))
2537
+ # #341 Task 4: the conditional per-account wire. Built ONLY when the Codex
2538
+ # provider has >1 REAL account (R8) — a <=1-real-account install adds nothing
2539
+ # so its envelope is byte-identical to today. The array ships EVERY account's
2540
+ # projection (client-side chip filter); the hero renders per-account cards.
2541
+ accounts_wire: list[dict[str, object]] = []
2542
+ hero_cycles_wire: list[dict[str, object]] = []
2543
+ try:
2544
+ import _cctally_account
2545
+ _codex_decorated = _cctally_account.provider_is_decorated(
2546
+ context.stats_conn, "codex")
2547
+ except Exception:
2548
+ _codex_decorated = False
2549
+ if _codex_decorated:
2550
+ try:
2551
+ accounts_wire, hero_cycles_wire = _codex_accounts_wire(
2552
+ context,
2553
+ quota_observations=quota_observations,
2554
+ cycles=cycles_all,
2555
+ accounting_start=accounting_start,
2556
+ accounting_end=accounting_end,
2557
+ )
2558
+ except (sqlite3.Error, QualifiedMetadataUnavailable):
2559
+ # A per-account wire failure must never fail the whole source build;
2560
+ # degrade to the byte-stable undecorated shape.
2561
+ accounts_wire = []
2562
+ hero_cycles_wire = []
2179
2563
  return SourceDashboardState(
2180
2564
  source="codex",
2181
2565
  availability=availability,
@@ -2228,7 +2612,9 @@ def build_codex_source_state(
2228
2612
  "quota": quota["summary"],
2229
2613
  "budget": configured_budget,
2230
2614
  "alerts": {"count": len(alerts)},
2615
+ **({"cycles": hero_cycles_wire} if _codex_decorated else {}),
2231
2616
  },
2617
+ **({"accounts": accounts_wire} if _codex_decorated else {}),
2232
2618
  "periods": {
2233
2619
  "daily": _period_wire(daily),
2234
2620
  "monthly": _period_wire(monthly),