cctally 1.37.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,11 @@ 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
+
8
13
  ## [1.37.0] - 2026-06-11
9
14
 
10
15
  ### Added
@@ -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.1",
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": {