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 +11 -0
- package/bin/_cctally_cache.py +101 -10
- package/bin/_cctally_dashboard.py +22 -3
- package/bin/_lib_conversation.py +48 -24
- package/bin/_lib_conversation_query.py +37 -22
- package/package.json +1 -1
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
|
package/bin/_cctally_cache.py
CHANGED
|
@@ -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
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
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
|
-
#
|
|
7910
|
-
|
|
7911
|
-
|
|
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:
|
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"
|
|
@@ -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
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
313
|
-
if isinstance(
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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.
|
|
715
|
-
|
|
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
|
-
|
|
722
|
-
state = {}
|
|
732
|
+
threads = {} # subagent_key -> {"order": [...], "state": {...}, "seen": bool}
|
|
723
733
|
|
|
724
|
-
def snapshot():
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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
|
-
|
|
760
|
-
|
|
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
|
-
|
|
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.
|
|
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": {
|