cctally 1.83.0 → 1.84.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,40 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.84.0] - 2026-07-29
9
+
10
+ ### Added
11
+ - Selecting a Codex account in the dashboard now re-scopes the whole Codex view to that account — periods, sessions, projects, the trend chart, cache diagnostics, the forecast, budget, quota blocks and alerts, including each panel's expanded view, not just the hero card. Each account's spend, tokens and quota windows are its own; an account with no usage shows an explicit empty view instead of the previously selected account's numbers. Under "All accounts" the headline is the merged spend and token total across every account, while the percentage, reset, `$/1%`, forecast and week range stay blank with a `per account` pointer to the cards, because independent quota allowances are never blended and no one account's cycle window describes the whole. Alerts that belong to the whole vendor rather than one account stay visible under a focused account and are labelled as such. This appears only when more than one Codex account is known; a single-account install is unchanged. (#416)
12
+
13
+ ### Fixed
14
+ - Cache migration 030's replay-only physical-key conflict now enriches only the two cache-write TTL split fields and their mutation stamps, preserving the retained timestamp, model, tokens, usage metadata, speed, raw cost, physical identity, and first account attribution. The cost-usage structural scanner now exempts only six explicit live wire-format fixture emitters and rejects stale or newly invented builder exemptions, while the Projects fixtures deliberately pin SQLite planner statistics absent and prove clean rebuilds remain byte-identical. (#418, #370, #195)
15
+ - A Codex account whose quota week has already reset no longer shows a current quota in the dashboard's Forecast surfaces. The account's last recorded percentage is kept as history, and the Forecast panel and its expanded per-account table were still printing it as a live figure — so an account that had not been used for over a week read `41%` with `medium` confidence under `Current quota`, while the account card, the hero, the alerts gauge and the per-account cycle table beside them all correctly read `—` for the same account. Both surfaces now say `—` until the account is observed on a running quota window, and the expanded view no longer prints a `%/h` quota rate for a window that has already ended. Where two Codex accounts share one Codex home directory, a reset account is also no longer revived by its sibling's still-running window. A window that is still running keeps its percentage and confidence even when its projection is stale or low-confidence. A single-account install is affected only in the same way: a reset week reads `—` until the next observation. (#416)
16
+ - With more than one Codex account, the dashboard's "Spent this week" headline and combined spend now add up every account instead of showing one account's. Each account keeps its Codex usage in its own directory, and the headline was reading a single account's directory over a single account's cycle — so it rendered as a live total while being exactly one of the cards printed beneath it, with roughly a sixth of the week's spend missing from the number and the contradiction visible in the same view. Opening the cycle from that headline now shows a per-account table naming every account rather than one unnamed account's percentage ladder, and the blanks beside it say `per account` instead of reading as missing data. A single-account install is unchanged. (#416)
17
+ - The dashboard's Forecast panel and its expanded view no longer present one Codex account's forecast as the whole picture. With several Codex accounts the panel read one account's projection and verdict — `≥100%` in red directly beneath a hero already reading `per account`, while another account sat comfortably under its limit — and the expanded view was worse still: it paired one account's cycle with a `$/1%` taken from a different account's week, producing daily spending guidance that belonged to no account at all. The panel now says `per account`, and the expanded view lists every account by name with its own projection, current quota, confidence and verdict. Select an account and its own forecast returns in full. A single-account install is unchanged. (#416)
18
+ - The dashboard's Blocks panel now lists every Codex account's 5-hour blocks instead of only one account's. Each account keeps its usage in its own directory, and the merged view was bounded to a single account's directory — so a second account's live block, and its spend, were missing from both the list and the footer total, visible only by selecting that account. Each merged row now names the account it belongs to. A single-account install is unchanged. (#416)
19
+ - Opening a past Codex cycle with an account selected now correctly identifies which of that account's cycles is the current one. The lookup was still measuring against the first account's reset, so a second account's live cycle was never recognised as current and could be shown cut short. (#416)
20
+ - Codex account cards no longer read "resets in 2d 2h ago". Every card with a future reset was rendering its countdown as though it were an elapsed age, contradicting the hero countdown beside it. (#416)
21
+ - A sub-dollar weekly spend no longer displays as `$0`. Per-account Codex spends are routinely under a dollar, and the headline rounds to whole dollars — so a real $0.23 rendered as the same thing as nothing, directly above the card that showed it correctly. Amounts under a dollar now keep their cents. (#416)
22
+ - The dashboard's All-providers view no longer presents one Codex account's quota as the whole picture. Its `CODEX 7-DAY` percentage, its reset countdown and its `Codex quota` row were all read from a single representative account's cycle, so with several Codex accounts the combined view published one of them, unlabelled, as the Codex position — while the Codex view beside it correctly refused to. All three now read `per account`, and the per-account cards that previously appeared only on the Codex view now appear here too, carrying each account's own percentage, 5-hour percentage, reset and spend, so the view gains information rather than losing a number. The combined spend and token totals, which genuinely do add up across accounts, are unchanged, and a Codex account selected on the Codex view never narrows this one. A single-account install is unchanged. (#416)
23
+ - Opening a past Codex cycle with an account selected now shows that account's own cycle. The history list is built per account, but opening an entry from it read back across every account on the same Codex home — so the cycle could show another account's crossings and every account's spend, or fail to open at all when the two accounts' resets were seconds apart. Cycles opened without an account selected are unchanged, and so is a single-account install. (#416)
24
+ - With a Codex account selected, its weekly percentage, `$/1%`, quota milestone costs and milestone history are now genuinely its own. Where two accounts share one Codex home directory, several views still read by directory and time without asking which account the row belonged to — so a focused account could show another account's weekly percentage, divide its own spend by that percentage, count another account's spend into its quota milestones, annotate a crossing with another account's 5-hour percentage, or list another account's past weeks. A focused account's milestone ladder also no longer disappears when the quota window was matched to it after the fact: the window still opens where it really opened, while spend that cannot be attributed to any account shows as `$0.00` there and stays counted under the unattributed group, so no usage is shown twice. Usage stamped with an account the local account list has not caught up with — including an account known only from an older quota window — is now shown as its own group rather than silently disappearing from every account's totals. "All accounts" is unchanged, and so is a single-account install. (#416)
25
+ - Two accounts that carry the same email address no longer display under the same name. Where an auto-generated label would collide, cctally now appends the plan (`you@example.com (pro)` / `you@example.com (team)`), falling back to a short account-key fragment when the plan does not tell them apart either. Names that do not collide are left exactly as they were, and a label you set yourself still takes precedence. The same name is used everywhere — `cctally account list` and `show`, the dashboard account chips, alert prefixes and share labels — and when a reference like an email is ambiguous, the candidate list now prints both the disambiguated name and a key fragment you can use directly. (#416)
26
+ - Sharing a Codex panel while one account is selected now exports only that account's rows. The share was already labelled with the selected account, but its body still contained every account's data — so a share meant for one account disclosed the others. Shares taken without an account selected are unchanged, and a share naming an account the install does not know is now refused rather than falling back to the all-account body. (#416)
27
+ - Opening a Codex quota window from the dashboard now shows every sample in that window, and no longer fails to open at all. The detail view was still matching samples on the raw provider reset while the window itself had moved to its settled reset, so it could drop most of the window's history — recomputing its percentage ladder, freshness and forecast from a fraction of the evidence — or find nothing and report the window as missing. (#416)
28
+ - Upgrading no longer stalls on the first command after a large Codex quota history is re-grouped. Settling each window's reset was scanning every previously settled reset for the window, so the one-time upgrade pass and `cctally cache-sync --rebuild` grew quadratically with history. The lookup is now constant-time and the grouping it produces is unchanged. (#416)
29
+ - A Codex quota alert you have already been shown is no longer forgotten when the local index is rebuilt, and is no longer sent a second time after the window-grouping fix above re-groups its window. Each alert now records itself durably when it fires, so rebuilding preserves exactly which thresholds alerted and when — and rebuilding still sends nothing. This holds for an alert recorded against a window whose reported length was one minute off its native length, which the length fix below also re-groups. (#416)
30
+ - One Codex quota window now renders as one window. OpenAI reports the same reset a few seconds apart from one sample to the next, and cctally was treating each of those spellings as its own window — so a single week could appear as half a dozen blocks, each with its own peak, its own percentage ladder and its own forecast. Each window's reset is now settled once, when its samples are first read, and recorded alongside the raw provider value; history, blocks, the forecast and the alert ladder all read that settled value. Existing history is re-grouped on upgrade. The dashboard's recent-history view and the CLI's full-history view now agree on which samples belong to which window, so the same week no longer looks different in the two places. (#416)
31
+ - A Codex weekly window whose reported length is one minute off its native length no longer shows up as a second, separate window. The stray length is snapped onto the native one when quota history is read, so the observations either side of it now belong to the same window with one percentage ladder. Windows that run on a separate model allowance, such as GPT Codex Spark, keep their own pool and are never merged into account quota. (#416)
32
+ - Rebuilding the cache no longer re-labels Codex history with whichever account happens to be signed in. Each rollout's account is now decided once when its bytes are first read, recorded durably in the append-only journal, and replayed from there, so `cctally cache-sync --rebuild` leaves every account's spend and quota rows exactly as they were. Recreating `cache.db` — after corruption recovery or a manual delete — replays the same decisions on the next ordinary sync rather than re-deriving them, an interrupted or failed cache write is recovered from the journal on the next sync instead of being re-decided from whoever is signed in by then, and `cache-sync --rebuild` now repairs an attribution map that has drifted away from the journal. Signing in as a different Codex account while a session is still running now attributes that session's later usage to the new account, without moving any of the usage already recorded under the previous one. Codex history that predates this mechanism stays unattributed rather than being guessed at. A truncated or half-written Codex `auth.json` correctly stops cctally from guessing an account, and `cache-sync` now says so on stderr while `doctor` reports it as `accounts.codex_identity`, so Codex spend and quota can no longer stop updating silently. (#416)
33
+ - `doctor` now detects `conversations.db` corruption, and `cache-sync --rebuild` safely preserves and rebuilds the complete transcript store without manual file deletion. (#415, #414)
34
+ - Dashboard transcript failures no longer claim `cache.db` corruption or expose raw SQLite paths, SQL, or exceptions. Core accounting, quota, session, and SSE surfaces remain available while `cctally doctor` retains store-specific diagnosis and the verified recovery path. (#415, #414)
35
+ - Internal (maintainer-only): the self-hosted macOS CI suite now finishes in roughly ten minutes instead of twenty-five. Its pytest phase had been running single-process — `bin/cctally-test-all` gates `-n` on `import xdist` and silently falls back to serial when the package is absent, and this lane's `pip install` was the only one of the three that omitted `pytest-xdist` — so the estate ran on one core for 914s where ten workers take 236s. The same lane also pulled a 1.3 GB npm cache from GitHub's cache service on each of its three jobs, a ~68s download and untar apiece, even though the runner is persistent and `npm ci` completes in about a second from the `~/.npm` already on disk; the ephemeral hosted PR lane, which does start cold, still uses it. No user-facing behavior change.
36
+
37
+ ## [1.83.1] - 2026-07-28
38
+
39
+ ### Fixed
40
+ - Doctor golden fixtures now pin the macOS backup/sync classifier as well as the `tmutil` response, keeping the public Linux CI matrix byte-stable across platforms.
41
+
8
42
  ## [1.83.0] - 2026-07-28
9
43
 
10
44
  ### Added
package/README.md CHANGED
@@ -30,11 +30,9 @@ Your Claude Code plan meters you with a percentage that creeps up all week. ccta
30
30
  </p>
31
31
 
32
32
  <!-- cctally:latest-stable:begin -->
33
- **Latest stable: v1.82.1** (2026-07-24)
33
+ **Latest stable: v1.83.1** (2026-07-28)
34
34
 
35
- - The public README is a fresh, shorter screenshot-led tour, and it now refreshes itself on every stable release: promoting a release regenerates the screenshots against that exact version and updates a "Latest stable" highlights block on the GitHub page automatically.
36
- - The dashboard's Recent Sessions card shows session names again. Splitting transcript storage into its own database dropped the name lookup, so every row in the Session column had rendered a dash since then. Names come back from the stored conversation index, and a transcript store that is missing, locked, or rebuilding simply leaves the dash in place instead of holding up the rest of the dashboard.
37
- - The All tab's Recent Sessions rows now show Claude session names too, matching the Codex rows beside them; previously only Codex rows were named there. Names still appear only for a local viewer, exactly as on the Claude tab.
35
+ - Doctor golden fixtures now pin the macOS backup/sync classifier as well as the `tmutil` response, keeping the public Linux CI matrix byte-stable across platforms.
38
36
  <!-- cctally:latest-stable:end -->
39
37
 
40
38
  ## Quick start
@@ -106,6 +106,103 @@ def account_label(conn, account_key: str) -> str:
106
106
  return account_key[:8]
107
107
 
108
108
 
109
+ def _base_label_for_row(row: dict) -> str:
110
+ """The undecorated label: manual label, else email, else key prefix.
111
+
112
+ `cctally account label` sits at the top of the `user > switcher > auto`
113
+ precedence, so a manual label is the BASE the collision pass decorates — it
114
+ is never overridden by the email.
115
+ """
116
+ if row.get("label"):
117
+ return str(row["label"])
118
+ if row.get("email"):
119
+ return str(row["email"])
120
+ return str(row.get("account_key") or "")[:8] or "-"
121
+
122
+
123
+ def display_label_map_from_rows(rows: "list[dict]") -> "dict[str, str]":
124
+ """Collision-only display labels for one provider's population (#416 §6).
125
+
126
+ Collision handling CANNOT live in `account_label` / `account_label_from_row`:
127
+ both are scalar — one row, no population and no plan context — so neither can
128
+ know that its label is shared. This map sees the whole provider population,
129
+ which is the only place the question is answerable.
130
+
131
+ Decision D5: auto-disambiguate ONLY on collision. A label nobody else shares
132
+ comes back untouched, so a single-account install and every existing golden
133
+ are unaffected. Two Codex accounts really do auto-label as one email (the
134
+ `pro` and one `team` account share it; `account_key` correctly differs
135
+ because it derives from `chatgpt_account_id + email`), and that is the case
136
+ this exists for.
137
+
138
+ The discriminator is the PLAN where the plan separates the tied accounts, and
139
+ a key prefix where it does not — a group of two `team` accounts on one email
140
+ is exactly the case D1 says no heuristic can separate, so the fallback is the
141
+ one thing that IS unique. Disambiguation is per sub-group, not
142
+ all-or-nothing: the `pro` account in such a group still gets the readable
143
+ plan discriminator while its two `team` siblings get prefixes.
144
+
145
+ Collision detection is CASE-INSENSITIVE because `resolve_account_ref`
146
+ resolves labels and emails case-insensitively — two labels differing only in
147
+ case are one ambiguous ref, so they are one collision here too.
148
+
149
+ The result is injective by construction: `account_key` prefixes are the
150
+ terminal discriminator and keys are distinct.
151
+ """
152
+ by_base: "dict[str, list[dict]]" = {}
153
+ for row in rows:
154
+ key = str(row.get("account_key") or "")
155
+ if not key or key in (_lib_accounts.UNATTRIBUTED, _lib_accounts.VENDOR_WIDE):
156
+ continue
157
+ by_base.setdefault(_base_label_for_row(row).lower(), []).append(row)
158
+
159
+ labels: "dict[str, str]" = {}
160
+ for group in by_base.values():
161
+ if len(group) == 1:
162
+ row = group[0]
163
+ labels[str(row["account_key"])] = _base_label_for_row(row)
164
+ continue
165
+ by_plan: "dict[str, list[dict]]" = {}
166
+ for row in group:
167
+ plan = str(row.get("plan_type") or "").strip()
168
+ by_plan.setdefault(plan.lower(), []).append(row)
169
+ for plan_group in by_plan.values():
170
+ for row in plan_group:
171
+ base = _base_label_for_row(row)
172
+ plan = str(row.get("plan_type") or "").strip()
173
+ key = str(row["account_key"])
174
+ discriminator = (
175
+ plan if plan and len(plan_group) == 1 else key[:8]
176
+ )
177
+ labels[key] = f"{base} ({discriminator})"
178
+ return labels
179
+
180
+
181
+ def display_label_map(conn, provider: str) -> "dict[str, str]":
182
+ """`display_label_map_from_rows` over one provider's registry."""
183
+ return display_label_map_from_rows(load_accounts(conn, provider))
184
+
185
+
186
+ def display_account_label(conn, account_key: str) -> str:
187
+ """The population-aware label for ONE key — the scalar entry point every
188
+ consumer (alert prefix, share label, `--account` JSON, dashboard card) uses.
189
+
190
+ Returns exactly what `display_label_map` would for the same key, so the
191
+ surfaces cannot disagree about what an account is called. Sentinels and keys
192
+ the registry does not know degrade to the scalar `account_label`, never to a
193
+ guess.
194
+ """
195
+ if account_key in (_lib_accounts.UNATTRIBUTED, _lib_accounts.VENDOR_WIDE):
196
+ return account_label(conn, account_key)
197
+ row = conn.execute(
198
+ "SELECT provider FROM accounts WHERE account_key = ?", (account_key,)
199
+ ).fetchone()
200
+ if row is None or not row[0]:
201
+ return account_label(conn, account_key)
202
+ return display_label_map(conn, str(row[0])).get(
203
+ account_key, account_label(conn, account_key))
204
+
205
+
109
206
  def resolve_account_filter(args, provider: str = "claude", *,
110
207
  needs_cache: bool = False) -> "tuple[str | None, int | None]":
111
208
  """Resolve the ``--account <ref>`` render filter (#341, spec §3) to an
@@ -133,10 +230,7 @@ def resolve_account_filter(args, provider: str = "claude", *,
133
230
  key = _lib_accounts.resolve_account_ref(conn, ref, provider)
134
231
  except _lib_accounts.AccountRefError as exc:
135
232
  eprint(f"account: --account {ref!r} is ambiguous or unknown")
136
- if exc.candidates:
137
- eprint("candidates:")
138
- for cand in exc.candidates:
139
- eprint(f" {cand}")
233
+ print_ref_candidates(conn, exc.candidates)
140
234
  return (None, 2)
141
235
  finally:
142
236
  conn.close()
@@ -161,7 +255,7 @@ def account_json_fields(account_key: "str | None") -> dict:
161
255
  return {}
162
256
  conn = _cctally_core.open_db()
163
257
  try:
164
- label = account_label(conn, account_key)
258
+ label = display_account_label(conn, account_key)
165
259
  finally:
166
260
  conn.close()
167
261
  return {"accountKey": account_key, "accountLabel": label}
@@ -267,10 +361,19 @@ def _cmd_account_list(args: argparse.Namespace) -> int:
267
361
  headers = ["PROVIDER", "LABEL", "EMAIL", "PLAN", "FIRST SEEN",
268
362
  "LAST SEEN", "ACTIVE"]
269
363
  rows = []
364
+ # #416 §6: the rendered label is population-aware, so two accounts that
365
+ # auto-label to one email no longer print identically in the list they are
366
+ # meant to be distinguished by. Built per provider, since a Claude account
367
+ # sharing a Codex account's email is not a collision (they never appear in
368
+ # one list).
369
+ display: "dict[str, str]" = {}
370
+ for prov in sorted({str(a["provider"]) for a in accounts if a["provider"]}):
371
+ display.update(display_label_map_from_rows(
372
+ [a for a in accounts if a["provider"] == prov]))
270
373
  for a in accounts:
271
374
  rows.append([
272
375
  a["provider"] or "-",
273
- account_label_from_row(a),
376
+ display.get(a["account_key"]) or account_label_from_row(a),
274
377
  _dash(a["email"]),
275
378
  _dash(a["plan_type"]),
276
379
  _date_only(a["first_seen_utc"]),
@@ -290,16 +393,40 @@ def account_label_from_row(a: dict) -> str:
290
393
  return (a["account_key"] or "")[:8] or "-"
291
394
 
292
395
 
396
+ def print_ref_candidates(conn, candidates) -> None:
397
+ """Print an ambiguity candidate list a user can actually act on (#416 §6).
398
+
399
+ Each line carries the population-aware DISPLAY label plus a key PREFIX.
400
+ Both are needed: `resolve_account_ref` accepts only STORED labels, emails and
401
+ key prefixes (`bin/_lib_accounts.py`), so a generated collision label such as
402
+ `omrikais@me.com (pro)` is NOT a resolvable ref — printing it alone would
403
+ hand the user a string that cannot be typed back. The prefix is the
404
+ resolvable half; the label is the half that says which account it is.
405
+
406
+ Best-effort: an unreadable registry falls back to the bare keys, which is
407
+ exactly today's output.
408
+ """
409
+ if not candidates:
410
+ return
411
+ eprint("candidates:")
412
+ try:
413
+ labels = {
414
+ key: display_account_label(conn, key) for key in candidates
415
+ }
416
+ except sqlite3.Error:
417
+ labels = {}
418
+ for cand in candidates:
419
+ label = labels.get(cand)
420
+ eprint(f" {cand[:8]} {label}" if label else f" {cand}")
421
+
422
+
293
423
  def _resolve_ref_or_exit(conn, ref: str) -> "str | None":
294
424
  """Resolve a ref, printing candidates + returning None on error (exit 2)."""
295
425
  try:
296
426
  return _lib_accounts.resolve_account_ref(conn, ref)
297
427
  except _lib_accounts.AccountRefError as exc:
298
428
  eprint(f"account: ref {ref!r} is ambiguous or unknown")
299
- if exc.candidates:
300
- eprint("candidates:")
301
- for cand in exc.candidates:
302
- eprint(f" {cand}")
429
+ print_ref_candidates(conn, exc.candidates)
303
430
  return None
304
431
 
305
432
 
@@ -319,6 +446,10 @@ def _cmd_account_show(args: argparse.Namespace) -> int:
319
446
  if row is not None else None)
320
447
  snap_count = _count_scoped(conn, "weekly_usage_snapshots", key)
321
448
  milestone_count = _count_scoped(conn, "percent_milestones", key)
449
+ # Resolved while the connection is still open — the map is
450
+ # population-aware and therefore needs the registry, unlike the scalar
451
+ # `account_label_from_row` it replaces.
452
+ display = display_account_label(conn, key) if a is not None else None
322
453
  finally:
323
454
  conn.close()
324
455
  active = resolve_active_account_keys()
@@ -342,7 +473,9 @@ def _cmd_account_show(args: argparse.Namespace) -> int:
342
473
  print(json.dumps(_cctally().stamp_schema_version(payload)))
343
474
  return 0
344
475
 
345
- label = (account_label_from_row(a) if a else
476
+ # #416 §6: population-aware, so `account show` names the account exactly the
477
+ # way `account list`, the chip, the alert prefix and the share label do.
478
+ label = (display or
346
479
  ("Unattributed" if key == _lib_accounts.UNATTRIBUTED else key[:8]))
347
480
  lines = [
348
481
  f"Account: {label}",
@@ -204,7 +204,9 @@ def _alert_label_prefix(axis: str, account_key: "str | None") -> str:
204
204
  try:
205
205
  if _cctally_account.real_account_count(conn, vendor) <= 1:
206
206
  return ""
207
- return f"[{_cctally_account.account_label(conn, account_key)}] "
207
+ # #416 §6: population-aware, so two accounts that auto-label to
208
+ # one email do not print the same alert prefix.
209
+ return f"[{_cctally_account.display_account_label(conn, account_key)}] "
208
210
  finally:
209
211
  conn.close()
210
212
  except Exception: