@humwick/runner 0.0.0-stage → 0.130.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.
Files changed (293) hide show
  1. package/LICENSE +167 -0
  2. package/agent-bin/agent-relay-browser +10 -0
  3. package/agent-bin/humwick +10 -0
  4. package/hooks/fff-search-redirect/fff-search-redirect.cjs +161 -0
  5. package/hooks/fff-search-redirect/hooks.json +13 -0
  6. package/hooks/rg-r-guard/hooks.json +18 -0
  7. package/hooks/rg-r-guard/rg-r-guard.cjs +144 -0
  8. package/hooks/web-tools-redirect/hooks.json +13 -0
  9. package/hooks/web-tools-redirect/web-tools-redirect.cjs +84 -0
  10. package/node_modules/@humwick/offload/LICENSE +167 -0
  11. package/node_modules/@humwick/offload/README.md +57 -0
  12. package/node_modules/@humwick/offload/dist/async-runner.d.ts +18 -0
  13. package/node_modules/@humwick/offload/dist/async-runner.d.ts.map +1 -0
  14. package/node_modules/@humwick/offload/dist/async-runner.js +158 -0
  15. package/node_modules/@humwick/offload/dist/async-runner.js.map +1 -0
  16. package/node_modules/@humwick/offload/dist/envelope.d.ts +25 -0
  17. package/node_modules/@humwick/offload/dist/envelope.d.ts.map +1 -0
  18. package/node_modules/@humwick/offload/dist/envelope.js +80 -0
  19. package/node_modules/@humwick/offload/dist/envelope.js.map +1 -0
  20. package/node_modules/@humwick/offload/dist/fixtures/worker-task.d.ts +10 -0
  21. package/node_modules/@humwick/offload/dist/fixtures/worker-task.d.ts.map +1 -0
  22. package/node_modules/@humwick/offload/dist/fixtures/worker-task.js +59 -0
  23. package/node_modules/@humwick/offload/dist/fixtures/worker-task.js.map +1 -0
  24. package/node_modules/@humwick/offload/dist/index.d.ts +5 -0
  25. package/node_modules/@humwick/offload/dist/index.d.ts.map +1 -0
  26. package/node_modules/@humwick/offload/dist/index.js +5 -0
  27. package/node_modules/@humwick/offload/dist/index.js.map +1 -0
  28. package/node_modules/@humwick/offload/dist/subprocess-runner.d.ts +62 -0
  29. package/node_modules/@humwick/offload/dist/subprocess-runner.d.ts.map +1 -0
  30. package/node_modules/@humwick/offload/dist/subprocess-runner.js +295 -0
  31. package/node_modules/@humwick/offload/dist/subprocess-runner.js.map +1 -0
  32. package/node_modules/@humwick/offload/dist/types.d.ts +111 -0
  33. package/node_modules/@humwick/offload/dist/types.d.ts.map +1 -0
  34. package/node_modules/@humwick/offload/dist/types.js +176 -0
  35. package/node_modules/@humwick/offload/dist/types.js.map +1 -0
  36. package/node_modules/@humwick/offload/dist/worker-pool.d.ts +23 -0
  37. package/node_modules/@humwick/offload/dist/worker-pool.d.ts.map +1 -0
  38. package/node_modules/@humwick/offload/dist/worker-pool.js +435 -0
  39. package/node_modules/@humwick/offload/dist/worker-pool.js.map +1 -0
  40. package/node_modules/@humwick/offload/dist/worker-runtime.d.ts +2 -0
  41. package/node_modules/@humwick/offload/dist/worker-runtime.d.ts.map +1 -0
  42. package/node_modules/@humwick/offload/dist/worker-runtime.js +27 -0
  43. package/node_modules/@humwick/offload/dist/worker-runtime.js.map +1 -0
  44. package/node_modules/@humwick/offload/dist/worker.d.ts +5 -0
  45. package/node_modules/@humwick/offload/dist/worker.d.ts.map +1 -0
  46. package/node_modules/@humwick/offload/dist/worker.js +1 -0
  47. package/node_modules/@humwick/offload/dist/worker.js.map +1 -0
  48. package/node_modules/@humwick/offload/package.json +46 -0
  49. package/node_modules/@humwick/offload/src/async-runner.ts +170 -0
  50. package/node_modules/@humwick/offload/src/envelope.ts +92 -0
  51. package/node_modules/@humwick/offload/src/fixtures/worker-task.ts +49 -0
  52. package/node_modules/@humwick/offload/src/index.ts +4 -0
  53. package/node_modules/@humwick/offload/src/subprocess-runner.ts +334 -0
  54. package/node_modules/@humwick/offload/src/types.ts +235 -0
  55. package/node_modules/@humwick/offload/src/worker-pool.ts +463 -0
  56. package/node_modules/@humwick/offload/src/worker-runtime.ts +32 -0
  57. package/node_modules/@humwick/offload/src/worker.ts +5 -0
  58. package/package.json +47 -4
  59. package/plugins/browser/skills/playwright-cli/SKILL.md +53 -0
  60. package/plugins/claude/.claude-plugin/plugin.json +8 -0
  61. package/plugins/claude/claude-statusline-probe.cjs +144 -0
  62. package/plugins/claude/hooks/gate-run-guard.provisioned.mjs +914 -0
  63. package/plugins/claude/hooks/gate-run-guard.sh +9 -0
  64. package/plugins/claude/hooks/gate-run-guard.ts +237 -0
  65. package/plugins/claude/hooks/hooks.json +158 -0
  66. package/plugins/claude/hooks/kill-guard.provisioned.mjs +7027 -0
  67. package/plugins/claude/hooks/kill-guard.sh +341 -0
  68. package/plugins/claude/hooks/kill-guard.ts +337 -0
  69. package/plugins/claude/hooks/permission-request.sh +22 -0
  70. package/plugins/claude/hooks/post-compact.sh +10 -0
  71. package/plugins/claude/hooks/post-tool-use.sh +8 -0
  72. package/plugins/claude/hooks/pre-compact.sh +12 -0
  73. package/plugins/claude/hooks/read-only-bash-guard.sh +587 -0
  74. package/plugins/claude/hooks/relay-status.sh +373 -0
  75. package/plugins/claude/hooks/session-end.sh +26 -0
  76. package/plugins/claude/hooks/session-start.sh +31 -0
  77. package/plugins/claude/hooks/shell-trust.ts +28 -0
  78. package/plugins/claude/hooks/stop-failure.sh +47 -0
  79. package/plugins/claude/hooks/stop.sh +48 -0
  80. package/plugins/claude/hooks/subagent-guard.cjs +26 -0
  81. package/plugins/claude/hooks/subagent-skill-policy.cjs +160 -0
  82. package/plugins/claude/hooks/subagent-start.sh +13 -0
  83. package/plugins/claude/hooks/subagent-stop.sh +19 -0
  84. package/plugins/claude/hooks/user-prompt-submit.sh +18 -0
  85. package/plugins/claude/hooks/wait-loop-guard.ts +120 -0
  86. package/plugins/claude/monitors/monitors.json +7 -0
  87. package/plugins/claude/monitors/relay-monitor.provisioned.mjs +786 -0
  88. package/plugins/claude/monitors/relay-monitor.ts +58 -0
  89. package/plugins/claude/skills/disconnect/SKILL.md +16 -0
  90. package/plugins/claude/skills/label/SKILL.md +23 -0
  91. package/plugins/claude/skills/message/SKILL.md +40 -0
  92. package/plugins/claude/skills/pair/SKILL.md +30 -0
  93. package/plugins/claude/skills/react/SKILL.md +18 -0
  94. package/plugins/claude/skills/read-message/SKILL.md +24 -0
  95. package/plugins/claude/skills/reply/SKILL.md +44 -0
  96. package/plugins/claude/skills/send-claimable/SKILL.md +39 -0
  97. package/plugins/claude/skills/slice-first/SKILL.md +32 -0
  98. package/plugins/claude/skills/slice-first/references/plan-recipe.md +46 -0
  99. package/plugins/claude/skills/status/SKILL.md +16 -0
  100. package/plugins/claude/skills/tags/SKILL.md +25 -0
  101. package/plugins/claude/skills/task/SKILL.md +28 -0
  102. package/plugins/claude-output-style/.claude-plugin/plugin.json +5 -0
  103. package/plugins/claude-output-style/output-styles/agent-relay.md +5 -0
  104. package/plugins/codex/skills/disconnect/SKILL.md +15 -0
  105. package/plugins/codex/skills/guide/SKILL.md +15 -0
  106. package/plugins/codex/skills/label/SKILL.md +22 -0
  107. package/plugins/codex/skills/message/SKILL.md +40 -0
  108. package/plugins/codex/skills/pair/SKILL.md +29 -0
  109. package/plugins/codex/skills/react/SKILL.md +17 -0
  110. package/plugins/codex/skills/read-message/SKILL.md +23 -0
  111. package/plugins/codex/skills/reply/SKILL.md +44 -0
  112. package/plugins/codex/skills/send-claimable/SKILL.md +39 -0
  113. package/plugins/codex/skills/slice-first/SKILL.md +32 -0
  114. package/plugins/codex/skills/slice-first/references/plan-recipe.md +46 -0
  115. package/plugins/codex/skills/status/SKILL.md +15 -0
  116. package/plugins/codex/skills/tags/SKILL.md +24 -0
  117. package/plugins/codex/skills/task/SKILL.md +28 -0
  118. package/src/adapter.ts +1061 -0
  119. package/src/adapters/__fixtures__/codex-app-server.ts +47 -0
  120. package/src/adapters/claude-api.ts +243 -0
  121. package/src/adapters/claude-context-command.ts +16 -0
  122. package/src/adapters/claude-credentials.ts +22 -0
  123. package/src/adapters/claude-delivery-lanes.ts +28 -0
  124. package/src/adapters/claude-delivery.ts +166 -0
  125. package/src/adapters/claude-effort.ts +94 -0
  126. package/src/adapters/claude-exit.ts +290 -0
  127. package/src/adapters/claude-headless-mirror.ts +88 -0
  128. package/src/adapters/claude-headless-refusal.ts +51 -0
  129. package/src/adapters/claude-headless.ts +617 -0
  130. package/src/adapters/claude-monitor-delivery-plan.ts +317 -0
  131. package/src/adapters/claude-permission.ts +243 -0
  132. package/src/adapters/claude-prompt-gates.ts +269 -0
  133. package/src/adapters/claude-prompt-hook.ts +145 -0
  134. package/src/adapters/claude-session-capture.ts +293 -0
  135. package/src/adapters/claude-session-probe.ts +414 -0
  136. package/src/adapters/claude-shutdown.ts +190 -0
  137. package/src/adapters/claude-status-detectors.ts +426 -0
  138. package/src/adapters/claude-subagents.ts +93 -0
  139. package/src/adapters/claude-submit-confirmation.ts +287 -0
  140. package/src/adapters/claude-tmux.ts +548 -0
  141. package/src/adapters/claude-token-usage-capture.ts +253 -0
  142. package/src/adapters/claude-tool-policy.ts +165 -0
  143. package/src/adapters/claude-transcript-tail.ts +193 -0
  144. package/src/adapters/claude-transcript.ts +812 -0
  145. package/src/adapters/claude.ts +1097 -0
  146. package/src/adapters/codex-agent-message-capture.ts +189 -0
  147. package/src/adapters/codex-app-server.ts +70 -0
  148. package/src/adapters/codex-client.ts +362 -0
  149. package/src/adapters/codex-effective-config.ts +87 -0
  150. package/src/adapters/codex-launch-config.ts +153 -0
  151. package/src/adapters/codex-rollout-context.ts +272 -0
  152. package/src/adapters/codex-skill-discovery.ts +18 -0
  153. package/src/adapters/codex-token-usage-capture.ts +163 -0
  154. package/src/adapters/codex-tool-output.ts +53 -0
  155. package/src/adapters/codex-turn-outcome.ts +27 -0
  156. package/src/adapters/codex-version.ts +64 -0
  157. package/src/adapters/codex.ts +1442 -0
  158. package/src/adapters/incremental-file.ts +110 -0
  159. package/src/adapters/opencode-api.ts +71 -0
  160. package/src/adapters/opencode-context.ts +277 -0
  161. package/src/adapters/opencode-model-registry.ts +126 -0
  162. package/src/adapters/opencode-provider.ts +169 -0
  163. package/src/adapters/opencode-server-retry.ts +50 -0
  164. package/src/adapters/opencode-soft-steer.ts +302 -0
  165. package/src/adapters/opencode-spawn.ts +145 -0
  166. package/src/adapters/opencode-subscribe.ts +62 -0
  167. package/src/adapters/opencode-token-usage-capture.ts +187 -0
  168. package/src/adapters/opencode-tool-events.ts +302 -0
  169. package/src/adapters/opencode-tools-config.ts +17 -0
  170. package/src/adapters/opencode.ts +2264 -0
  171. package/src/adapters/project-shell.ts +80 -0
  172. package/src/adapters/provider-launch-config.ts +21 -0
  173. package/src/adapters/provider-quota-harvest.ts +127 -0
  174. package/src/adapters/service-access.ts +153 -0
  175. package/src/adapters/tmux-offload.ts +207 -0
  176. package/src/adapters/tool-summary.ts +214 -0
  177. package/src/agent-redact.ts +75 -0
  178. package/src/agent-runner/commands/dispatch.ts +185 -0
  179. package/src/agent-runner/commands/lifecycle.ts +84 -0
  180. package/src/agent-runner/commands/prompt.ts +284 -0
  181. package/src/agent-runner/credentials.ts +208 -0
  182. package/src/agent-runner/http-liveness.ts +85 -0
  183. package/src/agent-runner/lifecycle-milestones.ts +207 -0
  184. package/src/agent-runner/lifecycle-phase.ts +80 -0
  185. package/src/agent-runner/registration.ts +163 -0
  186. package/src/agent-runner/runner-context.ts +158 -0
  187. package/src/agent-runner/session-mirror.ts +278 -0
  188. package/src/agent-runner/task-claims.ts +116 -0
  189. package/src/agent-runner/token-usage-coverage.ts +54 -0
  190. package/src/agent-runner/turn-report.ts +269 -0
  191. package/src/api-budget-accounting.ts +91 -0
  192. package/src/area-memory.ts +255 -0
  193. package/src/artifact-path-materialize.ts +52 -0
  194. package/src/async-guard.ts +35 -0
  195. package/src/attachment-cache-sweep.ts +31 -0
  196. package/src/attachment-cache.ts +195 -0
  197. package/src/background-script-reconcile.ts +106 -0
  198. package/src/bounded-log.ts +59 -0
  199. package/src/bounded-spawn-sync.ts +18 -0
  200. package/src/busy-reconciler.ts +282 -0
  201. package/src/claim-tracker.ts +140 -0
  202. package/src/claude-settings.ts +167 -0
  203. package/src/claude-workspace-trust.ts +289 -0
  204. package/src/config.ts +547 -0
  205. package/src/context-boundary.ts +101 -0
  206. package/src/context-command.ts +151 -0
  207. package/src/context-probe-state.ts +6 -0
  208. package/src/continuation-archive.ts +179 -0
  209. package/src/control-server.ts +730 -0
  210. package/src/delivery-policy.ts +9 -0
  211. package/src/delivery-result.ts +24 -0
  212. package/src/effort-command.ts +125 -0
  213. package/src/empty-turn.ts +816 -0
  214. package/src/free-port.ts +25 -0
  215. package/src/fs-offload.ts +26 -0
  216. package/src/gate-run-config.ts +35 -0
  217. package/src/gate-run-lease-probe.ts +38 -0
  218. package/src/host-asset-plan.ts +450 -0
  219. package/src/host-claude-config-worker.ts +34 -0
  220. package/src/host-sources.ts +40 -0
  221. package/src/inbox-carrier.ts +7 -0
  222. package/src/index.ts +386 -0
  223. package/src/launch-assembly.ts +955 -0
  224. package/src/liveness.ts +105 -0
  225. package/src/logger.ts +108 -0
  226. package/src/mcp-outbox.ts +204 -0
  227. package/src/mcp-preflight.ts +86 -0
  228. package/src/mcp-source-selection.ts +54 -0
  229. package/src/mcp-tool-deferral.ts +35 -0
  230. package/src/mcp-vault-env.ts +25 -0
  231. package/src/message-body-config/config.ts +16 -0
  232. package/src/message-body-config.ts +1 -0
  233. package/src/mid-turn-delivery.ts +43 -0
  234. package/src/native-memory-lever.ts +329 -0
  235. package/src/native-self-resume.ts +244 -0
  236. package/src/outbox.ts +453 -0
  237. package/src/park-post.ts +17 -0
  238. package/src/pending-permission-store.ts +148 -0
  239. package/src/process-meta.ts +9 -0
  240. package/src/profile-home.ts +419 -0
  241. package/src/profile-projection.ts +411 -0
  242. package/src/project-hooks.ts +65 -0
  243. package/src/project-instructions.ts +119 -0
  244. package/src/provider-double.ts +59 -0
  245. package/src/provider-hold.ts +228 -0
  246. package/src/provider-home-marker.ts +3 -0
  247. package/src/provider-home-tombstone.ts +4 -0
  248. package/src/provider-input-queue.ts +134 -0
  249. package/src/provider-integration.ts +93 -0
  250. package/src/provider-processes.ts +17 -0
  251. package/src/provider-submit-failure.ts +15 -0
  252. package/src/providers.ts +35 -0
  253. package/src/provisioning.ts +703 -0
  254. package/src/question-decision.ts +18 -0
  255. package/src/quota.ts +22 -0
  256. package/src/rate-limit.ts +351 -0
  257. package/src/refused-prompt-store.ts +36 -0
  258. package/src/relay-injection-events.ts +161 -0
  259. package/src/relay-instructions.ts +107 -0
  260. package/src/relay-mcp-proxy.long-turn-e2e.ts +183 -0
  261. package/src/relay-mcp-proxy.ts +704 -0
  262. package/src/relay-mcp.ts +583 -0
  263. package/src/reply-obligation-cache.ts +211 -0
  264. package/src/response-capture-report.ts +111 -0
  265. package/src/response-routing.ts +6 -0
  266. package/src/runner-core.ts +2955 -0
  267. package/src/runner-exit.ts +12 -0
  268. package/src/runner-helpers.ts +582 -0
  269. package/src/runner-insights.ts +105 -0
  270. package/src/runner-mcp-proxy.ts +46 -0
  271. package/src/runner-spawn-config.ts +68 -0
  272. package/src/runner-stale-scratch-sweep.ts +21 -0
  273. package/src/runner.ts +9 -0
  274. package/src/runtime-token-file.ts +45 -0
  275. package/src/runtime-token-remint.ts +56 -0
  276. package/src/service-access.ts +88 -0
  277. package/src/session-destroy.ts +27 -0
  278. package/src/session-insights.ts +118 -0
  279. package/src/session-scratch.ts +296 -0
  280. package/src/spawn-input.ts +147 -0
  281. package/src/startup-config-fetch.ts +48 -0
  282. package/src/subreaper.ts +113 -0
  283. package/src/template-resolver.ts +29 -0
  284. package/src/test-run-dedup.ts +435 -0
  285. package/src/transcript-context.ts +99 -0
  286. package/src/transport-errors.ts +48 -0
  287. package/src/turn-opener.ts +55 -0
  288. package/src/turn-reconcile-event.ts +88 -0
  289. package/src/vault-proxy-hop.ts +99 -0
  290. package/src/version.ts +44 -0
  291. package/src/web-tool-call-proxy.ts +128 -0
  292. package/src/web-tools.ts +59 -0
  293. package/README.md +0 -3
@@ -0,0 +1,812 @@
1
+ // Phase 1 live-session lane: extract the user-visible assistant turn from a
2
+ // Claude Code transcript JSONL so it can be surfaced in the dashboard chat
3
+ // without the agent re-emitting it via /reply (zero agent tokens).
4
+ //
5
+ // Transcript shape (one JSON object per line):
6
+ // { type: "user", message: { content: "..." | [{type:"text"|"tool_result", ...}] } }
7
+ // { type: "assistant", message: { content: [{type:"thinking"|"text"|"tool_use", ...}], stop_reason } }
8
+ //
9
+ // The "current turn" is everything after the last *real* user prompt (a user
10
+ // entry carrying text, not just tool_result blocks). We collect the assistant
11
+ // `text` blocks from that turn — thinking and tool_use are dropped.
12
+
13
+ import { isRecord, type ToolCallCategory } from "@humwick/sdk";
14
+ import type { ProviderSessionEvent } from "../adapter";
15
+ import { computeContextRatio, type SessionAnalysis, type SessionEvent } from "../session-insights";
16
+ import { absoluteToolPaths, clipToolSummary, redactAndCapParams, summarizeToolInput } from "./tool-summary";
17
+
18
+ interface TranscriptBlock {
19
+ type?: string;
20
+ text?: string;
21
+ thinking?: string;
22
+ name?: string;
23
+ input?: Record<string, unknown>;
24
+ is_error?: boolean;
25
+ id?: string;
26
+ tool_use_id?: string;
27
+ }
28
+
29
+ export interface TurnStep {
30
+ type: "narration" | "reasoning" | "tool";
31
+ text: string;
32
+ label?: string;
33
+ category?: ToolCallCategory;
34
+ occurredAt?: number;
35
+ /** #2437 — redacted, size-capped tool input, only set for type "tool". */
36
+ params?: unknown;
37
+ /** The native tool_use id, only set for type "tool"; pairs the call with its tool_result. */
38
+ toolUseId?: string;
39
+ /** #3411 — absolute file paths the tool reads or edits, only set for type "tool". */
40
+ paths?: string[];
41
+ }
42
+
43
+ export interface TranscriptMessage {
44
+ role?: string;
45
+ content?: string | TranscriptBlock[];
46
+ stop_reason?: string;
47
+ // Assistant entries carry the model id and the API-reported token usage for
48
+ // the turn. `usage.{input,cache_creation_input,cache_read_input}_tokens` sum to
49
+ // the same context-size figure the status-line probe reports — read here so the
50
+ // runner can keep context stats advancing every turn without the probe (#1512).
51
+ model?: string;
52
+ usage?: TranscriptUsage;
53
+ }
54
+
55
+ export interface TranscriptUsage {
56
+ input_tokens?: number;
57
+ cache_creation_input_tokens?: number;
58
+ cache_read_input_tokens?: number;
59
+ output_tokens?: number;
60
+ }
61
+
62
+ /** Fresh, per-turn context usage read straight from the transcript (#1512). */
63
+ export interface TranscriptContextUsage {
64
+ /** input + cache-creation + cache-read tokens — the context size sent this turn. */
65
+ tokensUsed: number;
66
+ model?: string;
67
+ /** Wall-clock ms of the assistant entry, when the transcript stamps one. */
68
+ occurredAt?: number;
69
+ }
70
+
71
+ interface TranscriptEntry {
72
+ type?: string;
73
+ timestamp?: string;
74
+ message?: TranscriptMessage;
75
+ // Claude Code stamps every transcript entry with `isSidechain`: true for
76
+ // entries belonging to a Task (subagent) run, false for the root session.
77
+ // Current CC writes sidechains to a separate subagents/*.jsonl so they don't
78
+ // reach the root transcript the runner tails — but older CC inlined them, and
79
+ // the behavior can revert, so the chat-mirror parsers below defensively skip
80
+ // sidechain entries to keep a subagent's reasoning/tools/responses from
81
+ // leaking into the parent agent's chat. Insights parsers (collectClaudeSession-
82
+ // Events/countSubstantiveTurns) intentionally do NOT filter — changing them
83
+ // would shift the #184/#185 baselines, a separate concern.
84
+ isSidechain?: boolean;
85
+ // Every entry carries its own `uuid` and its `parentUuid` (the entry it was
86
+ // appended after), forming a causal chain for the whole session. A `user`-type
87
+ // entry — whether the real prompt or a synthetic tool_result — is stamped with
88
+ // `promptId`: the same UUID Claude's Stop-hook payload reports as `prompt_id`,
89
+ // shared by every entry from that prompt until the next one. Assistant entries
90
+ // don't carry `promptId` directly; their turn is resolved by walking `parentUuid`
91
+ // back to the nearest ancestor that has one (see resolveEntryPromptId).
92
+ uuid?: string;
93
+ parentUuid?: string;
94
+ promptId?: string;
95
+ }
96
+
97
+ function blocks(message: TranscriptMessage | undefined): TranscriptBlock[] {
98
+ if (!message || !Array.isArray(message.content)) return [];
99
+ return message.content.filter((b): b is TranscriptBlock => Boolean(b) && typeof b === "object");
100
+ }
101
+
102
+ /** True for a subagent (Task) transcript entry — see the note on TranscriptEntry.isSidechain. */
103
+ function isSidechainEntry(entry: TranscriptEntry): boolean {
104
+ return entry.isSidechain === true;
105
+ }
106
+
107
+ function isRealUserPrompt(entry: TranscriptEntry): boolean {
108
+ if (entry.type !== "user") return false;
109
+ const content = entry.message?.content;
110
+ if (typeof content === "string") return content.trim().length > 0;
111
+ // An array user entry is a real prompt only if it carries a text block;
112
+ // pure tool_result entries are mid-turn plumbing, not a new prompt.
113
+ return blocks(entry.message).some((b) => b.type === "text" && Boolean(b.text?.trim()));
114
+ }
115
+
116
+ // #2840 — `closingOnly` keeps just the text after the turn's last tool call (the closing message,
117
+ // without narration the reasoning tail already mirrored); a turn ending on a tool keeps its last text.
118
+ class AssistantTurnText {
119
+ private texts: string[] = [];
120
+ private lastSegment: string[] = [];
121
+ constructor(private readonly closingOnly: boolean) {}
122
+
123
+ reset(): void {
124
+ this.texts = [];
125
+ this.lastSegment = [];
126
+ }
127
+
128
+ add(entry: TranscriptEntry): void {
129
+ if (entry.type !== "assistant") return;
130
+ for (const b of blocks(entry.message)) {
131
+ if (this.closingOnly && b.type === "tool_use") {
132
+ if (this.texts.length) this.lastSegment = this.texts;
133
+ this.texts = [];
134
+ } else if (b.type === "text" && typeof b.text === "string" && b.text.trim()) this.texts.push(b.text.trim());
135
+ }
136
+ }
137
+
138
+ text(): string {
139
+ return (this.texts.length ? this.texts : this.lastSegment).join("\n\n").trim();
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Returns true when the transcript's final parseable entry is an assistant
145
+ * message with stop_reason "end_turn", meaning the full turn has been flushed.
146
+ * Used to detect a race where the Stop hook reads the file before the last
147
+ * assistant block is written to disk.
148
+ */
149
+ export function transcriptLooksComplete(jsonl: string): boolean {
150
+ const state = new LatestTurnStepAccumulator();
151
+ state.appendJsonl(jsonl);
152
+ return state.transcriptLooksComplete;
153
+ }
154
+
155
+ /**
156
+ * Returns the concatenated assistant `text` for the most recent turn (since the
157
+ * last real user prompt), or "" if there is nothing user-visible to surface.
158
+ */
159
+ export function extractLastAssistantTurn(jsonl: string, closingOnly = false): string {
160
+ const lines = jsonl.split("\n");
161
+ const collected = new AssistantTurnText(closingOnly);
162
+ for (const line of lines) {
163
+ const trimmed = line.trim();
164
+ if (!trimmed) continue;
165
+ let entry: TranscriptEntry;
166
+ try {
167
+ entry = JSON.parse(trimmed) as TranscriptEntry;
168
+ } catch {
169
+ continue;
170
+ }
171
+ if (isSidechainEntry(entry)) continue;
172
+ if (isRealUserPrompt(entry)) {
173
+ collected.reset();
174
+ continue;
175
+ }
176
+ collected.add(entry);
177
+ }
178
+ return collected.text();
179
+ }
180
+
181
+ /**
182
+ * Like extractLastAssistantTurn but skips the first `afterEntry` valid JSON entries.
183
+ * Used when a partial transcript flush already emitted entries 1..afterEntry so the
184
+ * Stop-hook full-mode capture doesn't re-emit the already-published text.
185
+ */
186
+ export function extractLastAssistantTurnAfterEntry(jsonl: string, afterEntry: number, closingOnly = false): string {
187
+ const lines = jsonl.split("\n");
188
+ const collected = new AssistantTurnText(closingOnly);
189
+ let entryIndex = 0;
190
+ for (const line of lines) {
191
+ const trimmed = line.trim();
192
+ if (!trimmed) continue;
193
+ let entry: TranscriptEntry;
194
+ try {
195
+ entry = JSON.parse(trimmed) as TranscriptEntry;
196
+ } catch {
197
+ continue;
198
+ }
199
+ entryIndex++;
200
+ if (isSidechainEntry(entry)) continue;
201
+ if (isRealUserPrompt(entry)) {
202
+ collected.reset();
203
+ continue;
204
+ }
205
+ if (entryIndex <= afterEntry) continue;
206
+ collected.add(entry);
207
+ }
208
+ return collected.text();
209
+ }
210
+
211
+ /**
212
+ * Resolve the `promptId` (Stop hook's `prompt_id`) a transcript entry belongs to.
213
+ * A `user`-type entry (the real prompt, or a synthetic tool_result) carries its own
214
+ * `promptId`; an assistant entry doesn't, so its turn is resolved by walking
215
+ * `parentUuid` back through the causal chain until an ancestor with one is found.
216
+ * Returns undefined for an entry with no resolvable ancestor (e.g. the very first
217
+ * turn of a process, before any `promptId` has ever been stamped).
218
+ */
219
+ function resolveEntryPromptId(entry: TranscriptEntry, byUuid: ReadonlyMap<string, TranscriptEntry>): string | undefined {
220
+ let current: TranscriptEntry | undefined = entry;
221
+ const visited = new Set<string>();
222
+ while (current) {
223
+ if (typeof current.promptId === "string" && current.promptId) return current.promptId;
224
+ const parentUuid = current.parentUuid;
225
+ if (!parentUuid || visited.has(parentUuid)) return undefined;
226
+ visited.add(parentUuid);
227
+ current = byUuid.get(parentUuid);
228
+ }
229
+ return undefined;
230
+ }
231
+
232
+ /**
233
+ * Deterministic replacement for extractLastAssistantTurnAfterEntry's "walk back to
234
+ * the last real user prompt" heuristic: collects assistant `text` blocks for entries
235
+ * whose resolved promptId (see resolveEntryPromptId) equals the Claude-native
236
+ * `promptId` the Stop hook reported for this turn. Skips the first `afterEntry`
237
+ * entries the same way, for pre-flush resume. Since entries never move once
238
+ * written, a turn's promptId membership is exact — no text-shape guessing about
239
+ * what counts as a "real" prompt.
240
+ */
241
+ export function extractAssistantTurnForPrompt(jsonl: string, promptId: string, afterEntry = 0, closingOnly = false): string {
242
+ const lines = jsonl.split("\n");
243
+ const byUuid = new Map<string, TranscriptEntry>();
244
+ const collected = new AssistantTurnText(closingOnly);
245
+ let entryIndex = 0;
246
+ for (const line of lines) {
247
+ const trimmed = line.trim();
248
+ if (!trimmed) continue;
249
+ let entry: TranscriptEntry;
250
+ try {
251
+ entry = JSON.parse(trimmed) as TranscriptEntry;
252
+ } catch {
253
+ continue;
254
+ }
255
+ entryIndex++;
256
+ if (typeof entry.uuid === "string" && entry.uuid) byUuid.set(entry.uuid, entry);
257
+ if (isSidechainEntry(entry)) continue;
258
+ if (entryIndex <= afterEntry) continue;
259
+ if (entry.type !== "assistant") continue;
260
+ if (resolveEntryPromptId(entry, byUuid) !== promptId) continue;
261
+ collected.add(entry);
262
+ }
263
+ return collected.text();
264
+ }
265
+
266
+ /**
267
+ * Count valid JSON entries in a JSONL string. Used to record the high-water-mark
268
+ * after a pre-flush so the Stop hook knows where to resume in full-capture mode.
269
+ */
270
+ export function countTranscriptEntries(jsonl: string): number {
271
+ let count = 0;
272
+ for (const line of jsonl.split("\n")) {
273
+ if (!line.trim()) continue;
274
+ try { JSON.parse(line); count++; } catch {}
275
+ }
276
+ return count;
277
+ }
278
+
279
+ interface QueueOperationEntry extends TranscriptEntry {
280
+ operation?: string;
281
+ content?: unknown;
282
+ }
283
+
284
+ /**
285
+ * #2919 — prompts left in Claude's input queue at the end of `jsonl`: a mid-turn prompt is `enqueue`d,
286
+ * then `remove`d when a tool boundary absorbs it, or `dequeue`d (every queued prompt at once) to run as
287
+ * the next turn after the Stop. So a dequeue or a new promptId empties the queue.
288
+ */
289
+ export function pendingQueuedPrompts(jsonl: string): string[] {
290
+ let pending: string[] = [];
291
+ let promptId: string | undefined;
292
+ for (const line of jsonl.split("\n")) {
293
+ if (!line.includes("\"queue-operation\"") && !line.includes("\"promptId\"")) continue;
294
+ let entry: QueueOperationEntry;
295
+ try {
296
+ entry = JSON.parse(line) as QueueOperationEntry;
297
+ } catch {
298
+ continue;
299
+ }
300
+ if (isSidechainEntry(entry)) continue;
301
+ if (entry.promptId && entry.promptId !== promptId) {
302
+ promptId = entry.promptId;
303
+ pending = [];
304
+ }
305
+ if (entry.type !== "queue-operation") continue;
306
+ const content = typeof entry.content === "string" ? entry.content : undefined;
307
+ if (entry.operation === "dequeue") pending = [];
308
+ else if (entry.operation === "enqueue" && content !== undefined) pending.push(content);
309
+ else if (entry.operation === "remove" && content !== undefined && pending.includes(content)) pending.splice(pending.indexOf(content), 1);
310
+ }
311
+ return pending;
312
+ }
313
+
314
+ /** Key-order-independent structural equality, cheap enough for a small tool_input object. */
315
+ function canonicalJson(value: unknown): string {
316
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
317
+ if (value && typeof value === "object") {
318
+ const keys = Object.keys(value as Record<string, unknown>).sort();
319
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson((value as Record<string, unknown>)[key])}`).join(",")}}`;
320
+ }
321
+ return JSON.stringify(value);
322
+ }
323
+
324
+ /**
325
+ * True when the transcript already contains the exact assistant tool_use block that
326
+ * triggered a PreToolUse hook — the settle-poll anchor (#1116). The hook payload's own
327
+ * `tool_name`/`tool_input` are the precise call that fired it, so matching them against
328
+ * the transcript's tool_use blocks (name + structural input equality) is exact, unlike
329
+ * guessing from transcript shape alone. Claude's JSONL flush is async/bursty and can
330
+ * lag well behind the hook firing, so the entry backing the hook's own call may still be
331
+ * missing from disk the instant the hook runs — this is what the settle-poll waits for.
332
+ * #1086 should adopt this same anchor for its own turn-final settle marker rather than
333
+ * inventing a second one.
334
+ */
335
+ export function transcriptHasToolUseAnchor(jsonl: string, toolName: string, toolInput: unknown): boolean {
336
+ const wantInput = canonicalJson(toolInput ?? {});
337
+ for (const line of jsonl.split("\n")) {
338
+ const trimmed = line.trim();
339
+ if (!trimmed) continue;
340
+ let entry: TranscriptEntry;
341
+ try {
342
+ entry = JSON.parse(trimmed) as TranscriptEntry;
343
+ } catch {
344
+ continue;
345
+ }
346
+ if (entry.type !== "assistant" || isSidechainEntry(entry)) continue;
347
+ for (const block of blocks(entry.message)) {
348
+ if (block.type === "tool_use" && block.name === toolName && canonicalJson(block.input ?? {}) === wantInput) return true;
349
+ }
350
+ }
351
+ return false;
352
+ }
353
+
354
+ /**
355
+ * The transcript-embedded `timestamp` of the specific assistant entry carrying the exact
356
+ * tool_use block that triggered a PreToolUse hook (same anchor match as
357
+ * transcriptHasToolUseAnchor above). Used to stamp a resolved permission prompt with the
358
+ * tool call's real event time — a hook's own wall-clock receipt time can end up earlier than
359
+ * the entry the provider eventually flushes for that same call, which otherwise sorts the
360
+ * resolved prompt ahead of the narration that led up to it (#1185).
361
+ */
362
+ export function transcriptToolUseOccurredAt(jsonl: string, toolName: string, toolInput: unknown): number | undefined {
363
+ const wantInput = canonicalJson(toolInput ?? {});
364
+ for (const line of jsonl.split("\n")) {
365
+ const trimmed = line.trim();
366
+ if (!trimmed) continue;
367
+ let entry: TranscriptEntry;
368
+ try {
369
+ entry = JSON.parse(trimmed) as TranscriptEntry;
370
+ } catch {
371
+ continue;
372
+ }
373
+ if (entry.type !== "assistant" || isSidechainEntry(entry)) continue;
374
+ for (const block of blocks(entry.message)) {
375
+ if (block.type === "tool_use" && block.name === toolName && canonicalJson(block.input ?? {}) === wantInput) {
376
+ return transcriptEntryOccurredAt(entry);
377
+ }
378
+ }
379
+ }
380
+ return undefined;
381
+ }
382
+
383
+ /**
384
+ * Extract the ordered narration, reasoning, and tool steps for the most recent
385
+ * turn (since the last real user prompt). Used by the reasoning tailer to stream
386
+ * progress into chat while a turn is in flight. `narration` is the assistant's
387
+ * intermediate `text` between tool calls (the terminal's `●` lines); it is the
388
+ * primary, default-visible turn content. Returns steps in transcript order so the
389
+ * tailer can emit only the ones it hasn't seen yet.
390
+ */
391
+ export function extractLatestTurnSteps(jsonl: string): TurnStep[] {
392
+ const state = new LatestTurnStepAccumulator();
393
+ state.appendJsonl(jsonl);
394
+ return state.steps;
395
+ }
396
+
397
+ export class LatestTurnStepAccumulator {
398
+ private latestSteps: TurnStep[] = [];
399
+ private newUserPrompts: Array<{ text: string; occurredAt?: number }> = [];
400
+ private lastAssistantStopReason: string | undefined;
401
+ private parsedEntries = 0;
402
+ private latestPromptId: string | undefined;
403
+
404
+ get steps(): TurnStep[] {
405
+ return [...this.latestSteps];
406
+ }
407
+
408
+ get transcriptLooksComplete(): boolean {
409
+ return this.lastAssistantStopReason === "end_turn";
410
+ }
411
+
412
+ get entryCount(): number {
413
+ return this.parsedEntries;
414
+ }
415
+
416
+ /** Claude's native prompt id for the latest turn, when its prompt entry carries one. */
417
+ get promptId(): string | undefined {
418
+ return this.latestPromptId;
419
+ }
420
+
421
+ drainUserPrompts(): Array<{ text: string; occurredAt?: number }> {
422
+ const prompts = this.newUserPrompts;
423
+ this.newUserPrompts = [];
424
+ return prompts;
425
+ }
426
+
427
+ reset(): void {
428
+ this.latestSteps = [];
429
+ this.newUserPrompts = [];
430
+ this.lastAssistantStopReason = undefined;
431
+ this.parsedEntries = 0;
432
+ this.latestPromptId = undefined;
433
+ }
434
+
435
+ appendJsonl(jsonl: string): void {
436
+ for (const line of jsonl.split("\n")) this.appendLine(line);
437
+ }
438
+
439
+ appendLine(line: string): void {
440
+ const trimmed = line.trim();
441
+ if (!trimmed) return;
442
+ let entry: TranscriptEntry;
443
+ try {
444
+ entry = JSON.parse(trimmed) as TranscriptEntry;
445
+ } catch {
446
+ return;
447
+ }
448
+ this.parsedEntries += 1;
449
+ if (isSidechainEntry(entry)) return;
450
+ if (entry.type === "assistant") this.lastAssistantStopReason = entry.message?.stop_reason;
451
+ if (isRealUserPrompt(entry)) {
452
+ const content = entry.message?.content;
453
+ const text = typeof content === "string" ? content : blocks(entry.message).filter((block) => block.type === "text").map((block) => block.text ?? "").join("\n");
454
+ this.newUserPrompts.push({ text, occurredAt: transcriptEntryOccurredAt(entry) });
455
+ this.latestSteps = [];
456
+ this.latestPromptId = entry.promptId;
457
+ return;
458
+ }
459
+ if (entry.type !== "assistant") return;
460
+ this.latestSteps.push(...claudeAssistantSteps(entry.message, transcriptEntryOccurredAt(entry)));
461
+ }
462
+ }
463
+
464
+ /** Narration, reasoning and tool steps of one assistant message, shared by the transcript tail and the headless stream (#2771). */
465
+ export function claudeAssistantSteps(message: TranscriptMessage | undefined, occurredAt?: number): TurnStep[] {
466
+ const steps: TurnStep[] = [];
467
+ for (const b of blocks(message)) {
468
+ if (b.type === "text" && typeof b.text === "string" && b.text.trim()) {
469
+ steps.push({ type: "narration", text: b.text.trim(), ...(occurredAt ? { occurredAt } : {}) });
470
+ } else if (b.type === "thinking" && typeof b.thinking === "string" && b.thinking.trim()) {
471
+ steps.push({ type: "reasoning", text: b.thinking.trim(), ...(occurredAt ? { occurredAt } : {}) });
472
+ } else if (b.type === "tool_use" && typeof b.name === "string" && b.name) {
473
+ const paths = claudeToolPaths(b.name, b.input);
474
+ steps.push({ type: "tool", label: b.name, text: summarizeToolUse(b.name, b.input), category: claudeToolCategory(b.name), params: redactAndCapParams(b.input), ...(typeof b.id === "string" && b.id ? { toolUseId: b.id } : {}), ...(occurredAt ? { occurredAt } : {}), ...(paths ? { paths } : {}) });
475
+ }
476
+ }
477
+ return steps;
478
+ }
479
+
480
+ // #3411 — Claude's file tools and the input field naming their file. Keyed on the tool name, not the
481
+ // category: MultiEdit is "generic". Claude's file tools take absolute paths only.
482
+ const CLAUDE_TOOL_PATH_FIELDS: Record<string, string> = {
483
+ Read: "file_path",
484
+ Edit: "file_path",
485
+ MultiEdit: "file_path",
486
+ Write: "file_path",
487
+ NotebookEdit: "notebook_path",
488
+ };
489
+
490
+ /** The absolute file path a Claude file tool reads or edits, from its raw (uncapped) input. */
491
+ export function claudeToolPaths(name: string, input: Record<string, unknown> | undefined): string[] | undefined {
492
+ const field = CLAUDE_TOOL_PATH_FIELDS[name];
493
+ return field && input ? absoluteToolPaths([input[field]]) : undefined;
494
+ }
495
+
496
+ /** The raw PostToolUse payload uses the same path contract as transcript tools (#3646). */
497
+ export function claudeToolHookPaths(input: unknown): string[] | undefined {
498
+ return isRecord(input) && typeof input.tool_name === "string"
499
+ ? claudeToolPaths(input.tool_name, isRecord(input.tool_input) ? input.tool_input : undefined) : undefined;
500
+ }
501
+
502
+ /** The session event of one step, shared by the transcript tail and the headless stream. */
503
+ export function turnStepEvent(step: TurnStep, turnId: string | undefined): ProviderSessionEvent {
504
+ return {
505
+ type: step.type,
506
+ origin: "provider",
507
+ body: step.text,
508
+ ...(step.occurredAt ? { occurredAt: step.occurredAt } : {}),
509
+ ...(turnId ? { turnId } : {}),
510
+ ...(step.label ? { label: step.label } : {}),
511
+ ...(step.category ? { category: step.category } : {}),
512
+ ...(step.params !== undefined ? { params: step.params } : {}),
513
+ ...(step.paths ? { paths: step.paths } : {}),
514
+ };
515
+ }
516
+
517
+ /** tool_use_id → error outcome for each tool_result block of a user message. */
518
+ export function claudeToolResults(message: TranscriptMessage | undefined): Array<{ toolUseId: string; isError: boolean }> {
519
+ return blocks(message).flatMap((b) => b.type === "tool_result" && typeof b.tool_use_id === "string" ? [{ toolUseId: b.tool_use_id, isError: b.is_error === true }] : []);
520
+ }
521
+
522
+ function transcriptEntryOccurredAt(entry: TranscriptEntry): number | undefined {
523
+ if (typeof entry.timestamp !== "string" || !entry.timestamp) return undefined;
524
+ const parsed = Date.parse(entry.timestamp);
525
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
526
+ }
527
+
528
+ /**
529
+ * Stable dedup keys for a turn's steps, in order. Each key is salted with how many
530
+ * identical (type,label,text) steps preceded it in the same window — so running the
531
+ * same tool twice with identical input within a turn yields two distinct keys and
532
+ * both show in the activity trace (#265). Keying on occurrence-within-window rather
533
+ * than raw transcript index keeps the reasoning tailer idempotent when the "latest
534
+ * turn" window shrinks/resets mid-poll: a surviving step recomputes to the same or a
535
+ * lower occurrence, so an already-emitted step never re-fires.
536
+ */
537
+ export function stepDedupKeys(steps: TurnStep[]): string[] {
538
+ const counts = new Map<string, number>();
539
+ return steps.map((step) => {
540
+ const base = JSON.stringify([step.type, step.label ?? "", step.text]);
541
+ const occ = counts.get(base) ?? 0;
542
+ counts.set(base, occ + 1);
543
+ return JSON.stringify([step.type, step.label ?? "", step.text, occ]);
544
+ });
545
+ }
546
+
547
+ // #1498: Claude's native tool names → the provider-independent category taxonomy. Tools with
548
+ // no dedicated category (Grep, Glob, LS, WebFetch, WebSearch, ...) fall to "generic" rather than
549
+ // being force-fit into a category that doesn't really describe them — the fallback exists
550
+ // precisely so nothing is hidden from the dashboard's activity trace.
551
+ const CLAUDE_TOOL_CATEGORIES: Record<string, ToolCallCategory> = {
552
+ Bash: "cli-bash",
553
+ Read: "read",
554
+ Edit: "edit",
555
+ Write: "edit",
556
+ NotebookEdit: "edit",
557
+ AskUserQuestion: "ask-user-question",
558
+ ExitPlanMode: "plan",
559
+ Task: "agent-spawn",
560
+ ToolSearch: "mcp-tool-search",
561
+ };
562
+
563
+ /** Map a Claude tool_use name onto the provider-independent tool-call category (#1498). */
564
+ export function claudeToolCategory(name: string): ToolCallCategory {
565
+ if (name.startsWith("mcp__")) return "mcp-tool-call";
566
+ return CLAUDE_TOOL_CATEGORIES[name] ?? "generic";
567
+ }
568
+
569
+ /** Compact one-line summary of a tool invocation for the discreet activity row. The one-line +
570
+ * redact + clip is the provider-neutral primitive in `tool-summary.ts` (shared with Codex and
571
+ * OpenCode); only the Claude-specific candidate-field order lives here. */
572
+ export function summarizeToolUse(name: string, input: Record<string, unknown> | undefined): string {
573
+ const summary = summarizeToolInput(input);
574
+ if (!summary) return name;
575
+ return clipToolSummary(summary);
576
+ }
577
+
578
+ // --- Insights #184: context-gathering ratio (epic #183, docs/self-improvement.md) ---
579
+ //
580
+ // Computed mechanically from the whole-session transcript at session end — no model
581
+ // involvement, so it costs zero agent tokens and the agent can't game it. The ratio is
582
+ // paired with cheap outcome proxies (user re-prompts, tool errors) so it's never read
583
+ // alone — see the anti-Goodhart constraint in the epic.
584
+
585
+ /**
586
+ * Normalize a full Claude transcript into the provider-agnostic `SessionEvent` stream
587
+ * (#183/#184). The classifier and ratio math live in `session-insights.ts` and are shared
588
+ * across providers; this only knows the Claude JSONL shape. Events are emitted in
589
+ * transcript order so `leadingGather` is meaningful.
590
+ */
591
+ export function collectClaudeSessionEvents(jsonl: string): SessionEvent[] {
592
+ const events: SessionEvent[] = [];
593
+ for (const line of jsonl.split("\n")) {
594
+ const trimmed = line.trim();
595
+ if (!trimmed) continue;
596
+ let entry: TranscriptEntry;
597
+ try {
598
+ entry = JSON.parse(trimmed) as TranscriptEntry;
599
+ } catch {
600
+ continue;
601
+ }
602
+ if (isRealUserPrompt(entry)) events.push({ type: "user_prompt" });
603
+ if (entry.type === "user") {
604
+ for (const b of blocks(entry.message)) {
605
+ if (b.type === "tool_result" && b.is_error === true) events.push({ type: "tool_error" });
606
+ }
607
+ continue;
608
+ }
609
+ if (entry.type !== "assistant") continue;
610
+ let producedSomething = false;
611
+ for (const b of blocks(entry.message)) {
612
+ if (b.type === "text" && b.text?.trim()) producedSomething = true;
613
+ if (b.type !== "tool_use" || typeof b.name !== "string" || !b.name) continue;
614
+ producedSomething = true;
615
+ events.push({ type: "tool", name: b.name });
616
+ }
617
+ if (producedSomething) events.push({ type: "turn" });
618
+ }
619
+ return events;
620
+ }
621
+
622
+ /**
623
+ * Walk the full transcript and compute the context-gathering ratio plus paired outcome
624
+ * proxies. Returns null when there's nothing substantive to measure (no tool calls) —
625
+ * trivial sessions have nothing to learn from and shouldn't pollute the baselines.
626
+ */
627
+ export function analyzeSession(jsonl: string): SessionAnalysis | null {
628
+ return computeContextRatio(collectClaudeSessionEvents(jsonl));
629
+ }
630
+
631
+ /** Count substantive assistant turns — used by the #185 introspection gate. */
632
+ export function countSubstantiveTurns(jsonl: string): number {
633
+ let turns = 0;
634
+ for (const line of jsonl.split("\n")) {
635
+ const trimmed = line.trim();
636
+ if (!trimmed) continue;
637
+ let entry: TranscriptEntry;
638
+ try {
639
+ entry = JSON.parse(trimmed) as TranscriptEntry;
640
+ } catch {
641
+ continue;
642
+ }
643
+ if (entry.type !== "assistant") continue;
644
+ const hasContent = blocks(entry.message).some(
645
+ (b) => (b.type === "text" && b.text?.trim()) || (b.type === "tool_use" && b.name),
646
+ );
647
+ if (hasContent) turns++;
648
+ }
649
+ return turns;
650
+ }
651
+
652
+ // Walk the transcript from the end and return the most recent root-session
653
+ // assistant turn's token usage (#1512). This is the SAME input+cache token sum
654
+ // the Claude status-line probe reports, but read directly from the transcript —
655
+ // which the Stop hook hands the runner every turn — so context stats keep
656
+ // advancing across compact/clear/native-resume even when the status-line probe
657
+ // goes silent. Sidechain (subagent) entries are skipped so a Task's usage never
658
+ // masquerades as the root session's context size.
659
+ export function extractLatestContextUsage(jsonl: string): TranscriptContextUsage | undefined {
660
+ const lines = jsonl.split("\n");
661
+ for (let i = lines.length - 1; i >= 0; i--) {
662
+ const trimmed = lines[i]?.trim();
663
+ if (!trimmed) continue;
664
+ let entry: TranscriptEntry;
665
+ try {
666
+ entry = JSON.parse(trimmed) as TranscriptEntry;
667
+ } catch {
668
+ continue;
669
+ }
670
+ if (entry.type !== "assistant" || isSidechainEntry(entry)) continue;
671
+ const usage = entry.message?.usage;
672
+ if (!usage) continue;
673
+ const tokensUsed = sumUsageTokens(usage);
674
+ if (tokensUsed === undefined) continue;
675
+ const occurredAt = typeof entry.timestamp === "string" ? Date.parse(entry.timestamp) : NaN;
676
+ return {
677
+ tokensUsed,
678
+ ...(typeof entry.message?.model === "string" ? { model: entry.message.model } : {}),
679
+ ...(Number.isFinite(occurredAt) ? { occurredAt } : {}),
680
+ };
681
+ }
682
+ return undefined;
683
+ }
684
+
685
+ // Context size == prompt input tokens for the turn: fresh input + newly-cached
686
+ // input + cache-read input. Matches the status-line probe's sum (output tokens
687
+ // are excluded — they are not part of the context window carried forward).
688
+ export function sumUsageTokens(usage: TranscriptUsage): number | undefined {
689
+ let total = 0;
690
+ let seen = false;
691
+ for (const value of [usage.input_tokens, usage.cache_creation_input_tokens, usage.cache_read_input_tokens]) {
692
+ if (typeof value === "number" && Number.isFinite(value)) {
693
+ total += value;
694
+ seen = true;
695
+ }
696
+ }
697
+ return seen ? total : undefined;
698
+ }
699
+
700
+ // #2482 — the token-usage spine's capture parser. Where extractLatestContextUsage
701
+ // collapses a turn's usage into one context-size sum, this sibling keeps the RAW
702
+ // per-entry breakdown the spine records, one record per assistant transcript entry:
703
+ // dedupeKey = the entry `uuid` (entries never move once written, so the uuid is the
704
+ // stable turn-less key — replay-safe against the spine's dedupe_key upsert);
705
+ // createdAt = the entry `timestamp` (the provider's observed usage time, which the
706
+ // spine requires and never defaults from a receipt clock).
707
+ // Sidechain (subagent) entries are skipped exactly as extractLatestContextUsage does —
708
+ // a Task's usage is the subagent's own session's accounting, not the root turn's.
709
+ export interface TranscriptTokenUsageEntry {
710
+ uuid: string;
711
+ /** The Stop hook's prompt_id this entry belongs to (resolved via the parentUuid chain). */
712
+ promptId?: string;
713
+ model: string;
714
+ inputTokens: number;
715
+ outputTokens: number;
716
+ cacheReadTokens: number;
717
+ cacheWriteTokens: number;
718
+ occurredAt: number;
719
+ /** #3351 — an API assistant entry without a usage block: spend happened, counts are unknown. */
720
+ usageStatus?: "unknown";
721
+ }
722
+
723
+ export function extractTokenUsageEntries(jsonl: string, afterEntry = 0): TranscriptTokenUsageEntry[] {
724
+ const extractor = new TranscriptTokenUsageExtractor(afterEntry);
725
+ const entries: TranscriptTokenUsageEntry[] = [];
726
+ for (const line of jsonl.split("\n")) {
727
+ const entry = extractor.appendLine(line);
728
+ if (entry) entries.push(entry);
729
+ }
730
+ return entries;
731
+ }
732
+
733
+ /** One parseable transcript line → its spine usage record, or undefined when the line
734
+ * is blank/malformed, isn't a reportable root-session assistant usage entry, or falls
735
+ * inside the suppressed prefix. */
736
+ function usageEntryFromTranscriptEntry(entry: TranscriptEntry, byUuid: ReadonlyMap<string, TranscriptEntry>, includeSidechain = false): TranscriptTokenUsageEntry | undefined {
737
+ if (entry.type !== "assistant" || (!includeSidechain && isSidechainEntry(entry))) return undefined;
738
+ const usage = entry.message?.usage;
739
+ const occurredAt = transcriptEntryOccurredAt(entry);
740
+ const uuid = typeof entry.uuid === "string" ? entry.uuid : "";
741
+ const model = typeof entry.message?.model === "string" && entry.message.model ? entry.message.model : usage ? "unknown" : "";
742
+ // The spine requires both an observed createdAt and a dedupeKey: an entry the
743
+ // transcript hasn't stamped yet is not reportable this pass (it stays unconsumed
744
+ // and is picked up once Claude flushes the full entry).
745
+ if (!uuid || occurredAt === undefined || !model) return undefined;
746
+ const promptId = resolveEntryPromptId(entry, byUuid);
747
+ return {
748
+ uuid,
749
+ ...(promptId ? { promptId } : {}),
750
+ model,
751
+ inputTokens: usageCount(usage?.input_tokens),
752
+ outputTokens: usageCount(usage?.output_tokens),
753
+ cacheReadTokens: usageCount(usage?.cache_read_input_tokens),
754
+ cacheWriteTokens: usageCount(usage?.cache_creation_input_tokens),
755
+ occurredAt,
756
+ ...(usage ? {} : { usageStatus: "unknown" as const }),
757
+ };
758
+ }
759
+
760
+ /**
761
+ * Incremental #2482 extractor: feeds transcript lines one at a time so a caller can
762
+ * consume only newly-appended data without re-walking the file. promptId is resolved
763
+ * against the entries THIS instance has seen (the uuid→entry map) — feed a whole file
764
+ * for whole-transcript resolution, or feed only an appended tail and accept that an
765
+ * entry whose promptId-bearing ancestor was flushed in an earlier pass resolves without
766
+ * one. promptId is diagnostic metadata only; the spine record keys on the entry uuid
767
+ * alone. `skipFirst` suppresses the first N parseable lines (the afterEntry cursor of
768
+ * {@link extractTokenUsageEntries}); the capture passes none.
769
+ */
770
+ export class TranscriptTokenUsageExtractor {
771
+ private readonly byUuid = new Map<string, TranscriptEntry>();
772
+ private seenCount = 0;
773
+
774
+ constructor(private readonly skipFirst = 0, private readonly includeSidechain = false) {}
775
+
776
+ appendLine(line: string): TranscriptTokenUsageEntry | undefined {
777
+ const trimmed = line.trim();
778
+ if (!trimmed) return undefined;
779
+ let entry: TranscriptEntry;
780
+ try {
781
+ entry = JSON.parse(trimmed) as TranscriptEntry;
782
+ } catch {
783
+ return undefined;
784
+ }
785
+ this.seenCount++;
786
+ if (typeof entry.uuid === "string" && entry.uuid) this.byUuid.set(entry.uuid, entry);
787
+ if (this.seenCount <= this.skipFirst) return undefined;
788
+ return usageEntryFromTranscriptEntry(entry, this.byUuid, this.includeSidechain);
789
+ }
790
+ }
791
+
792
+ /** Claude API usage fields are non-negative integers; anything else reports as 0. */
793
+ function usageCount(value: number | undefined): number {
794
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0;
795
+ }
796
+
797
+ /** Claude names its transcript file `<sessionId>.jsonl` (#1235), so the provider's own
798
+ * session uuid is recoverable from the path even when the status probe is silent. */
799
+ export function claudeSessionIdFromTranscriptPath(transcriptPath: string): string {
800
+ const file = transcriptPath.split(/[\\/]/).pop() ?? "";
801
+ return file.replace(/\.[^.]+$/, "");
802
+ }
803
+
804
+ export function extractHookAssistantMessage(content: unknown): string {
805
+ if (typeof content === "string") return content.trim();
806
+ if (!Array.isArray(content)) return "";
807
+ return (content as TranscriptBlock[])
808
+ .filter((b) => b.type === "text" && typeof b.text === "string")
809
+ .map((b) => b.text!.trim())
810
+ .filter(Boolean)
811
+ .join("\n\n");
812
+ }