cctally 1.36.0 → 1.37.1
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.37.1] - 2026-06-11
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- 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`.
|
|
12
|
+
|
|
13
|
+
## [1.37.0] - 2026-06-11
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- Conversation viewer: Claude Code's live to-do tools (`TaskCreate`/`TaskUpdate`/`TaskList`) now render as a single checklist card with a progress bar — the same card the retired `TodoWrite` used — reconstructed at read time from the create/update/delete stream so the task list evolves turn by turn, instead of separate raw JSON tool chips. Additive; the legacy `TodoWrite` path is unchanged and no database migration is required.
|
|
17
|
+
|
|
8
18
|
## [1.36.0] - 2026-06-11
|
|
9
19
|
|
|
10
20
|
### Added
|
package/bin/_lib_conversation.py
CHANGED
|
@@ -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"
|
|
@@ -105,6 +106,7 @@ def _normalize(obj, t, offset):
|
|
|
105
106
|
entry_type = TOOL_RESULT
|
|
106
107
|
_attach_subagent_result(blocks, obj) # #166: record-level toolUseResult
|
|
107
108
|
_attach_ask_answers(blocks, obj) # #177 S2: AskUserQuestion answers
|
|
109
|
+
_attach_task_meta(blocks, obj) # task checklist identity
|
|
108
110
|
# tool_result rows are stored but NOT indexed as prose (spec §2). A
|
|
109
111
|
# user line that mixes a text block with a tool_result block must not
|
|
110
112
|
# leak that text into the FTS index; the full content stays in
|
|
@@ -287,6 +289,70 @@ def _attach_ask_answers(blocks, obj):
|
|
|
287
289
|
results[0]["ask_annotations"] = bounded_anno
|
|
288
290
|
|
|
289
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
|
+
|
|
301
|
+
def _attach_task_meta(blocks, obj):
|
|
302
|
+
"""Stash a Task* tool's record-level identity onto its single tool_result
|
|
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:
|
|
308
|
+
TaskCreate -> {"task": {"id": ...}} -> block["task_id"]
|
|
309
|
+
TaskUpdate -> {"taskId": ...} -> block["task_id"]
|
|
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
|
+
|
|
318
|
+
Same exactly-one-result-block guard as _attach_subagent_result. Subjects
|
|
319
|
+
bounded through _bound_input.
|
|
320
|
+
|
|
321
|
+
The ``task.id`` (not ``task.task_id``) gate deliberately ignores the
|
|
322
|
+
look-alike local_bash spawn result {"task": {"task_id": ..., ...}}, which is
|
|
323
|
+
a different tool family and carries no checklist id."""
|
|
324
|
+
results = [b for b in blocks if b.get("kind") == "tool_result"]
|
|
325
|
+
if len(results) != 1:
|
|
326
|
+
return
|
|
327
|
+
block = results[0]
|
|
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)
|
|
354
|
+
|
|
355
|
+
|
|
290
356
|
def _stringify(c):
|
|
291
357
|
if isinstance(c, str):
|
|
292
358
|
return c
|
|
@@ -484,6 +484,7 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
484
484
|
spawn_kind = {} # tool_use id -> subagent_type
|
|
485
485
|
agent_link = {} # tool_use id -> (agent_id, raw_meta)
|
|
486
486
|
ask_link = {} # tool_use id -> (answers, annotations) (#177 S2)
|
|
487
|
+
task_link = {} # tool_use id -> {"task_id", "task_list"} (Task* checklist)
|
|
487
488
|
for it in items:
|
|
488
489
|
for b in it["blocks"]:
|
|
489
490
|
k = b.get("kind")
|
|
@@ -500,6 +501,10 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
500
501
|
anno = b.pop("ask_annotations", None)
|
|
501
502
|
if ans is not None and b.get("tool_use_id") is not None:
|
|
502
503
|
ask_link[b["tool_use_id"]] = (ans, anno)
|
|
504
|
+
tid_ = b.pop("task_id", None) # Task* checklist
|
|
505
|
+
tlist_ = b.pop("task_list", None)
|
|
506
|
+
if b.get("tool_use_id") is not None and (tid_ is not None or tlist_ is not None):
|
|
507
|
+
task_link[b["tool_use_id"]] = {"task_id": tid_, "task_list": tlist_}
|
|
503
508
|
subagent_meta = {}
|
|
504
509
|
for _tuid, _kind in spawn_kind.items():
|
|
505
510
|
_link = agent_link.get(_tuid)
|
|
@@ -564,6 +569,9 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
564
569
|
if link[1]:
|
|
565
570
|
b["annotations"] = link[1]
|
|
566
571
|
|
|
572
|
+
# ---- Phase 3b: fold the Task* op stream into per-run checklist snapshots ----
|
|
573
|
+
_fold_task_runs(items, task_link)
|
|
574
|
+
|
|
567
575
|
# ---- Phase 4: classify injected meta items (skill / command / context) ----
|
|
568
576
|
# `meta` rows (the parser's isMeta classification) AND — only while the 005
|
|
569
577
|
# reingest is still pending — not-yet-reingested `human` rows whose body is a
|
|
@@ -697,6 +705,83 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
697
705
|
}
|
|
698
706
|
|
|
699
707
|
|
|
708
|
+
_TASK_TRIO = ("TaskCreate", "TaskUpdate", "TaskList")
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _fold_task_runs(items, task_link):
|
|
712
|
+
"""Reconstruct the running to-do list from the chronological Task* op stream
|
|
713
|
+
and stamp the resulting todos[] snapshot onto the FIRST tool_call of each
|
|
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.
|
|
727
|
+
|
|
728
|
+
Mirrors the ask_answers join: the parser stashed the record-level identity
|
|
729
|
+
onto the tool_result block, the Phase-1 sweep popped it into ``task_link``
|
|
730
|
+
keyed by tool_use_id, and this fold joins it back. The frontend stays a pure
|
|
731
|
+
todos[] renderer — all running-list state lives here."""
|
|
732
|
+
threads = {} # subagent_key -> {"order": [...], "state": {...}, "seen": bool}
|
|
733
|
+
|
|
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]
|
|
739
|
+
|
|
740
|
+
for it in items:
|
|
741
|
+
if it.get("kind") != "assistant":
|
|
742
|
+
continue
|
|
743
|
+
th = threads.setdefault(it.get("subagent_key"),
|
|
744
|
+
{"order": [], "state": {}, "seen": False})
|
|
745
|
+
first_task_call = None
|
|
746
|
+
for b in it["blocks"]:
|
|
747
|
+
if b.get("kind") != "tool_call" or b.get("name") not in _TASK_TRIO:
|
|
748
|
+
continue
|
|
749
|
+
if first_task_call is None:
|
|
750
|
+
first_task_call = b
|
|
751
|
+
link = task_link.get(b.get("tool_use_id")) or {}
|
|
752
|
+
inp = b.get("input") if isinstance(b.get("input"), dict) else {}
|
|
753
|
+
name = b["name"]
|
|
754
|
+
if name == "TaskCreate":
|
|
755
|
+
tid = link.get("task_id")
|
|
756
|
+
if tid is not None:
|
|
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 ""}
|
|
762
|
+
elif name == "TaskUpdate":
|
|
763
|
+
tid = str(inp.get("taskId")) if inp.get("taskId") is not None else link.get("task_id")
|
|
764
|
+
status = inp.get("status")
|
|
765
|
+
if tid is not None:
|
|
766
|
+
if status == "deleted":
|
|
767
|
+
th["state"].pop(tid, None)
|
|
768
|
+
elif tid in th["state"] and status:
|
|
769
|
+
th["state"][tid]["status"] = status
|
|
770
|
+
elif name == "TaskList":
|
|
771
|
+
snap = link.get("task_list")
|
|
772
|
+
if snap is not None:
|
|
773
|
+
th["seen"] = True
|
|
774
|
+
th["order"] = []
|
|
775
|
+
th["state"] = {}
|
|
776
|
+
for t in snap:
|
|
777
|
+
tid = t["id"]
|
|
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)
|
|
783
|
+
|
|
784
|
+
|
|
700
785
|
def _latest(logical, col):
|
|
701
786
|
"""Most-recent non-null value in a column across the session (project/branch
|
|
702
787
|
show the latest, matching the dashboard's session posture)."""
|