cctally 1.31.1 → 1.33.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,22 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.33.0] - 2026-06-11
9
+
10
+ ### Added
11
+ - Dashboard conversation reader: URL deep-linking — selecting a conversation or landing a turn jump now reflects into the address-bar hash (`#/conversations/<id>/<turn>`), reloads and Back/Forward restore that state, and every conversation turn gains a permalink button that copies a link straight to that turn (local-first; the link only resolves for someone who can already reach your dashboard). Closes #169, #174.
12
+
13
+ ### Fixed
14
+ - **The dashboard conversation reader now nests a skill's content inside its Skill tool call.** Previously a Skill invocation's loaded markdown body rendered as a separate collapsed "Skill content" pill below the assistant turn (visually detached from the Skill tool chip that produced it). The body now folds into the Skill tool chip itself as its expandable, rich-Markdown content (the redundant "Launching skill: <name>" line is dropped); SessionStart-injected skills, which have no Skill tool call, still render the standalone pill. The fix lands on existing history automatically the next time the dashboard syncs the conversation cache (a one-time re-ingest; the cache is re-derivable).
15
+
16
+ ## [1.32.0] - 2026-06-10
17
+
18
+ ### Added
19
+ - Conversation reader: subagent thread cards now show the subagent's kind (`SUBAGENT · <kind>`) and its result meta (tokens, duration, tool count, status) on modern transcripts; old transcripts keep the title-only card (#166).
20
+
21
+ ### Fixed
22
+ - **The dashboard conversation reader no longer attributes injected content to you.** When an assistant turn invoked a skill (via the `Skill` tool), the skill's full markdown body rendered as a large **"You"** prompt, as if you had typed the entire skill into the conversation — and the same applied to other harness-injected (`isMeta`) lines (git-context blocks, "Continue from where you left off.", pasted-image placeholders, slash-command plumbing). These now render as quiet, collapsed-by-default disclosures attributed correctly: a skill body becomes a `Skill content · <name>` pill (still rendered with full Markdown — headings, lists, syntax-highlighted code — when expanded), and other injected content becomes a neutral `Injected context` pill. Nothing injected is ever shown as a "You" turn, and these bodies no longer pollute conversation titles or full-text search. The fix lands on existing history automatically the next time the dashboard syncs the conversation cache (a one-time re-ingest); the cache is re-derivable, so nothing is lost.
23
+
8
24
  ## [1.31.1] - 2026-06-09
9
25
 
10
26
  ### Added
@@ -183,8 +183,8 @@ _CONV_INSERT_SQL = (
183
183
  "INSERT OR IGNORE INTO conversation_messages"
184
184
  "(session_id,uuid,parent_uuid,source_path,byte_offset,"
185
185
  " timestamp_utc,entry_type,text,blocks_json,model,msg_id,"
186
- " req_id,cwd,git_branch,is_sidechain)"
187
- " VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"
186
+ " req_id,cwd,git_branch,is_sidechain,source_tool_use_id)"
187
+ " VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"
188
188
  )
189
189
 
190
190
 
@@ -194,6 +194,7 @@ def _conv_row_tuple(m, path_str):
194
194
  m.session_id, m.uuid, m.parent_uuid, path_str, m.byte_offset,
195
195
  m.timestamp_utc, m.entry_type, m.text, m.blocks_json, m.model,
196
196
  m.msg_id, m.req_id, m.cwd, m.git_branch, m.is_sidechain,
197
+ m.source_tool_use_id,
197
198
  )
198
199
 
199
200
 
@@ -615,9 +616,17 @@ def sync_cache(
615
616
  # Issue #164: a rebuild also clears + repopulates the message index
616
617
  # id-aware via the normal offset-0 walk, so drop the 003 reingest
617
618
  # flag too — the post-rebuild sync must not run a redundant
618
- # (idempotent but wasteful) clear+backfill pass.
619
+ # (idempotent but wasteful) clear+backfill pass. #166 migration 004
620
+ # also sets this same flag (to land the subagent kind/meta fields);
621
+ # the rebuild re-derives those fields via the same offset-0 walk, so
622
+ # dropping the flag here covers the 004 reingest too. Migration 006
623
+ # sets the DISTINCT conversation_source_tool_use_reingest_pending
624
+ # flag (to land source_tool_use_id); the same offset-0 walk re-derives
625
+ # it, so drop that flag here as well to avoid a redundant pass.
619
626
  conn.execute(
620
- "DELETE FROM cache_meta WHERE key='conversation_reingest_pending'")
627
+ "DELETE FROM cache_meta WHERE key IN "
628
+ "('conversation_reingest_pending',"
629
+ " 'conversation_source_tool_use_reingest_pending')")
621
630
  conn.commit()
622
631
  eprint("[cache-sync] rebuild: cleared Claude cached entries")
623
632
 
@@ -666,11 +675,25 @@ def sync_cache(
666
675
  # storm-free (#138); the offset-0 backfill walks every JSONL from 0;
667
676
  # the flag is dropped LAST so a crash mid-walk re-runs cleanly on the
668
677
  # next sync. Never on the rebuild path (which already wipes +
669
- # repopulates the index id-aware via the normal walk).
678
+ # repopulates the index id-aware via the normal walk). #166 migration
679
+ # 004 reuses this SAME flag (to land the spawn subagent_type + the
680
+ # record-level toolUseResult agentId/meta on existing history): the
681
+ # offset-0 backfill re-parses every JSONL through the current parser,
682
+ # so those fields land here with zero new consumption code. Migration
683
+ # 005 reuses it again to reclassify injected isMeta rows from
684
+ # entry_type='human' to 'meta' (so the reader stops attributing skill
685
+ # bodies / git-context to the user). Migration 006 uses a DISTINCT
686
+ # flag ``conversation_source_tool_use_reingest_pending`` (NOT the
687
+ # shared one) to land the message-level ``source_tool_use_id`` — the
688
+ # shared flag also gates the kernel's 005 human-fallback, so re-arming
689
+ # it for 006 could misclassify a genuine human prompt during the
690
+ # pre-reingest window. We trigger the SAME clear + offset-0 backfill on
691
+ # EITHER flag and clear BOTH atomically here under the held flock.
670
692
  try:
671
693
  _reingest = conn.execute(
672
- "SELECT 1 FROM cache_meta "
673
- "WHERE key='conversation_reingest_pending'"
694
+ "SELECT 1 FROM cache_meta WHERE key IN "
695
+ "('conversation_reingest_pending',"
696
+ " 'conversation_source_tool_use_reingest_pending')"
674
697
  ).fetchone() is not None
675
698
  except sqlite3.OperationalError:
676
699
  _reingest = False
@@ -678,8 +701,9 @@ def sync_cache(
678
701
  clear_conversation_messages(conn)
679
702
  backfill_conversation_messages(conn)
680
703
  conn.execute(
681
- "DELETE FROM cache_meta "
682
- "WHERE key='conversation_reingest_pending'"
704
+ "DELETE FROM cache_meta WHERE key IN "
705
+ "('conversation_reingest_pending',"
706
+ " 'conversation_source_tool_use_reingest_pending')"
683
707
  )
684
708
  conn.commit()
685
709
 
@@ -2331,6 +2331,7 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
2331
2331
  cwd TEXT,
2332
2332
  git_branch TEXT,
2333
2333
  is_sidechain INTEGER NOT NULL DEFAULT 0,
2334
+ source_tool_use_id TEXT,
2334
2335
  UNIQUE(source_path, byte_offset)
2335
2336
  );
2336
2337
  CREATE INDEX IF NOT EXISTS idx_conv_session_ts
@@ -2383,6 +2384,11 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
2383
2384
  # populated lazily in sync_cache() / _ensure_session_files_row().
2384
2385
  add_column_if_missing(conn, "session_files", "session_id", "TEXT")
2385
2386
  add_column_if_missing(conn, "session_files", "project_path", "TEXT")
2387
+ # Existing-DB guard for the skill-content fold link (cctally-dev
2388
+ # skill-content-nesting): the message-level sourceToolUseID. Idempotent
2389
+ # column-add (no marker, no version); cache migration 006 then re-ingests
2390
+ # so the value actually lands on historical rows.
2391
+ add_column_if_missing(conn, "conversation_messages", "source_tool_use_id", "TEXT")
2386
2392
  conn.execute(
2387
2393
  "CREATE INDEX IF NOT EXISTS idx_session_files_session_id "
2388
2394
  "ON session_files(session_id)"
@@ -3009,6 +3015,65 @@ def _003_conversation_reingest_tool_ids(conn: sqlite3.Connection) -> None:
3009
3015
  conn.commit()
3010
3016
 
3011
3017
 
3018
+ @cache_migration("004_conversation_reingest_subagent_kind")
3019
+ def _004_conversation_reingest_subagent_kind(conn: sqlite3.Connection) -> None:
3020
+ """Flag-only re-ingest of conversation_messages so the spawn ``subagent_type``
3021
+ and the record-level ``toolUseResult`` agentId/meta land on existing history
3022
+ (#166). REUSES 003's ``conversation_reingest_pending`` flag — sync_cache
3023
+ already consumes it (clear + offset-0 backfill under the cache.db.lock flock),
3024
+ and the offset-0 walk re-parses every JSONL through the current parser, so the
3025
+ new fields land with zero new consumption code. A distinct ``schema_migrations``
3026
+ marker is what triggers this reingest on an existing install that already has
3027
+ 003 applied; the flag is the generic 'conversation index needs a full
3028
+ clear+reingest' signal. Central stamp via the dispatcher (issue #140); a fresh
3029
+ install stamps it without running (empty table -> the flag, if ever set, is a
3030
+ harmless no-op)."""
3031
+ _set_cache_meta(conn, "conversation_reingest_pending", "1")
3032
+ conn.commit()
3033
+
3034
+
3035
+ @cache_migration("005_conversation_reingest_meta")
3036
+ def _005_conversation_reingest_meta(conn: sqlite3.Connection) -> None:
3037
+ """Flag-only re-ingest of conversation_messages so injected ``isMeta`` user
3038
+ lines (skill bodies, git-context, "Continue…", image placeholders,
3039
+ slash-command caveats) are reclassified from ``entry_type='human'`` to the
3040
+ new ``'meta'`` value and stop rendering as "YOU" prompts in the reader.
3041
+ REUSES 003's ``conversation_reingest_pending`` flag exactly like 004 — the
3042
+ offset-0 walk in sync_cache (clear + backfill under the cache.db.lock flock)
3043
+ re-parses every JSONL through the now-meta-aware parser, so the new
3044
+ classification lands with zero new consumption code. A distinct
3045
+ ``schema_migrations`` marker is what triggers this reingest on installs that
3046
+ already have 003/004 applied. Central stamp via the dispatcher (issue #140);
3047
+ a fresh install stamps it without running (empty table -> the flag, if ever
3048
+ set, is a harmless no-op)."""
3049
+ _set_cache_meta(conn, "conversation_reingest_pending", "1")
3050
+ conn.commit()
3051
+
3052
+
3053
+ @cache_migration("006_conversation_reingest_source_tool_use_id")
3054
+ def _006_conversation_reingest_source_tool_use_id(conn: sqlite3.Connection) -> None:
3055
+ """Flag-only re-ingest of conversation_messages so the message-level
3056
+ ``sourceToolUseID`` lands on existing history as the new
3057
+ ``source_tool_use_id`` column — the link the reader uses to fold a
3058
+ Skill-invoked skill body into its Skill tool chip.
3059
+
3060
+ Sets a DISTINCT flag, ``conversation_source_tool_use_reingest_pending``,
3061
+ NOT the shared ``conversation_reingest_pending``. The shared flag also gates
3062
+ migration 005's read-time *human*-fallback (``_reingest_pending`` in the
3063
+ query kernel); re-arming it here would re-enable that fallback after it was
3064
+ consumed and could misclassify a genuine human prompt that happens to start
3065
+ with the skill preamble as a collapsed skill pill during 006's pre-reingest
3066
+ window. sync_cache consumes EITHER flag (clear + offset-0 backfill under the
3067
+ cache.db.lock flock) and the offset-0 walk re-parses every JSONL through the
3068
+ sourceToolUseID-aware parser, so the column lands with zero new consumption
3069
+ code. A distinct ``schema_migrations`` marker is what triggers this reingest
3070
+ on installs already at 005. Central stamp via the dispatcher (issue #140); a
3071
+ fresh install stamps it without running (empty table -> the flag, if ever
3072
+ set, is a harmless no-op)."""
3073
+ _set_cache_meta(conn, "conversation_source_tool_use_reingest_pending", "1")
3074
+ conn.commit()
3075
+
3076
+
3012
3077
  # === Region 7d: Stats migration 008_recompute_weekly_cost_snapshots_dedup_fix ===
3013
3078
 
3014
3079
  @stats_migration("008_recompute_weekly_cost_snapshots_dedup_fix")
@@ -13,6 +13,7 @@ from dataclasses import dataclass
13
13
  HUMAN = "human"
14
14
  ASSISTANT = "assistant"
15
15
  TOOL_RESULT = "tool_result"
16
+ META = "meta"
16
17
 
17
18
  _TOOL_RESULT_CAP = 4000 # chars; full text always re-derivable from JSONL
18
19
 
@@ -33,6 +34,7 @@ class MessageRow:
33
34
  cwd: "str | None"
34
35
  git_branch: "str | None"
35
36
  is_sidechain: int
37
+ source_tool_use_id: "str | None" = None
36
38
 
37
39
 
38
40
  def iter_message_rows(fh, path_str):
@@ -91,11 +93,26 @@ def _normalize(obj, t, offset):
91
93
  entry_type = ASSISTANT
92
94
  elif any(b["kind"] == "tool_result" for b in blocks):
93
95
  entry_type = TOOL_RESULT
96
+ _attach_subagent_result(blocks, obj) # #166: record-level toolUseResult
94
97
  # tool_result rows are stored but NOT indexed as prose (spec §2). A
95
98
  # user line that mixes a text block with a tool_result block must not
96
99
  # leak that text into the FTS index; the full content stays in
97
100
  # blocks_json for rendering.
98
101
  text = ""
102
+ elif obj.get("isMeta"):
103
+ # Injected, harness-authored content carried as a user line: skill
104
+ # bodies (Skill tool + SessionStart), git-context blocks, "Continue
105
+ # from where you left off.", pasted-image placeholders, slash-command
106
+ # caveats, check-review "## Task" blocks. The user did NOT type these,
107
+ # so the reader must not render them as a "YOU" prompt. We classify
108
+ # them META here; text="" keeps the body out of the FTS index and out
109
+ # of title derivation (which filters entry_type='human'), exactly like
110
+ # tool_result. The body survives in blocks_json; the skill-vs-context
111
+ # discrimination is a read-time concern (the query kernel, keyed on the
112
+ # body). Ordered AFTER tool_result so an isMeta line that also carries a
113
+ # tool_result block still folds as a result.
114
+ entry_type = META
115
+ text = ""
99
116
  else:
100
117
  entry_type = HUMAN
101
118
  is_asst = t == "assistant"
@@ -114,6 +131,7 @@ def _normalize(obj, t, offset):
114
131
  cwd=obj.get("cwd"),
115
132
  git_branch=obj.get("gitBranch"),
116
133
  is_sidechain=1 if obj.get("isSidechain") else 0,
134
+ source_tool_use_id=obj.get("sourceToolUseID"),
117
135
  )
118
136
 
119
137
 
@@ -135,10 +153,15 @@ def _blocks_and_text(content):
135
153
  elif bt == "thinking":
136
154
  blocks.append({"kind": "thinking", "text": b.get("thinking", "") or ""})
137
155
  elif bt == "tool_use":
138
- blocks.append({"kind": "tool_use", "name": b.get("name"),
139
- "input_summary": _summarize(b.get("input")),
140
- "id": b.get("id"),
141
- "preview": tool_preview(b.get("name"), b.get("input"))})
156
+ block = {"kind": "tool_use", "name": b.get("name"),
157
+ "input_summary": _summarize(b.get("input")),
158
+ "id": b.get("id"),
159
+ "preview": tool_preview(b.get("name"), b.get("input"))}
160
+ inp = b.get("input")
161
+ st = inp.get("subagent_type") if isinstance(inp, dict) else None
162
+ if isinstance(st, str) and st: # #166: spawn kind (Agent/Task)
163
+ block["subagent_type"] = st
164
+ blocks.append(block)
142
165
  elif bt == "tool_result":
143
166
  raw = _stringify(b.get("content"))
144
167
  blocks.append({"kind": "tool_result", "text": raw[:_TOOL_RESULT_CAP],
@@ -152,6 +175,43 @@ def _blocks_and_text(content):
152
175
  return blocks, "\n".join(t for t in texts if t)
153
176
 
154
177
 
178
+ _SUBAGENT_META_KEYS = (
179
+ ("totalTokens", "total_tokens"),
180
+ ("totalDurationMs", "total_duration_ms"),
181
+ ("totalToolUseCount", "total_tool_use_count"),
182
+ ("status", "status"),
183
+ )
184
+
185
+
186
+ def _attach_subagent_result(blocks, obj):
187
+ """Attach the record-level ``toolUseResult`` agentId + meta (#166) onto the
188
+ tool_result block, but ONLY when the record carries exactly one tool_result
189
+ block — the unambiguous subagent-spawn result shape. Zero or >1 tool_result
190
+ blocks: no-op (the kernel then degrades that subagent card to title-only).
191
+ The kind (subagent_type) is captured separately on the spawn tool_use block;
192
+ the kernel joins the two on tool_use_id. ``agentId`` == the subagent file's
193
+ ``_subagent_key``. Meta keys are normalized to snake_case here so the kernel
194
+ stays a pure pass-through (same posture as is_error / tool_use_id)."""
195
+ tur = obj.get("toolUseResult")
196
+ if not isinstance(tur, dict):
197
+ return
198
+ agent_id = tur.get("agentId")
199
+ if not isinstance(agent_id, str) or not agent_id:
200
+ return
201
+ results = [b for b in blocks if b.get("kind") == "tool_result"]
202
+ if len(results) != 1:
203
+ return
204
+ block = results[0]
205
+ block["agent_id"] = agent_id
206
+ meta = {}
207
+ for src, dst in _SUBAGENT_META_KEYS:
208
+ v = tur.get(src)
209
+ if v is not None:
210
+ meta[dst] = v
211
+ if meta:
212
+ block["subagent_meta"] = meta
213
+
214
+
155
215
  def _stringify(c):
156
216
  if isinstance(c, str):
157
217
  return c
@@ -55,6 +55,84 @@ def _title_from_text(text) -> str:
55
55
  return ""
56
56
 
57
57
 
58
+ # Every Claude Code skill body (Skill-tool-invoked AND SessionStart-injected)
59
+ # opens with this preamble line — the entry_type-independent skill discriminator.
60
+ _SKILL_PREAMBLE = "Base directory for this skill:"
61
+
62
+
63
+ def _first_nonblank_line(text) -> str:
64
+ """First non-blank, stripped line of `text` ('' if none). Skill detection
65
+ keys on this (NOT a strict body.startswith) so a leading blank text block
66
+ can't hide the preamble (Codex P2.2)."""
67
+ for line in (text or "").split("\n"):
68
+ s = line.strip()
69
+ if s:
70
+ return s
71
+ return ""
72
+
73
+
74
+ def _skill_name_from_preamble(first_line) -> "str | None":
75
+ """`brainstorming` from `Base directory for this skill: …/skills/brainstorming`.
76
+ Basename of the path after the first ':'; None on an empty/degenerate path
77
+ (Codex P2.2) so the client renders a name-less 'Skill content' rather than a
78
+ dangling separator."""
79
+ _, _, rest = first_line.partition(":")
80
+ path = rest.strip().rstrip("/")
81
+ return os.path.basename(path) or None if path else None
82
+
83
+
84
+ def _join_text_blocks(blocks) -> str:
85
+ """Rejoin a row's text-block bodies the way the parser's _blocks_and_text did
86
+ ('\\n'-joined). A true meta row carries text='' (parser) with the body here in
87
+ blocks; a not-yet-reingested human row carries the body in its text column —
88
+ _meta_classify reads whichever is populated."""
89
+ if not blocks:
90
+ return ""
91
+ return "\n".join(b.get("text", "") or "" for b in blocks if b.get("kind") == "text")
92
+
93
+
94
+ def _reingest_pending(conn) -> bool:
95
+ """True iff migration 005's ``conversation_reingest_pending`` flag is still
96
+ set — i.e. existing history has NOT yet been re-ingested under the meta-aware
97
+ parser. While pending, a stale ``human`` row may actually be an injected
98
+ skill body, so the read-time skill fallback (rendering + title-skip) is
99
+ active. Once sync consumes the flag (skill bodies become true ``meta`` rows),
100
+ the fallback turns OFF — so a genuine human prompt that merely *starts with*
101
+ the skill preamble is never misclassified as a collapsed skill pill (Codex
102
+ code-review P1). Missing table / degraded DB -> treated as not pending."""
103
+ try:
104
+ return conn.execute(
105
+ "SELECT 1 FROM cache_meta WHERE key='conversation_reingest_pending'"
106
+ ).fetchone() is not None
107
+ except sqlite3.OperationalError:
108
+ return False
109
+
110
+
111
+ def _meta_classify(item, allow_human_fallback):
112
+ """Classify an injected item by its BODY, returning ``(meta_kind, skill_name,
113
+ body)`` or ``None`` to leave it a genuine human turn.
114
+
115
+ - skill: first non-blank line is the skill preamble. Fires for a true 'meta'
116
+ row ALWAYS; for a 'human' row ONLY when ``allow_human_fallback`` is set (the
117
+ pre-reingest window — see _reingest_pending). After the reingest a 'human'
118
+ row keeping the preamble is a real user prompt, so it stays a "You" turn
119
+ rather than being hidden in a collapsed skill pill (Codex code-review P1).
120
+ - command/context: ONLY for a true 'meta' row (slash-command plumbing vs the
121
+ rest). A 'human' row that is not a skill body stays human — generic injected
122
+ context can't be recovered read-time without isMeta; it lands on the next
123
+ sync-triggered reingest."""
124
+ is_meta = item["kind"] == "meta"
125
+ body = item.get("text") or _join_text_blocks(item.get("blocks"))
126
+ first = _first_nonblank_line(body)
127
+ if first.startswith(_SKILL_PREAMBLE) and (is_meta or allow_human_fallback):
128
+ return ("skill", _skill_name_from_preamble(first), body)
129
+ if not is_meta:
130
+ return None
131
+ if _is_system_marker(body):
132
+ return ("command", None, body)
133
+ return ("context", None, body)
134
+
135
+
58
136
  def _session_titles_map(conn, session_ids):
59
137
  """{sid: title} for the first non-marker, non-blank MAIN-session human line
60
138
  per session (read-time, no migration). Windowed to the earliest 12 human
@@ -68,6 +146,14 @@ def _session_titles_map(conn, session_ids):
68
146
  if not session_ids:
69
147
  return {}
70
148
  titles = {}
149
+ # While 005's reingest is pending, a stale `human` row may actually be an
150
+ # injected skill body (a SessionStart skill can even lead the transcript) —
151
+ # skip those as title candidates so the rail never shows "Base directory for
152
+ # this skill: …" until the next sync reclassifies them to `meta` (which the
153
+ # entry_type='human' filter below then excludes). Gated on the flag for the
154
+ # same reason as the render fallback: a genuine post-reingest human prompt
155
+ # starting with the preamble stays a normal title (Codex code-review P2).
156
+ skip_skill_titles = _reingest_pending(conn)
71
157
  ph = ",".join("?" for _ in session_ids)
72
158
  rows = conn.execute(
73
159
  "SELECT session_id, text FROM ("
@@ -85,6 +171,8 @@ def _session_titles_map(conn, session_ids):
85
171
  continue # already resolved to the first non-marker
86
172
  if _is_system_marker(text):
87
173
  continue
174
+ if skip_skill_titles and _first_nonblank_line(text).startswith(_SKILL_PREAMBLE):
175
+ continue
88
176
  t = _title_from_text(text)
89
177
  if t:
90
178
  titles[sid] = t
@@ -289,7 +377,8 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
289
377
  # uuid, so the first occurrence in ascending order is canonical.
290
378
  raw = conn.execute(
291
379
  "SELECT id, uuid, timestamp_utc, entry_type, text, blocks_json, model, "
292
- " msg_id, req_id, is_sidechain, cwd, git_branch, source_path, parent_uuid "
380
+ " msg_id, req_id, is_sidechain, cwd, git_branch, source_path, parent_uuid, "
381
+ " source_tool_use_id "
293
382
  "FROM conversation_messages WHERE session_id=? "
294
383
  "ORDER BY timestamp_utc, id", (session_id,)).fetchall()
295
384
 
@@ -333,7 +422,7 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
333
422
 
334
423
  for row in logical:
335
424
  (rid, u, ts, etype, text, blocks, model, msg_id, req_id,
336
- is_sc, cwd, branch, source_path, parent_uuid) = row
425
+ is_sc, cwd, branch, source_path, parent_uuid, source_tool_use_id) = row
337
426
  if etype == "assistant" and msg_id is not None:
338
427
  key = (msg_id, req_id)
339
428
  idx = turn_index.get(key)
@@ -355,6 +444,37 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
355
444
  if etype == "assistant": # null-msg_id assistant: index its uses too
356
445
  _index_tool_uses(it)
357
446
 
447
+ # ---- Subagent-kind correlation (#166); MUST run before Phase 2 fold ----
448
+ # Reads AND strips (pop) the parser-only keys in one pass, so the returned
449
+ # tool_call/tool_result block shapes are unchanged — the only new output is
450
+ # the top-level subagent_meta map (no undocumented block keys leak). Join is
451
+ # spawn tool_use id <-> tool_result tool_use_id; agent_id == subagent_key.
452
+ spawn_kind = {} # tool_use id -> subagent_type
453
+ agent_link = {} # tool_use id -> (agent_id, raw_meta)
454
+ for it in items:
455
+ for b in it["blocks"]:
456
+ k = b.get("kind")
457
+ if k == "tool_use":
458
+ st = b.pop("subagent_type", None)
459
+ if st and b.get("id") is not None:
460
+ spawn_kind[b["id"]] = st
461
+ elif k == "tool_result":
462
+ aid = b.pop("agent_id", None)
463
+ meta = b.pop("subagent_meta", None)
464
+ if aid and b.get("tool_use_id") is not None:
465
+ agent_link[b["tool_use_id"]] = (aid, meta or {})
466
+ subagent_meta = {}
467
+ for _tuid, _kind in spawn_kind.items():
468
+ _link = agent_link.get(_tuid)
469
+ if _link is None:
470
+ continue # spawn with no (yet) result -> title-only
471
+ _aid, _raw = _link
472
+ _entry = {"kind": _kind}
473
+ for _f in ("total_tokens", "total_duration_ms", "total_tool_use_count", "status"):
474
+ if _raw.get(_f) is not None:
475
+ _entry[_f] = _raw[_f]
476
+ subagent_meta[_aid] = _entry # agent_id == subagent_key
477
+
358
478
  # ---- Phase 2: fold each tool_result item into its owning assistant item ----
359
479
  drop = set() # id() of folded placeholder items
360
480
  for tr in tool_result_items:
@@ -398,6 +518,57 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
398
518
  b["tool_use_id"] = b.pop("id", None)
399
519
  b.setdefault("result", None)
400
520
 
521
+ # ---- Phase 4: classify injected meta items (skill / command / context) ----
522
+ # `meta` rows (the parser's isMeta classification) AND — only while the 005
523
+ # reingest is still pending — not-yet-reingested `human` rows whose body is a
524
+ # skill preamble (the read-time fallback) become kind='meta' with a meta_kind
525
+ # + skill_name, so the client renders a collapsed skill/system-marker/context
526
+ # disclosure instead of a "YOU" prompt. `text` is set to the rendered body
527
+ # (the DB text column stays '' for FTS); genuine human turns are untouched.
528
+ allow_human_fallback = _reingest_pending(conn)
529
+ for it in items:
530
+ if it["kind"] in ("meta", "human"):
531
+ cls = _meta_classify(it, allow_human_fallback)
532
+ if cls is not None:
533
+ meta_kind, skill_name, body = cls
534
+ it["kind"] = "meta"
535
+ it["meta_kind"] = meta_kind
536
+ it["skill_name"] = skill_name
537
+ it["text"] = body
538
+
539
+ # ---- Phase 4b: fold a Skill-invoked skill body into its Skill tool chip ----
540
+ # A Skill invocation's injected body (now meta_kind='skill') links to its
541
+ # Skill tool_use via source_tool_use_id (threaded as the internal
542
+ # _source_tool_use_id). Resolve it against the SAME tooluse_index the Phase 2
543
+ # tool_result fold uses (ids unique per session; last-writer-wins). The index
544
+ # value is (item, block) holding the LIVE block dict — Phase 3 mutated that
545
+ # same dict in place to a `tool_call`, so block["skill_body"]=… mutates the
546
+ # live chip. On a hit: the body becomes the chip's expandable content
547
+ # (skill_body/skill_name), the trivial "Launching skill" result is dropped
548
+ # (result=None), the body uuid joins the owner's member_uuids (#160 jump
549
+ # anchor), and the standalone item is removed. NO hit (SessionStart skills;
550
+ # pre-006 NULL column; orphan id) -> the standalone pill stays. NULL-driven
551
+ # and flag-INDEPENDENT (it does NOT key on _reingest_pending). Runs before
552
+ # pagination so a match never depends on page boundaries.
553
+ _skill_drop = set()
554
+ for it in items:
555
+ if it.get("meta_kind") != "skill":
556
+ continue
557
+ stid = it.get("_source_tool_use_id")
558
+ if not stid:
559
+ continue
560
+ hit = tooluse_index.get(stid)
561
+ if hit is None:
562
+ continue
563
+ owner, block = hit
564
+ block["skill_body"] = it["text"]
565
+ block["skill_name"] = it.get("skill_name")
566
+ block["result"] = None
567
+ owner["member_uuids"].append(it["anchor"]["uuid"])
568
+ _skill_drop.add(id(it))
569
+ if _skill_drop:
570
+ items = [it for it in items if id(it) not in _skill_drop]
571
+
401
572
  costs = _turn_cost_map(conn, list(turn_index))
402
573
  # Stamp per-item cost first, then derive the header from the SUM of the
403
574
  # ROUNDED per-item assistant costs (M2) — so the §6.5 invariant
@@ -416,6 +587,12 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
416
587
  it.pop("_has_prose", None)
417
588
  header_cost = round(header_cost, 6)
418
589
 
590
+ # Strip the internal Phase-4b threading key from EVERY item (meta/human items
591
+ # carry it too, not just assistant turns) so it never surfaces in the public
592
+ # item JSON.
593
+ for it in items:
594
+ it.pop("_source_tool_use_id", None)
595
+
419
596
  # Cursor pagination over the item list (anchored to each item's canonical id).
420
597
  # A non-None `after` that matches no item's anchor (stale/deleted cursor)
421
598
  # yields an EMPTY page — never silently re-serves the head (M1).
@@ -436,6 +613,7 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
436
613
  "cost_usd": header_cost,
437
614
  "models": sorted({r[6] for r in logical if r[6]}),
438
615
  "items": [],
616
+ "subagent_meta": subagent_meta,
439
617
  "page": {"next_after": None, "has_more": False},
440
618
  }
441
619
  page = items[start:start + limit]
@@ -460,6 +638,7 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
460
638
  "cost_usd": header_cost,
461
639
  "models": models,
462
640
  "items": page,
641
+ "subagent_meta": subagent_meta,
463
642
  "page": {"next_after": next_after, "has_more": has_more},
464
643
  }
465
644
 
@@ -497,6 +676,10 @@ def _build_turn(members):
497
676
  "parent_uuid": first[13],
498
677
  "_msg_id": first[7],
499
678
  "_req_id": first[8],
679
+ # Internal threading for the Phase 4b skill-body fold (analogous to
680
+ # _msg_id/_req_id); consumed + del'd before items are returned, never in
681
+ # the public JSON. Meaningful only on meta skill items, harmless here.
682
+ "_source_tool_use_id": first[14],
500
683
  "_has_prose": False,
501
684
  }
502
685
  _fold_fragment(item, first)
@@ -543,7 +726,7 @@ def _build_simple(row):
543
726
  internal _msg_id/_req_id keys, so the cost loop's KeyError path can never fire
544
727
  (I2). The model is preserved for assistant rows."""
545
728
  (rid, u, ts, etype, text, blocks, model, msg_id, req_id, is_sc, cwd, branch,
546
- source_path, parent_uuid) = row
729
+ source_path, parent_uuid, source_tool_use_id) = row
547
730
  try:
548
731
  parsed = _json.loads(blocks or "[]")
549
732
  except (ValueError, TypeError):
@@ -558,6 +741,10 @@ def _build_simple(row):
558
741
  "is_sidechain": bool(is_sc),
559
742
  "subagent_key": _subagent_key(source_path),
560
743
  "parent_uuid": parent_uuid,
744
+ # Internal threading for the Phase 4b skill-body fold (consumed + del'd
745
+ # before return). Carried on every simple item; meaningful only on the
746
+ # meta skill body row.
747
+ "_source_tool_use_id": source_tool_use_id,
561
748
  }
562
749
  if etype == "assistant":
563
750
  item["model"] = model