cctally 1.47.0 → 1.48.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,16 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.48.0] - 2026-06-16
9
+
10
+ ### Fixed
11
+ - **Dashboard conversation viewer — Claude Code's new subagent transcript format:** restored correct rendering after Claude Code moved subagent transcripts to `subagents/agent-*.jsonl` with `Agent`-renamed spawns and null cross-file parent links. Background (async) subagent cards now show completion (✓) with token/duration/tool counts — derived from the subagent's own thread and marked with a `~` when Claude Code provided none; nested subagents render as recursive nested cards, each shown exactly once with the spawning tool chip merged in (a grandchild no longer renders as a detached title-only card plus a stray `Agent` chip); and a skill invoked inside a live-watched subagent now appears without a manual page refresh (the live-tail re-fetches a small overlap window and upserts folded-in content). All read-time over the existing cache — no migration, no re-ingest.
12
+ - **Dashboard conversation viewer — a message you typed while the agent was still working now renders.** When you send a message while the main agent (or a subagent) is busy, Claude Code queues it and persists it as a `queued_command` *attachment* — with the text in `attachment.prompt` rather than a normal `user` turn — so the transcript reader silently dropped it: the prompt was right there in the session's JSONL but never appeared in the rendered conversation. The reader now ingests those queued prompts (`commandMode: "prompt"`) as ordinary "you" turns in their chronological place, while harness-injected queued items such as background `task-notification`s stay excluded. New conversations show queued messages immediately; existing history is re-derived once by a flag-only cache migration (`014_conversation_queued_prompt_reingest`) — the cache is fully re-derivable, so there is nothing to do on upgrade.
13
+ - **Dashboard conversation viewer — jumping to a deeply-nested subagent now lands the card aligned to the top.** Clicking an outline-rail entry for a *grandchild* subagent (a thread nested inside another subagent card) scrolled the target to the *center* of the viewport — fine for an ordinary message, but a subagent card is often much taller than the screen, so centering it pushed its head well above the fold (roughly 250px up, and more on a large window). A subagent-card jump now aligns the card's head to the top of the viewport instead of centering it (and re-aims on the next animation frame once the force-opened ancestor cards have settled), so a deeply-nested target lands head-first at the top (#204).
14
+
15
+ ### Security
16
+ - **Internal (build/test-time only, no user-facing change): bumped the dashboard's dev dependencies to clear three Dependabot advisories** — `vite` 8.0.10 → 8.0.16 (fixes a `server.fs.deny` bypass and an NTLMv2 hash disclosure, both Windows + dev-server only) and the test-only transitive `form-data` 4.0.5 → 4.0.6 (CRLF injection via unescaped multipart field names). None of these packages ship to installed users — the npm/brew artifacts carry only the Python CLI plus the pre-built `dashboard/static/` bundle, never `node_modules` — so there is no runtime exposure; the bundle was rebuilt against the new `vite` and all dashboard tests pass.
17
+
8
18
  ## [1.47.0] - 2026-06-16
9
19
 
10
20
  ### Added
@@ -594,6 +594,7 @@ _TARGETED_DECLINE_FLAGS = (
594
594
  "conversation_search_split_pending",
595
595
  "conversation_promote_command_args_pending",
596
596
  "conversation_sessions_backfill_pending",
597
+ "conversation_queued_prompt_reingest_pending", # migration 014
597
598
  )
598
599
 
599
600
 
@@ -725,12 +726,19 @@ def sync_cache(
725
726
  # media[] placeholders + user-content media index + web_search/
726
727
  # web_fetch captures); the same offset-0 walk re-derives them, so drop
727
728
  # that flag here as well.
729
+ # Migration 014 sets the DISTINCT
730
+ # conversation_queued_prompt_reingest_pending flag (to land queued-
731
+ # while-busy user prompts persisted as queued_command attachments); the
732
+ # same offset-0 walk re-derives them through the current parser, so drop
733
+ # that flag here too — MISSING this site re-arms the flag on every
734
+ # cache-sync --rebuild.
728
735
  conn.execute(
729
736
  "DELETE FROM cache_meta WHERE key IN "
730
737
  "('conversation_reingest_pending',"
731
738
  " 'conversation_source_tool_use_reingest_pending',"
732
739
  " 'conversation_reingest_enrichment_pending',"
733
740
  " 'conversation_media_reingest_pending',"
741
+ " 'conversation_queued_prompt_reingest_pending',"
734
742
  " 'conversation_reingest_cursor',"
735
743
  " 'conversation_reingest_cursor_gen')")
736
744
  # #177 S6: a rebuild repopulates search_tool/search_thinking via the
@@ -874,16 +882,20 @@ def sync_cache(
874
882
  # ANOTHER distinct flag ``conversation_media_reingest_pending`` to land
875
883
  # the tool_result media[] placeholders + user-content media index +
876
884
  # web_search/web_fetch captures; same offset-0 re-parse, same reason
877
- # for a distinct flag. We trigger the SAME clear + offset-0 backfill on
878
- # ANY of these flags and clear them ALL atomically here under the held
879
- # flock.
885
+ # for a distinct flag. Migration 014 uses ANOTHER distinct flag
886
+ # ``conversation_queued_prompt_reingest_pending`` to land queued-while-
887
+ # busy user prompts (queued_command attachments the parser now promotes
888
+ # to HUMAN); same offset-0 re-parse, same distinct-flag reason. We
889
+ # trigger the SAME clear + offset-0 backfill on ANY of these flags and
890
+ # clear them ALL atomically here under the held flock.
880
891
  try:
881
892
  _reingest = conn.execute(
882
893
  "SELECT 1 FROM cache_meta WHERE key IN "
883
894
  "('conversation_reingest_pending',"
884
895
  " 'conversation_source_tool_use_reingest_pending',"
885
896
  " 'conversation_reingest_enrichment_pending',"
886
- " 'conversation_media_reingest_pending')"
897
+ " 'conversation_media_reingest_pending',"
898
+ " 'conversation_queued_prompt_reingest_pending')"
887
899
  ).fetchone() is not None
888
900
  except sqlite3.OperationalError:
889
901
  _reingest = False
@@ -1473,6 +1485,7 @@ _REINGEST_FLAG_KEYS = (
1473
1485
  "conversation_source_tool_use_reingest_pending",
1474
1486
  "conversation_reingest_enrichment_pending",
1475
1487
  "conversation_media_reingest_pending", # #177 S4 (migration 009)
1488
+ "conversation_queued_prompt_reingest_pending", # migration 014
1476
1489
  )
1477
1490
 
1478
1491
 
@@ -1554,6 +1567,7 @@ def _resumable_reingest_conversation_messages(conn):
1554
1567
  " 'conversation_source_tool_use_reingest_pending',"
1555
1568
  " 'conversation_reingest_enrichment_pending',"
1556
1569
  " 'conversation_media_reingest_pending',"
1570
+ " 'conversation_queued_prompt_reingest_pending',"
1557
1571
  " 'conversation_reingest_cursor',"
1558
1572
  " 'conversation_reingest_cursor_gen')")
1559
1573
  conn.commit()
@@ -7594,6 +7594,20 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7594
7594
  + "\n\n").encode("utf-8"))
7595
7595
  self.wfile.flush()
7596
7596
  idle = 0.0
7597
+ # §6 P2-H — a brand-new subagent file's FIRST content was
7598
+ # just ingested by this emitting cycle, so the session's
7599
+ # source-path set may have grown. Re-resolve it now (vs
7600
+ # waiting up to _LIVE_TAIL_FILE_RESET_EVERY cycles) so the
7601
+ # new thread (incl. a skill invoked inside it) live-tails
7602
+ # promptly. A new path seeds seen=None (cur lacks a row),
7603
+ # so changed_paths flags it next cycle → it ingests + emits.
7604
+ # setdefault never disturbs an existing cursor.
7605
+ new_files = _resolve()
7606
+ if set(new_files) != set(files):
7607
+ files = new_files
7608
+ cur = _cached_file_sigs(conn, files)
7609
+ for p in files:
7610
+ seen.setdefault(p, cur.get(p))
7597
7611
  continue
7598
7612
  idle += _LIVE_TAIL_POLL_INTERVAL
7599
7613
  if idle >= _LIVE_TAIL_KEEPALIVE:
@@ -3442,6 +3442,27 @@ def _013_create_conversation_sessions(conn: sqlite3.Connection) -> None:
3442
3442
  conn.commit()
3443
3443
 
3444
3444
 
3445
+ @cache_migration("014_conversation_queued_prompt_reingest")
3446
+ def _014_conversation_queued_prompt_reingest(conn: sqlite3.Connection) -> None:
3447
+ """Flag-only re-ingest so a message typed while the agent was busy — QUEUED
3448
+ and persisted as an ``attachment`` row (``attachment.type=="queued_command"``,
3449
+ ``commandMode=="prompt"``) rather than a ``type:"user"`` turn — lands on
3450
+ existing history. The parser (``_queued_prompt_row``) now promotes those to a
3451
+ synthetic HUMAN turn at ingest, so the offset-0 re-parse re-derives them with
3452
+ zero new consumption code. Sets the DISTINCT
3453
+ ``conversation_queued_prompt_reingest_pending`` flag (NOT the shared
3454
+ ``conversation_reingest_pending``, which also gates migration 005's read-time
3455
+ human-fallback in the query kernel). Consumption rides the #179 RESUMABLE
3456
+ per-file reingest (_resumable_reingest_conversation_messages) — the flag is
3457
+ wired into _TARGETED_DECLINE_FLAGS + _REINGEST_FLAG_KEYS + both flag SELECTs +
3458
+ the two cleanup DELETE lists in _cctally_cache.py (all five sites; missing one
3459
+ either never triggers or re-arms forever). Central stamp via the dispatcher
3460
+ (#140); a fresh install stamps it WITHOUT running (empty table -> the flag, if
3461
+ ever set, is a harmless no-op). Mirrors 007/009."""
3462
+ _set_cache_meta(conn, "conversation_queued_prompt_reingest_pending", "1")
3463
+ conn.commit()
3464
+
3465
+
3445
3466
  # === Region 7d: Stats migration 008_recompute_weekly_cost_snapshots_dedup_fix ===
3446
3467
 
3447
3468
  @stats_migration("008_recompute_weekly_cost_snapshots_dedup_fix")
@@ -263,12 +263,51 @@ def parse_message_row(obj, offset):
263
263
  populated by a separate second seek-and-walk over the same byte span."""
264
264
  t = obj.get("type")
265
265
  if t not in ("user", "assistant"):
266
- return None
266
+ # A message typed while the agent (the main session OR a subagent) is
267
+ # still working is QUEUED and persisted as an ``attachment`` row, never a
268
+ # ``type:"user"`` turn — so the user/assistant gate above would drop it and
269
+ # it would never reach conversation_messages (the reader bug). Promote the
270
+ # user-typed ones here; everything else stays dropped.
271
+ return _queued_prompt_row(obj, t, offset)
267
272
  if not obj.get("uuid"):
268
273
  return None
269
274
  return _normalize(obj, t, offset)
270
275
 
271
276
 
277
+ def _queued_prompt_row(obj, t, offset):
278
+ """A queued user prompt -> a synthetic HUMAN ``MessageRow``, else ``None``.
279
+
280
+ Claude Code persists a message typed while the agent is busy as
281
+ ``{"type":"attachment","attachment":{"type":"queued_command",
282
+ "commandMode":"prompt","prompt":<text>}}`` — carrying its OWN
283
+ uuid/parentUuid/timestamp, with the text in ``attachment.prompt`` rather than
284
+ ``message.content``. Only ``commandMode=="prompt"`` is promoted: a queued
285
+ ``task-notification`` (``commandMode=="task-notification"``) is harness-injected
286
+ background plumbing — the same ``<task-notification>`` content already
287
+ classifies META when it arrives as a regular line — not something the user
288
+ typed, so it stays dropped."""
289
+ if t != "attachment" or not obj.get("uuid"):
290
+ return None
291
+ att = obj.get("attachment")
292
+ if not isinstance(att, dict) or att.get("type") != "queued_command":
293
+ return None
294
+ if att.get("commandMode") != "prompt":
295
+ return None
296
+ prompt = att.get("prompt")
297
+ if not isinstance(prompt, str) or not prompt.strip():
298
+ return None
299
+ # Route a synthesized user message through _normalize so the queued prompt
300
+ # gets the SAME classification a typed turn would (HUMAN, the #188
301
+ # slash-command-args promotion, system-marker / notification folding, the
302
+ # remote-control prefix strip, and the split search-column derivation). Every
303
+ # top-level field (uuid / parentUuid / sessionId / timestamp / cwd / gitBranch
304
+ # / isSidechain) rides along on the shallow copy; _normalize keys off the ``t``
305
+ # arg, never ``obj["type"]``, so the "attachment" type is inert here.
306
+ synth = dict(obj)
307
+ synth["message"] = {"role": "user", "content": prompt}
308
+ return _normalize(synth, "user", offset)
309
+
310
+
272
311
  @dataclass
273
312
  class AiTitleRow:
274
313
  """Pure per-line AI-title record (no I/O). Parallels MessageRow but for the
@@ -16,7 +16,7 @@ import json as _json
16
16
  import os
17
17
  import re
18
18
  import sqlite3
19
- from datetime import datetime as _datetime
19
+ from datetime import datetime as _datetime, timezone as _timezone
20
20
 
21
21
  # Public surface (Plan 2): shipped in the npm tarball + brew formula + public
22
22
  # mirror — imported by the dashboard's conversation endpoints at runtime.
@@ -272,6 +272,55 @@ def _subagent_key(source_path):
272
272
  return stem or None
273
273
 
274
274
 
275
+ # §4 1a — nested (grandchild) subagent result parse. The new Claude Code format
276
+ # emits a grandchild's spawn result as STRING content (no structured
277
+ # `agent_id`/`subagent_meta`), with a trailing `agentId: <hash> (use SendMessage
278
+ # …)` line and an OPTIONAL `<usage>` totals wrapper. Anchored on the literal
279
+ # `<usage>` wrapper; tolerant of whitespace/newline variants (re.DOTALL).
280
+ _NESTED_AGENT_ID_RE = re.compile(r"agentId:\s*([0-9a-f]+)")
281
+ _NESTED_USAGE_RE = re.compile(
282
+ r"<usage>\s*subagent_tokens:\s*(\d+)\s*tool_uses:\s*(\d+)\s*duration_ms:\s*(\d+)\s*</usage>",
283
+ re.DOTALL,
284
+ )
285
+
286
+
287
+ def _parse_nested_agent_result(text):
288
+ """Nested (grandchild) subagent result: string-content, no structured
289
+ `agentId` (spec §4 1a). Parse `agentId` (+ OPTIONAL `<usage>` totals) out of
290
+ the result text. Returns (agent_id, meta) — meta is {} when `<usage>` is
291
+ absent/clipped past the 16 KB cap, populated with completed totals when
292
+ present. Returns None when no `agentId` is present (an ordinary result, or a
293
+ >16 KB clip past the `agentId:` line itself — degrades to a flat card, no
294
+ mis-link). Linking on `agentId` ALONE is sufficient (Codex P1-B)."""
295
+ if not text:
296
+ return None
297
+ m = _NESTED_AGENT_ID_RE.search(text)
298
+ if not m:
299
+ return None
300
+ meta = {}
301
+ u = _NESTED_USAGE_RE.search(text)
302
+ if u:
303
+ meta["total_tokens"] = int(u.group(1))
304
+ meta["total_tool_use_count"] = int(u.group(2))
305
+ meta["total_duration_ms"] = int(u.group(3))
306
+ meta["status"] = "completed"
307
+ return m.group(1), meta
308
+
309
+
310
+ def _iso_ms(ts):
311
+ """Epoch milliseconds for an ISO-8601 timestamp string (tolerant of a
312
+ trailing 'Z'); None on parse failure. Used by §4 1c derived-duration."""
313
+ if not ts:
314
+ return None
315
+ try:
316
+ dt = _datetime.fromisoformat(ts.replace("Z", "+00:00"))
317
+ if dt.tzinfo is None:
318
+ dt = dt.replace(tzinfo=_timezone.utc)
319
+ return int(dt.timestamp() * 1000)
320
+ except (ValueError, TypeError):
321
+ return None
322
+
323
+
275
324
  def _entry_cost(model, inp, out, cc, cr, cost_usd_raw) -> float:
276
325
  """Cost for one session_entries row via the shared pricing helper. Tokens →
277
326
  the helper's usage dict. cost_usd_raw is passed as the optional override the
@@ -758,6 +807,9 @@ def _assemble_session(conn, session_id):
758
807
  spawn_kind = {} # tool_use id -> subagent_type
759
808
  spawn_desc = {} # tool_use id -> spawning Task description (#193)
760
809
  agent_link = {} # tool_use id -> (agent_id, raw_meta)
810
+ nested_candidates = [] # §4 1a: (tool_use_id, block) — string-content spawn
811
+ # results with no structured agent_id, resolved after
812
+ # spawn_kind is complete (text still present pre-fold)
761
813
  ask_link = {} # tool_use id -> (answers, annotations) (#177 S2)
762
814
  bash_link = {} # tool_use id -> (stderr, interrupted) (#177 S3)
763
815
  web_search_link = {} # tool_use id -> web_search payload (#177 S4)
@@ -782,6 +834,11 @@ def _assemble_session(conn, session_id):
782
834
  meta = b.pop("subagent_meta", None)
783
835
  if aid and b.get("tool_use_id") is not None:
784
836
  agent_link[b["tool_use_id"]] = (aid, meta or {})
837
+ elif b.get("tool_use_id") is not None:
838
+ # §4 1a candidate — a tool_result with no structured agentId.
839
+ # Resolved AFTER spawn_kind is complete (text still present,
840
+ # pre-Phase-2-fold). The block's `text` holds the result body.
841
+ nested_candidates.append((b["tool_use_id"], b))
785
842
  ans = b.pop("ask_answers", None) # #177 S2
786
843
  anno = b.pop("ask_annotations", None)
787
844
  if ans is not None and b.get("tool_use_id") is not None:
@@ -800,6 +857,14 @@ def _assemble_session(conn, session_id):
800
857
  tlist_ = b.pop("task_list", None)
801
858
  if b.get("tool_use_id") is not None and (tid_ is not None or tlist_ is not None):
802
859
  task_link[b["tool_use_id"]] = {"task_id": tid_, "task_list": tlist_}
860
+ # §4 1a — nested grandchild results: gate the text-parse on the owning
861
+ # tool_use being a spawn (in spawn_kind), so an ordinary tool result that
862
+ # merely mentions "agentId" never matches.
863
+ for _tuid, _block in nested_candidates:
864
+ if _tuid in spawn_kind and _tuid not in agent_link:
865
+ parsed = _parse_nested_agent_result(_block.get("text"))
866
+ if parsed is not None:
867
+ agent_link[_tuid] = parsed
803
868
  subagent_meta = {}
804
869
  for _tuid, _kind in spawn_kind.items():
805
870
  _link = agent_link.get(_tuid)
@@ -814,6 +879,24 @@ def _assemble_session(conn, session_id):
814
879
  _entry[_f] = _raw[_f]
815
880
  subagent_meta[_aid] = _entry # agent_id == subagent_key
816
881
 
882
+ # §4 1b — parent linkage: the item HOLDING each linked spawn is the child's
883
+ # parent thread + placement anchor. tooluse_index[spawn_id] = (item, block);
884
+ # the item's subagent_key/anchor.uuid were set at build time (available now,
885
+ # pre-Phase-3). Works for a main parent (subagent_key=None) AND a grandchild
886
+ # whose parent is a child subagent. spawn_tool_use_id is required because one
887
+ # assistant item can hold MORE than one spawn (Codex P1-C).
888
+ for _tuid, (_aid, _raw) in agent_link.items():
889
+ _entry = subagent_meta.get(_aid)
890
+ if _entry is None:
891
+ continue
892
+ _hit = tooluse_index.get(_tuid)
893
+ if _hit is None:
894
+ continue
895
+ _owner = _hit[0]
896
+ _entry["parent_subagent_key"] = _owner["subagent_key"]
897
+ _entry["spawn_uuid"] = _owner["anchor"]["uuid"]
898
+ _entry["spawn_tool_use_id"] = _tuid
899
+
817
900
  # ---- Phase 2: fold each tool_result item into its owning assistant item ----
818
901
  drop = set() # id() of folded placeholder items
819
902
  for tr in tool_result_items:
@@ -987,6 +1070,66 @@ def _assemble_session(conn, session_id):
987
1070
  it.pop("_has_prose", None)
988
1071
  header_cost = round(header_cost, 6)
989
1072
 
1073
+ # §4 1c — async completion. A background subagent's launch result carries
1074
+ # status:"async_launched" and NO totals; completion arrives as a separate
1075
+ # <task-notification> meta row (text populated by Phase 4). Join it back, and
1076
+ # derive any totals Claude Code never provided from the child's own thread.
1077
+ _notif_status = {} # spawn tool_use_id -> status
1078
+ for it in items:
1079
+ if it["kind"] == "meta" and it.get("meta_kind") == "notification":
1080
+ body = it.get("text") or ""
1081
+ tu = re.search(r"<tool-use-id>([^<]+)</tool-use-id>", body)
1082
+ stx = re.search(r"<status>([^<]+)</status>", body)
1083
+ if tu and stx:
1084
+ _notif_status[tu.group(1).strip()] = stx.group(1).strip()
1085
+ for _tuid, _status in _notif_status.items():
1086
+ _link = agent_link.get(_tuid)
1087
+ if _link is not None and _link[0] in subagent_meta:
1088
+ subagent_meta[_link[0]]["status"] = _status # upgrades async_launched -> completed
1089
+
1090
+ # Derived totals: any child still missing a count gets it from its own
1091
+ # subagent_key bucket. tool-count = tool_call/tool_use blocks; duration =
1092
+ # (max_ts - min_ts) ms; tokens = sum of per-turn token totals (now stamped).
1093
+ # Authoritative values always win; only missing keys are filled.
1094
+ # Bucket items by subagent_key in ONE document-ordered pass so the loop
1095
+ # below indexes in O(1) instead of re-walking all items per child.
1096
+ _items_by_subagent_key: dict[str, list] = {}
1097
+ for it in items:
1098
+ _sk = it.get("subagent_key")
1099
+ if _sk is not None:
1100
+ _items_by_subagent_key.setdefault(_sk, []).append(it)
1101
+ for _aid, _entry in subagent_meta.items():
1102
+ if all(_entry.get(_f) is not None
1103
+ for _f in ("total_tokens", "total_duration_ms", "total_tool_use_count")):
1104
+ continue
1105
+ _bucket = _items_by_subagent_key.get(_aid)
1106
+ if not _bucket:
1107
+ continue
1108
+ _derived = False
1109
+ if _entry.get("total_tool_use_count") is None:
1110
+ _entry["total_tool_use_count"] = sum(
1111
+ 1 for it in _bucket for b in it["blocks"]
1112
+ if b.get("kind") in ("tool_call", "tool_use"))
1113
+ _derived = True
1114
+ if _entry.get("total_duration_ms") is None:
1115
+ _ms = [m for m in (_iso_ms(it.get("ts")) for it in _bucket) if m is not None]
1116
+ if len(_ms) >= 2:
1117
+ _entry["total_duration_ms"] = max(_ms) - min(_ms)
1118
+ _derived = True
1119
+ if _entry.get("total_tokens") is None:
1120
+ _tok, _any = 0, False
1121
+ for it in _bucket:
1122
+ t = it.get("tokens")
1123
+ if t:
1124
+ _any = True
1125
+ _tok += (t.get("input", 0) + t.get("output", 0)
1126
+ + t.get("cache_creation", 0) + t.get("cache_read", 0))
1127
+ if _any:
1128
+ _entry["total_tokens"] = _tok
1129
+ _derived = True
1130
+ if _derived:
1131
+ _entry["totals_derived"] = True
1132
+
990
1133
  # Stamp cache-failure markers (spec §1) AFTER tokens are on each item and
991
1134
  # while `items` is still document-ordered (the running-max walk is
992
1135
  # order-dependent). Healthy turns get no key (absent, not zero). Shared