cctally 1.37.0 → 1.37.2

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,17 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.37.2] - 2026-06-12
9
+
10
+ ### Fixed
11
+ - Dashboard: the web dashboard no longer hangs on startup when you have a large conversation history. It now binds its HTTP port and serves the six cost/usage panels immediately, building the first heavy cache sync on a background thread and pushing the enriched conversation data over SSE once it completes — instead of blocking the bind behind a multi-minute (and, if interrupted, indefinite) first sync. `--no-sync` startup is byte-identical (#179).
12
+ - Dashboard conversation reader: the one-time conversation-enrichment reingest (the #177 re-derivation that runs once over existing transcripts) is now resumable. It walks transcript files under a sorted-path cursor, re-enriching one file per atomic transaction, so a Ctrl-C or crash mid-reingest resumes where it left off on the next sync instead of restarting the full clear-and-backfill from scratch — which previously could hold the cache writer lock and hang. `cache-sync --rebuild` remains the clean one-shot recovery path (#179).
13
+
14
+ ## [1.37.1] - 2026-06-11
15
+
16
+ ### Fixed
17
+ - Conversation viewer: the `TaskCreate`/`TaskUpdate`/`TaskList` checklist card rendered an empty "0 / 0" for tasks created inside subagents. Subagent Task tools record their result as a plain string (`Task #N created successfully: …`) rather than the structured shape main-session tools use, so the reader couldn't recover the task ids; it now parses both shapes. The running checklist is also reconstructed per-subagent, so parallel subagents no longer bleed their tasks into one another's cards, and a Task run whose results can't be parsed now falls back to plain tool chips instead of a misleading empty card. Existing transcripts pick this up on the next `cache-sync --rebuild`.
18
+
8
19
  ## [1.37.0] - 2026-06-11
9
20
 
10
21
  ### Added
@@ -268,6 +268,9 @@ _CACHE_MIGRATIONS = _cctally_db_sib._CACHE_MIGRATIONS
268
268
  # drop/recreate dance so the per-row delete trigger never fires O(rows) under
269
269
  # the held lock on a rebuild / truncation escalation.
270
270
  clear_conversation_messages = _cctally_db_sib.clear_conversation_messages
271
+ # cache_meta key/value upsert helper — reused by the resumable reingest cursor
272
+ # writes (#179) so the ON CONFLICT idiom lives in one place. Caller commits.
273
+ _set_cache_meta = _cctally_db_sib._set_cache_meta
271
274
 
272
275
 
273
276
  # === BEGIN MOVED REGIONS ===
@@ -640,7 +643,9 @@ def sync_cache(
640
643
  "DELETE FROM cache_meta WHERE key IN "
641
644
  "('conversation_reingest_pending',"
642
645
  " 'conversation_source_tool_use_reingest_pending',"
643
- " 'conversation_reingest_enrichment_pending')")
646
+ " 'conversation_reingest_enrichment_pending',"
647
+ " 'conversation_reingest_cursor',"
648
+ " 'conversation_reingest_cursor_gen')")
644
649
  conn.commit()
645
650
  eprint("[cache-sync] rebuild: cleared Claude cached entries")
646
651
 
@@ -720,15 +725,12 @@ def sync_cache(
720
725
  except sqlite3.OperationalError:
721
726
  _reingest = False
722
727
  if _reingest:
723
- clear_conversation_messages(conn)
724
- backfill_conversation_messages(conn)
725
- conn.execute(
726
- "DELETE FROM cache_meta WHERE key IN "
727
- "('conversation_reingest_pending',"
728
- " 'conversation_source_tool_use_reingest_pending',"
729
- " 'conversation_reingest_enrichment_pending')"
730
- )
731
- conn.commit()
728
+ # #179: resumable per-file reingest (was a global clear_conversation_messages
729
+ # + offset-0 backfill that re-armed the entire ~2.5min rebuild on any
730
+ # interrupt). The helper checkpoints a sorted-path cursor and clears the
731
+ # three flags + cursor + gen atomically on completion. Never on the rebuild
732
+ # path (which already wipes + repopulates id-aware via the normal walk).
733
+ _resumable_reingest_conversation_messages(conn)
732
734
 
733
735
  paths: list[pathlib.Path] = list(_iter_claude_jsonl_files())
734
736
  stats.files_total = len(paths)
@@ -1157,6 +1159,95 @@ def backfill_conversation_messages(conn: sqlite3.Connection) -> int:
1157
1159
  return inserted
1158
1160
 
1159
1161
 
1162
+ _REINGEST_FLAG_KEYS = (
1163
+ "conversation_reingest_pending",
1164
+ "conversation_source_tool_use_reingest_pending",
1165
+ "conversation_reingest_enrichment_pending",
1166
+ )
1167
+
1168
+
1169
+ def _reingest_parse_file(jp, path_str):
1170
+ """Parse one Claude JSONL into enriched ``conversation_messages`` row tuples
1171
+ (``_CONV_INSERT_SQL`` column order). Mirrors ``backfill_conversation_messages``'s
1172
+ inner read+flatten, factored to a module-level seam so the resumable reingest
1173
+ builds rows BEFORE any write (a parse failure does no DML) and tests can inject.
1174
+ Raises ``OSError`` if the file can't be opened/read."""
1175
+ rows = []
1176
+ with open(jp, "r", encoding="utf-8", errors="replace") as fh:
1177
+ for m in _iter_message_rows(fh, path_str):
1178
+ rows.append(_conv_row_tuple(m, path_str))
1179
+ return rows
1180
+
1181
+
1182
+ def _resumable_reingest_conversation_messages(conn):
1183
+ """#179: resumable, lock-friendly replacement for the old global
1184
+ ``clear_conversation_messages`` + offset-0 ``backfill_conversation_messages``
1185
+ reingest, which re-armed the whole ~2.5min rebuild on any interrupt. Walks
1186
+ every Claude JSONL in deterministic sorted-path order, re-enriching one file
1187
+ per atomic transaction and checkpointing ``conversation_reingest_cursor`` so an
1188
+ interrupt resumes instead of restarting. A ``conversation_reingest_cursor_gen``
1189
+ fingerprint (the set of pending reingest flags) resets the cursor whenever the
1190
+ pending-flag set changes, so a newly-armed flag forces a fresh pass. The caller
1191
+ (``sync_cache``) already holds the cache.db flock; per-file commits bound only
1192
+ the SQLite write transaction, not the flock. Clears the three flags + cursor +
1193
+ gen atomically on completion."""
1194
+ # 1. Generation guard: reset the cursor if the live pending-flag set differs.
1195
+ set_flags = [k for k in _REINGEST_FLAG_KEYS
1196
+ if conn.execute("SELECT 1 FROM cache_meta WHERE key=?", (k,)).fetchone()]
1197
+ gen = ",".join(sorted(set_flags))
1198
+ gen_row = conn.execute(
1199
+ "SELECT value FROM cache_meta WHERE key='conversation_reingest_cursor_gen'"
1200
+ ).fetchone()
1201
+ if (gen_row[0] if gen_row else None) != gen:
1202
+ _set_cache_meta(conn, "conversation_reingest_cursor_gen", gen)
1203
+ conn.execute("DELETE FROM cache_meta WHERE key='conversation_reingest_cursor'")
1204
+ conn.commit()
1205
+ cursor = ""
1206
+ else:
1207
+ crow = conn.execute(
1208
+ "SELECT value FROM cache_meta WHERE key='conversation_reingest_cursor'"
1209
+ ).fetchone()
1210
+ cursor = crow[0] if crow and crow[0] is not None else ""
1211
+
1212
+ # 2. Per-file resumable walk in deterministic sorted-path order.
1213
+ for jp in sorted(_iter_claude_jsonl_files(), key=str):
1214
+ path_str = str(jp)
1215
+ if path_str <= cursor:
1216
+ continue
1217
+ try:
1218
+ rows = _reingest_parse_file(jp, path_str) # parse FIRST — no DML on failure
1219
+ except OSError as exc:
1220
+ # Read/parse failed BEFORE any conversation_messages DML — the file's
1221
+ # existing rows are untouched (preserved, not dropped). Only advance the
1222
+ # cursor; this cursor-only write needs no rollback envelope (no message
1223
+ # DML to undo, and an interrupt mid-commit just re-runs this file).
1224
+ eprint(f"[conversation-reingest] could not read {jp}: {exc}; "
1225
+ "preserving existing rows")
1226
+ _set_cache_meta(conn, "conversation_reingest_cursor", path_str)
1227
+ conn.commit()
1228
+ continue
1229
+ try:
1230
+ conn.execute("DELETE FROM conversation_messages WHERE source_path=?",
1231
+ (path_str,))
1232
+ if rows:
1233
+ conn.executemany(_CONV_INSERT_SQL, rows)
1234
+ _set_cache_meta(conn, "conversation_reingest_cursor", path_str)
1235
+ conn.commit()
1236
+ except BaseException:
1237
+ conn.rollback()
1238
+ raise
1239
+
1240
+ # 3. Completion: clear flags + cursor + gen atomically.
1241
+ conn.execute(
1242
+ "DELETE FROM cache_meta WHERE key IN "
1243
+ "('conversation_reingest_pending',"
1244
+ " 'conversation_source_tool_use_reingest_pending',"
1245
+ " 'conversation_reingest_enrichment_pending',"
1246
+ " 'conversation_reingest_cursor',"
1247
+ " 'conversation_reingest_cursor_gen')")
1248
+ conn.commit()
1249
+
1250
+
1160
1251
  def iter_entries(
1161
1252
  conn: sqlite3.Connection,
1162
1253
  range_start: dt.datetime,
@@ -7829,6 +7829,21 @@ def _dashboard_wait_for_signal(
7829
7829
  os.close(write_fd)
7830
7830
 
7831
7831
 
7832
+ def _dashboard_initial_snapshot(args, *, pinned_now, display_tz_pref_override):
7833
+ """#179: build the dashboard's first snapshot WITHOUT the heavy sync so the
7834
+ HTTP port binds promptly. The background ``_DashboardSyncThread`` (started in
7835
+ cmd_dashboard before the ThreadingHTTPServer bind) runs the first full
7836
+ ``sync_cache`` — including any pending conversation reingest — and SSE-pushes
7837
+ the populated snapshot on completion. ``--no-sync`` already passed
7838
+ ``skip_sync=True`` here, so that path is byte-identical; only the normal launch
7839
+ changes (its previously-redundant foreground sync is removed, not duplicated —
7840
+ the background thread's first tick was always going to sync anyway)."""
7841
+ return sys.modules["cctally"]._tui_build_snapshot(
7842
+ now_utc=pinned_now, skip_sync=True,
7843
+ display_tz_pref_override=display_tz_pref_override,
7844
+ )
7845
+
7846
+
7832
7847
  def cmd_dashboard(args: argparse.Namespace) -> int:
7833
7848
  """Launch the live web dashboard."""
7834
7849
  import signal as _signal
@@ -7906,9 +7921,13 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
7906
7921
  # weekly, project, forecast subcommands already respect.
7907
7922
  pinned_now = _command_as_of() if os.environ.get("CCTALLY_AS_OF") else None
7908
7923
 
7909
- # Build initial snapshot blocking, serves immediately.
7910
- initial = sys.modules["cctally"]._tui_build_snapshot(
7911
- now_utc=pinned_now, skip_sync=args.no_sync,
7924
+ # #179: build the initial snapshot WITHOUT the heavy sync so the HTTP port
7925
+ # binds promptly even on a heavy-history instance. The background
7926
+ # _DashboardSyncThread (started below, before the bind) owns the first full
7927
+ # sync_cache — including any pending conversation-enrichment reingest — and
7928
+ # SSE-pushes the populated snapshot on completion.
7929
+ initial = _dashboard_initial_snapshot(
7930
+ args, pinned_now=pinned_now,
7912
7931
  display_tz_pref_override=display_tz_pref_override,
7913
7932
  )
7914
7933
  if args.no_sync:
@@ -8,6 +8,7 @@ mid-write tail line. Spec §1, §2.
8
8
  """
9
9
  from __future__ import annotations
10
10
  import json
11
+ import re
11
12
  from dataclasses import dataclass
12
13
 
13
14
  HUMAN = "human"
@@ -288,45 +289,68 @@ def _attach_ask_answers(blocks, obj):
288
289
  results[0]["ask_annotations"] = bounded_anno
289
290
 
290
291
 
292
+ # Subagent Task tools record toolUseResult=null and put the identity in the
293
+ # human-readable result text instead; the id is the only thing the fold needs
294
+ # from the result (subject/status come from the call input). Anchored to the
295
+ # line start so unrelated output mentioning a task id mid-sentence never matches.
296
+ # Shapes verified against real subagent transcripts (Claude Code 2.1.173).
297
+ _TASK_CREATE_RESULT_RE = re.compile(r"^Task #(\d+) created\b")
298
+ _TASK_UPDATE_RESULT_RE = re.compile(r"^Updated task #(\d+)\b")
299
+
300
+
291
301
  def _attach_task_meta(blocks, obj):
292
302
  """Stash a Task* tool's record-level identity onto its single tool_result
293
- block so the query-kernel fold has a robust id (NOT a result-string parse).
294
- Task ids are monotonic + never reused, so the explicit id is the only stable
295
- fold key. Self-identifying by toolUseResult shape:
303
+ block so the query-kernel fold has a robust id. Task ids are monotonic +
304
+ never reused, so the explicit id is the only stable fold key. Two result
305
+ shapes, both self-identifying off the single tool_result block:
306
+
307
+ Structured (MAIN-session Task tools) — toolUseResult carries the identity:
296
308
  TaskCreate -> {"task": {"id": ...}} -> block["task_id"]
297
309
  TaskUpdate -> {"taskId": ...} -> block["task_id"]
298
310
  TaskList -> {"tasks": [{id,subject,status}]} -> block["task_list"]
311
+
312
+ String-content (SUBAGENT Task tools) — toolUseResult is null and the id
313
+ lives in the result text ("Task #7 created successfully: ..." / "Updated
314
+ task #3 status"); we recover the id from block["text"]. Subagent-driven
315
+ workflows make this the dominant shape, so missing it left every subagent
316
+ Task run rendering as an empty "0 / 0" card.
317
+
299
318
  Same exactly-one-result-block guard as _attach_subagent_result. Subjects
300
319
  bounded through _bound_input.
301
320
 
302
321
  The ``task.id`` (not ``task.task_id``) gate deliberately ignores the
303
322
  look-alike local_bash spawn result {"task": {"task_id": ..., ...}}, which is
304
323
  a different tool family and carries no checklist id."""
305
- tur = obj.get("toolUseResult")
306
- if not isinstance(tur, dict):
307
- return
308
324
  results = [b for b in blocks if b.get("kind") == "tool_result"]
309
325
  if len(results) != 1:
310
326
  return
311
327
  block = results[0]
312
- task = tur.get("task")
313
- if isinstance(task, dict) and task.get("id") is not None:
314
- block["task_id"] = str(task["id"])
315
- return
316
- if tur.get("taskId") is not None:
317
- block["task_id"] = str(tur["taskId"])
318
- return
319
- tasks = tur.get("tasks")
320
- if isinstance(tasks, list):
321
- snap = []
322
- for t in tasks:
323
- if not isinstance(t, dict) or t.get("id") is None:
324
- continue
325
- bounded, _ = _bound_input({"subject": t.get("subject") or ""})
326
- snap.append({"id": str(t["id"]),
327
- "subject": bounded.get("subject", ""),
328
- "status": t.get("status") or "pending"})
329
- block["task_list"] = snap
328
+ tur = obj.get("toolUseResult")
329
+ if isinstance(tur, dict):
330
+ task = tur.get("task")
331
+ if isinstance(task, dict) and task.get("id") is not None:
332
+ block["task_id"] = str(task["id"])
333
+ return
334
+ if tur.get("taskId") is not None:
335
+ block["task_id"] = str(tur["taskId"])
336
+ return
337
+ tasks = tur.get("tasks")
338
+ if isinstance(tasks, list):
339
+ snap = []
340
+ for t in tasks:
341
+ if not isinstance(t, dict) or t.get("id") is None:
342
+ continue
343
+ bounded, _ = _bound_input({"subject": t.get("subject") or ""})
344
+ snap.append({"id": str(t["id"]),
345
+ "subject": bounded.get("subject", ""),
346
+ "status": t.get("status") or "pending"})
347
+ block["task_list"] = snap
348
+ return
349
+ # String-content fallback (subagent Task tools): no structured identity.
350
+ m = (_TASK_CREATE_RESULT_RE.match(block.get("text") or "")
351
+ or _TASK_UPDATE_RESULT_RE.match(block.get("text") or ""))
352
+ if m:
353
+ block["task_id"] = m.group(1)
330
354
 
331
355
 
332
356
  def _stringify(c):
@@ -711,24 +711,37 @@ _TASK_TRIO = ("TaskCreate", "TaskUpdate", "TaskList")
711
711
  def _fold_task_runs(items, task_link):
712
712
  """Reconstruct the running to-do list from the chronological Task* op stream
713
713
  and stamp the resulting todos[] snapshot onto the FIRST tool_call of each
714
- Task* run. State spans the whole session; key on the explicit task id (never
715
- reused). `deleted` drops a task; a TaskList result reseeds the whole snapshot.
714
+ Task* run. Key on the explicit task id (never reused). `deleted` drops a
715
+ task; a TaskList result reseeds the whole snapshot.
716
+
717
+ Scoped PER subagent thread (``subagent_key``): the main session (key None)
718
+ and each subagent keep INDEPENDENT running checklists, so parallel subagents
719
+ with disjoint task-id ranges never bleed into one another's cards. Within a
720
+ thread, state still spans the whole session.
721
+
722
+ Degradation guard: a thread's run is stamped only once that thread has
723
+ recognized a real create/list (``seen``). A Task* run with no recognizable
724
+ create — a future result shape we don't parse, or pre-fix legacy rows —
725
+ leaves ``task_snapshot`` ABSENT, so the frontend falls back to generic chips
726
+ instead of a misleading empty "0 / 0" card.
716
727
 
717
728
  Mirrors the ask_answers join: the parser stashed the record-level identity
718
729
  onto the tool_result block, the Phase-1 sweep popped it into ``task_link``
719
730
  keyed by tool_use_id, and this fold joins it back. The frontend stays a pure
720
731
  todos[] renderer — all running-list state lives here."""
721
- order = []
722
- state = {}
732
+ threads = {} # subagent_key -> {"order": [...], "state": {...}, "seen": bool}
723
733
 
724
- def snapshot():
725
- return [dict(content=state[i]["content"], status=state[i]["status"],
726
- **({"activeForm": state[i]["activeForm"]} if state[i].get("activeForm") else {}))
727
- for i in order if i in state]
734
+ def snapshot(th):
735
+ st, order = th["state"], th["order"]
736
+ return [dict(content=st[i]["content"], status=st[i]["status"],
737
+ **({"activeForm": st[i]["activeForm"]} if st[i].get("activeForm") else {}))
738
+ for i in order if i in st]
728
739
 
729
740
  for it in items:
730
741
  if it.get("kind") != "assistant":
731
742
  continue
743
+ th = threads.setdefault(it.get("subagent_key"),
744
+ {"order": [], "state": {}, "seen": False})
732
745
  first_task_call = None
733
746
  for b in it["blocks"]:
734
747
  if b.get("kind") != "tool_call" or b.get("name") not in _TASK_TRIO:
@@ -741,30 +754,32 @@ def _fold_task_runs(items, task_link):
741
754
  if name == "TaskCreate":
742
755
  tid = link.get("task_id")
743
756
  if tid is not None:
744
- if tid not in state:
745
- order.append(tid)
746
- state[tid] = {"content": inp.get("subject") or "", "status": "pending",
747
- "activeForm": inp.get("activeForm") or ""}
757
+ th["seen"] = True
758
+ if tid not in th["state"]:
759
+ th["order"].append(tid)
760
+ th["state"][tid] = {"content": inp.get("subject") or "", "status": "pending",
761
+ "activeForm": inp.get("activeForm") or ""}
748
762
  elif name == "TaskUpdate":
749
763
  tid = str(inp.get("taskId")) if inp.get("taskId") is not None else link.get("task_id")
750
764
  status = inp.get("status")
751
765
  if tid is not None:
752
766
  if status == "deleted":
753
- state.pop(tid, None)
754
- elif tid in state and status:
755
- state[tid]["status"] = status
767
+ th["state"].pop(tid, None)
768
+ elif tid in th["state"] and status:
769
+ th["state"][tid]["status"] = status
756
770
  elif name == "TaskList":
757
771
  snap = link.get("task_list")
758
772
  if snap is not None:
759
- order = []
760
- state = {}
773
+ th["seen"] = True
774
+ th["order"] = []
775
+ th["state"] = {}
761
776
  for t in snap:
762
777
  tid = t["id"]
763
- order.append(tid)
764
- state[tid] = {"content": t.get("subject") or "",
765
- "status": t.get("status") or "pending", "activeForm": ""}
766
- if first_task_call is not None:
767
- first_task_call["task_snapshot"] = snapshot()
778
+ th["order"].append(tid)
779
+ th["state"][tid] = {"content": t.get("subject") or "",
780
+ "status": t.get("status") or "pending", "activeForm": ""}
781
+ if first_task_call is not None and th["seen"]:
782
+ first_task_call["task_snapshot"] = snapshot(th)
768
783
 
769
784
 
770
785
  def _latest(logical, col):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cctally",
3
- "version": "1.37.0",
3
+ "version": "1.37.2",
4
4
  "description": "Claude Code usage tracker and local dashboard for Pro/Max subscription limits - weekly cost-per-percent trend, quota forecasts, threshold alerts. ccusage-compatible.",
5
5
  "homepage": "https://github.com/omrikais/cctally",
6
6
  "repository": {