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 +10 -0
- package/bin/_cctally_cache.py +18 -4
- package/bin/_cctally_dashboard.py +14 -0
- package/bin/_cctally_db.py +21 -0
- package/bin/_lib_conversation.py +40 -1
- package/bin/_lib_conversation_query.py +144 -1
- package/dashboard/static/assets/index-De72H7wU.js +68 -0
- package/dashboard/static/assets/{index-CuAtCrys.css → index-QLGET4cL.css} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +1 -1
- package/dashboard/static/assets/index-BOR4FBvt.js +0 -68
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
|
package/bin/_cctally_cache.py
CHANGED
|
@@ -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.
|
|
878
|
-
#
|
|
879
|
-
#
|
|
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:
|
package/bin/_cctally_db.py
CHANGED
|
@@ -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")
|
package/bin/_lib_conversation.py
CHANGED
|
@@ -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
|
-
|
|
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
|