@jini-ai/agent-runtime 0.2.0 → 0.3.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 (163) hide show
  1. package/README.md +2 -2
  2. package/dist/agent-protocol/acp/session.d.ts +206 -1
  3. package/dist/agent-protocol/acp/session.d.ts.map +1 -1
  4. package/dist/agent-protocol/acp/session.js +505 -350
  5. package/dist/agent-protocol/acp/session.js.map +1 -1
  6. package/dist/agent-protocol/core/json-line-stream.d.ts +53 -0
  7. package/dist/agent-protocol/core/json-line-stream.d.ts.map +1 -1
  8. package/dist/agent-protocol/core/json-line-stream.js +307 -255
  9. package/dist/agent-protocol/core/json-line-stream.js.map +1 -1
  10. package/dist/agent-protocol/pi-rpc/events.d.ts +22 -1
  11. package/dist/agent-protocol/pi-rpc/events.d.ts.map +1 -1
  12. package/dist/agent-protocol/pi-rpc/events.js +189 -153
  13. package/dist/agent-protocol/pi-rpc/events.js.map +1 -1
  14. package/dist/agent-protocol/pi-rpc/session.d.ts +39 -0
  15. package/dist/agent-protocol/pi-rpc/session.d.ts.map +1 -1
  16. package/dist/agent-protocol/pi-rpc/session.js +143 -107
  17. package/dist/agent-protocol/pi-rpc/session.js.map +1 -1
  18. package/dist/claude-stream.d.ts +64 -1
  19. package/dist/claude-stream.d.ts.map +1 -1
  20. package/dist/claude-stream.js +568 -441
  21. package/dist/claude-stream.js.map +1 -1
  22. package/dist/defs/aider.d.ts.map +1 -1
  23. package/dist/defs/aider.js +13 -1
  24. package/dist/defs/aider.js.map +1 -1
  25. package/dist/defs/amp.d.ts.map +1 -1
  26. package/dist/defs/amp.js +16 -1
  27. package/dist/defs/amp.js.map +1 -1
  28. package/dist/defs/amr.d.ts +2 -1
  29. package/dist/defs/amr.d.ts.map +1 -1
  30. package/dist/defs/amr.js +12 -1
  31. package/dist/defs/amr.js.map +1 -1
  32. package/dist/defs/antigravity.d.ts +41 -2
  33. package/dist/defs/antigravity.d.ts.map +1 -1
  34. package/dist/defs/antigravity.js +201 -22
  35. package/dist/defs/antigravity.js.map +1 -1
  36. package/dist/defs/claude.d.ts +2 -0
  37. package/dist/defs/claude.d.ts.map +1 -1
  38. package/dist/defs/claude.js +58 -3
  39. package/dist/defs/claude.js.map +1 -1
  40. package/dist/defs/codebuddy.d.ts.map +1 -1
  41. package/dist/defs/codebuddy.js +9 -1
  42. package/dist/defs/codebuddy.js.map +1 -1
  43. package/dist/defs/codex.d.ts +1 -0
  44. package/dist/defs/codex.d.ts.map +1 -1
  45. package/dist/defs/codex.js +34 -0
  46. package/dist/defs/codex.js.map +1 -1
  47. package/dist/defs/copilot.d.ts.map +1 -1
  48. package/dist/defs/copilot.js +15 -1
  49. package/dist/defs/copilot.js.map +1 -1
  50. package/dist/defs/cursor-agent.d.ts.map +1 -1
  51. package/dist/defs/cursor-agent.js +24 -2
  52. package/dist/defs/cursor-agent.js.map +1 -1
  53. package/dist/defs/deepseek.d.ts.map +1 -1
  54. package/dist/defs/deepseek.js +12 -0
  55. package/dist/defs/deepseek.js.map +1 -1
  56. package/dist/defs/devin.d.ts +2 -1
  57. package/dist/defs/devin.d.ts.map +1 -1
  58. package/dist/defs/devin.js +12 -7
  59. package/dist/defs/devin.js.map +1 -1
  60. package/dist/defs/grok-build.d.ts.map +1 -1
  61. package/dist/defs/grok-build.js +10 -0
  62. package/dist/defs/grok-build.js.map +1 -1
  63. package/dist/defs/hermes.d.ts +1 -0
  64. package/dist/defs/hermes.d.ts.map +1 -1
  65. package/dist/defs/hermes.js +3 -0
  66. package/dist/defs/hermes.js.map +1 -1
  67. package/dist/defs/kilo.d.ts +1 -0
  68. package/dist/defs/kilo.d.ts.map +1 -1
  69. package/dist/defs/kilo.js +3 -0
  70. package/dist/defs/kilo.js.map +1 -1
  71. package/dist/defs/kimi.d.ts +1 -0
  72. package/dist/defs/kimi.d.ts.map +1 -1
  73. package/dist/defs/kimi.js +3 -0
  74. package/dist/defs/kimi.js.map +1 -1
  75. package/dist/defs/kiro.d.ts +1 -0
  76. package/dist/defs/kiro.d.ts.map +1 -1
  77. package/dist/defs/kiro.js +3 -0
  78. package/dist/defs/kiro.js.map +1 -1
  79. package/dist/defs/pi.d.ts +1 -1
  80. package/dist/defs/pi.d.ts.map +1 -1
  81. package/dist/defs/pi.js +6 -1
  82. package/dist/defs/pi.js.map +1 -1
  83. package/dist/defs/qoder.d.ts +1 -0
  84. package/dist/defs/qoder.d.ts.map +1 -1
  85. package/dist/defs/qoder.js +15 -0
  86. package/dist/defs/qoder.js.map +1 -1
  87. package/dist/defs/qwen.d.ts.map +1 -1
  88. package/dist/defs/qwen.js +9 -0
  89. package/dist/defs/qwen.js.map +1 -1
  90. package/dist/defs/reasonix.d.ts +1 -0
  91. package/dist/defs/reasonix.d.ts.map +1 -1
  92. package/dist/defs/reasonix.js +3 -0
  93. package/dist/defs/reasonix.js.map +1 -1
  94. package/dist/defs/shared.d.ts +31 -1
  95. package/dist/defs/shared.d.ts.map +1 -1
  96. package/dist/defs/shared.js +35 -0
  97. package/dist/defs/shared.js.map +1 -1
  98. package/dist/defs/trae-cli.d.ts +1 -0
  99. package/dist/defs/trae-cli.d.ts.map +1 -1
  100. package/dist/defs/trae-cli.js +3 -0
  101. package/dist/defs/trae-cli.js.map +1 -1
  102. package/dist/defs/vibe.d.ts +1 -0
  103. package/dist/defs/vibe.d.ts.map +1 -1
  104. package/dist/defs/vibe.js +3 -0
  105. package/dist/defs/vibe.js.map +1 -1
  106. package/dist/detection.d.ts +1 -1
  107. package/dist/detection.d.ts.map +1 -1
  108. package/dist/detection.js +7 -2
  109. package/dist/detection.js.map +1 -1
  110. package/dist/index.d.ts +2 -1
  111. package/dist/index.d.ts.map +1 -1
  112. package/dist/index.js +2 -1
  113. package/dist/index.js.map +1 -1
  114. package/dist/log-file.d.ts +23 -0
  115. package/dist/log-file.d.ts.map +1 -0
  116. package/dist/log-file.js +56 -0
  117. package/dist/log-file.js.map +1 -0
  118. package/dist/model-registry.d.ts +7 -45
  119. package/dist/model-registry.d.ts.map +1 -1
  120. package/dist/model-registry.js.map +1 -1
  121. package/dist/providers/anthropic-messages.d.ts +198 -6
  122. package/dist/providers/anthropic-messages.d.ts.map +1 -1
  123. package/dist/providers/anthropic-messages.js +317 -140
  124. package/dist/providers/anthropic-messages.js.map +1 -1
  125. package/dist/providers/azure-chat.d.ts +113 -3
  126. package/dist/providers/azure-chat.d.ts.map +1 -1
  127. package/dist/providers/azure-chat.js +114 -21
  128. package/dist/providers/azure-chat.js.map +1 -1
  129. package/dist/providers/connection-guard.d.ts +121 -1
  130. package/dist/providers/connection-guard.d.ts.map +1 -1
  131. package/dist/providers/connection-guard.js +205 -3
  132. package/dist/providers/connection-guard.js.map +1 -1
  133. package/dist/providers/connection-test.d.ts +62 -0
  134. package/dist/providers/connection-test.d.ts.map +1 -0
  135. package/dist/providers/connection-test.js +404 -0
  136. package/dist/providers/connection-test.js.map +1 -0
  137. package/dist/providers/elevenlabs.d.ts.map +1 -1
  138. package/dist/providers/elevenlabs.js +3 -2
  139. package/dist/providers/elevenlabs.js.map +1 -1
  140. package/dist/providers/google-messages.d.ts +208 -7
  141. package/dist/providers/google-messages.d.ts.map +1 -1
  142. package/dist/providers/google-messages.js +331 -105
  143. package/dist/providers/google-messages.js.map +1 -1
  144. package/dist/providers/index.d.ts +1 -0
  145. package/dist/providers/index.d.ts.map +1 -1
  146. package/dist/providers/index.js +1 -0
  147. package/dist/providers/index.js.map +1 -1
  148. package/dist/providers/model-catalog.d.ts.map +1 -1
  149. package/dist/providers/model-catalog.js +21 -0
  150. package/dist/providers/model-catalog.js.map +1 -1
  151. package/dist/providers/ollama-chat.d.ts +190 -1
  152. package/dist/providers/ollama-chat.d.ts.map +1 -1
  153. package/dist/providers/ollama-chat.js +319 -110
  154. package/dist/providers/ollama-chat.js.map +1 -1
  155. package/dist/providers/openai-chat.d.ts +222 -14
  156. package/dist/providers/openai-chat.d.ts.map +1 -1
  157. package/dist/providers/openai-chat.js +377 -128
  158. package/dist/providers/openai-chat.js.map +1 -1
  159. package/dist/providers/sse-decode.d.ts +2 -2
  160. package/dist/providers/sse-decode.js +2 -2
  161. package/dist/types.d.ts +169 -87
  162. package/dist/types.d.ts.map +1 -1
  163. package/package.json +8 -4
@@ -25,11 +25,305 @@
25
25
  * Callers give us `onEvent({ type, ...payload })`. We track per-content-block
26
26
  * state to accumulate partial tool_use input JSON and emit a single
27
27
  * `tool_use` event when that block stops.
28
+ *
29
+ * Module layout: functions above `createClaudeStreamHandler` are hoisted,
30
+ * exported, unit-testable pure (or near-pure, dependency-injected) decision
31
+ * logic — they take their inputs explicitly rather than reading them off a
32
+ * closure. `createClaudeStreamHandler` itself, and everything declared
33
+ * inside it, is the genuinely stateful part: per-message id/epoch
34
+ * bookkeeping, the block-accumulation map, and the artifact-echo
35
+ * dedup state machine all mutate several interdependent fields together
36
+ * (see the inline comments on `anonymousMessageEpoch` and
37
+ * `stripDuplicateArtifactText` for the specific invariants), so pulling
38
+ * those out into standalone functions would mean designing an explicit
39
+ * reducer over the whole stream state rather than extracting a helper —
40
+ * a larger, riskier change than this pass makes. They stay as closures,
41
+ * exercised only through `feed`/`flush` in the test suite.
28
42
  */
29
43
  import { createRoleMarkerGuard } from './role-marker-guard.js';
30
44
  function isRecord(value) {
31
45
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
32
46
  }
47
+ const WRITE_TOOL_NAMES = new Set(['Write', 'Edit', 'write_file', 'replace']);
48
+ export function toolInputPath(input) {
49
+ if (typeof input.file_path === 'string')
50
+ return input.file_path;
51
+ if (typeof input.path === 'string')
52
+ return input.path;
53
+ return '';
54
+ }
55
+ // Runtime-task status aliases the Claude Code wire format has used across
56
+ // versions, normalized to the four statuses the UI understands.
57
+ const TASK_STATUS_ALIASES = {
58
+ completed: 'completed',
59
+ in_progress: 'in_progress',
60
+ stopped: 'stopped',
61
+ complete: 'completed',
62
+ done: 'completed',
63
+ doing: 'in_progress',
64
+ active: 'in_progress',
65
+ failed: 'stopped',
66
+ canceled: 'stopped',
67
+ cancelled: 'stopped',
68
+ };
69
+ export function normalizeTaskStatus(value) {
70
+ if (typeof value !== 'string')
71
+ return 'pending';
72
+ return TASK_STATUS_ALIASES[value] ?? 'pending';
73
+ }
74
+ function nextGeneratedRuntimeTaskId(counter) {
75
+ // Coverage-driven refactor (2026-07-18, no behavior change): the
76
+ // original OD source guarded the returned id with
77
+ // `while (runtimeTasks.has(String(nextRuntimeTaskId))) nextRuntimeTaskId += 1;`.
78
+ // Tracing every path that can insert a numeric id into `runtimeTasks`
79
+ // shows the guard can never trigger: the only two insertion paths are
80
+ // (a) this function itself, which always hands out the current
81
+ // `nextRuntimeTaskId` and then increments it, so that exact value is
82
+ // never in the map yet; and (b) `runtimeTaskIdFromCreate`'s
83
+ // explicit-id branch, which bumps `nextRuntimeTaskId` to
84
+ // `numericId + 1` (strictly past itself) BEFORE this function could
85
+ // ever be asked to reuse that id. Since `nextRuntimeTaskId` only ever
86
+ // increases, it can never equal an id already present in the map at
87
+ // this point. Removed per the coverage skill's Phase 6.5 "dead
88
+ // branch" classification rather than fake a test for an unreachable
89
+ // loop; see source-map.md.
90
+ const id = String(counter.next);
91
+ counter.next += 1;
92
+ return id;
93
+ }
94
+ export function runtimeTaskIdFromCreate(input, counter) {
95
+ if (typeof input.taskId === 'string' && input.taskId) {
96
+ const numericId = Number(input.taskId);
97
+ if (Number.isSafeInteger(numericId) && numericId >= counter.next) {
98
+ counter.next = numericId + 1;
99
+ }
100
+ return input.taskId;
101
+ }
102
+ return nextGeneratedRuntimeTaskId(counter);
103
+ }
104
+ export function applyTaskCreate(input, tasks, counter) {
105
+ const content = typeof input.subject === 'string'
106
+ ? input.subject
107
+ : typeof input.description === 'string'
108
+ ? input.description
109
+ : '';
110
+ if (!content)
111
+ return false;
112
+ const id = runtimeTaskIdFromCreate(input, counter);
113
+ const activeForm = typeof input.activeForm === 'string' ? input.activeForm : undefined;
114
+ tasks.set(id, {
115
+ id,
116
+ content,
117
+ status: normalizeTaskStatus(input.status),
118
+ ...(activeForm ? { activeForm } : {}),
119
+ });
120
+ return true;
121
+ }
122
+ export function applyTaskUpdate(input, tasks) {
123
+ if (typeof input.taskId !== 'string')
124
+ return false;
125
+ const existing = tasks.get(input.taskId);
126
+ if (!existing)
127
+ return false;
128
+ const content = typeof input.subject === 'string'
129
+ ? input.subject
130
+ : typeof input.description === 'string'
131
+ ? input.description
132
+ : existing.content;
133
+ const activeForm = typeof input.activeForm === 'string' ? input.activeForm : existing.activeForm;
134
+ tasks.set(input.taskId, {
135
+ ...existing,
136
+ content,
137
+ status: normalizeTaskStatus(input.status),
138
+ ...(activeForm ? { activeForm } : {}),
139
+ });
140
+ return true;
141
+ }
142
+ export function emitCanonicalTaskSnapshot(toolUseId, name, input, registry, onEvent) {
143
+ if (typeof toolUseId !== 'string' || typeof name !== 'string' || !isRecord(input))
144
+ return false;
145
+ if (registry.seenToolUseIds.has(toolUseId))
146
+ return true;
147
+ const applied = name === 'TaskCreate'
148
+ ? applyTaskCreate(input, registry.tasks, registry.counter)
149
+ : name === 'TaskUpdate'
150
+ ? applyTaskUpdate(input, registry.tasks)
151
+ : false;
152
+ if (!applied)
153
+ return false;
154
+ // Coverage-driven refactor (2026-07-18, no behavior change): the
155
+ // original OD source guarded this emission with
156
+ // `if (!changed || runtimeTasks.size === 0) return false;`. Tracing
157
+ // every path into this point shows both conditions are unreachable —
158
+ // the `TaskCreate` and `TaskUpdate` branches above only fall through
159
+ // to here after an unconditional `runtimeTasks.set(...)` (so `size`
160
+ // can never be 0), and every early-return path above returns before
161
+ // reaching here (so the "changed" flag those returns guarded was
162
+ // always true here). Removed the dead double-guard per the coverage
163
+ // skill's Phase 6.5 "dead branch" classification rather than fake a
164
+ // test for an unreachable condition; see source-map.md.
165
+ registry.seenToolUseIds.add(toolUseId);
166
+ onEvent({
167
+ type: 'tool_use',
168
+ id: `${toolUseId}:todo-task`,
169
+ name: 'TodoWrite',
170
+ input: {
171
+ todos: Array.from(registry.tasks.values()).map(({ content, status, activeForm }) => ({
172
+ content,
173
+ status,
174
+ ...(activeForm ? { activeForm } : {}),
175
+ })),
176
+ },
177
+ });
178
+ return true;
179
+ }
180
+ export function isFileWriteToolUse(name, input) {
181
+ if (typeof name !== 'string' || !isRecord(input))
182
+ return false;
183
+ if (!WRITE_TOOL_NAMES.has(name))
184
+ return false;
185
+ if (/\.(html|htm|css|js|jsx|ts|tsx|md)$/iu.test(toolInputPath(input)))
186
+ return true;
187
+ return typeof input.content === 'string' || typeof input.new_string === 'string';
188
+ }
189
+ // Coverage-driven refactor (2026-07-18, no behavior change): both
190
+ // `fileWriteContent` and `isHtmlWriteToolInput` originally opened with
191
+ // their own `if (!isRecord(input)) return ...;` guard. Each function
192
+ // has exactly one call site (`emitToolUse`, for both), and both call
193
+ // sites already sit inside an `if (isFileWriteToolUse(name, input))`
194
+ // block, which itself already asserted `isRecord(input)` before
195
+ // returning true. So `input` is always a record by the time either
196
+ // function runs — the guards duplicated a check the shared caller
197
+ // already made. Removed the runtime early-return per the coverage
198
+ // skill's Phase 6.5 "dead branch" classification rather than fake a
199
+ // test with a non-record `input` neither function can ever actually
200
+ // receive (see source-map.md), replaced by a one-line type assertion
201
+ // (classification 4: TS-required, no real runtime path) so the
202
+ // parameter can stay `unknown` without disturbing the call sites.
203
+ export function fileWriteContent(input) {
204
+ const record = input;
205
+ if (typeof record.content === 'string')
206
+ return record.content;
207
+ if (typeof record.new_string === 'string')
208
+ return record.new_string;
209
+ return null;
210
+ }
211
+ export function isHtmlWriteToolInput(input) {
212
+ const record = input;
213
+ const rawPath = record.file_path ?? record.filePath;
214
+ if (typeof rawPath === 'string' && /\.(?:html?|xhtml)$/i.test(rawPath))
215
+ return true;
216
+ const content = fileWriteContent(input);
217
+ return typeof content === 'string' && /<!doctype\s+html\b|<html\b/i.test(content);
218
+ }
219
+ export function artifactOpenCandidateLength(text, openTag) {
220
+ const max = Math.min(openTag.length - 1, text.length);
221
+ for (let len = max; len > 0; len -= 1) {
222
+ if (openTag.startsWith(text.slice(-len)))
223
+ return len;
224
+ }
225
+ return 0;
226
+ }
227
+ function isHtmlArtifact(candidate) {
228
+ const openTag = candidate.slice(0, Math.max(0, candidate.indexOf('>') + 1));
229
+ return /\btype\s*=\s*["']text\/html["']/i.test(openTag);
230
+ }
231
+ function normalizeArtifactEchoContent(value) {
232
+ return value
233
+ .replace(/^\uFEFF/, '')
234
+ .replace(/\r\n?/g, '\n')
235
+ .replace(/^(?:\s|\\r|\\n)+|(?:\s|\\r|\\n)+$/g, '');
236
+ }
237
+ export function isRedundantWrittenArtifact(candidate, ctx) {
238
+ const gt = candidate.indexOf('>');
239
+ const close = candidate.lastIndexOf('</artifact>');
240
+ if (gt === -1 || close === -1 || close <= gt)
241
+ return false;
242
+ if (ctx.suppressHtmlArtifactsAfterFileWrite && isHtmlArtifact(candidate) && ctx.wroteHtmlFileThisTurn) {
243
+ return true;
244
+ }
245
+ const body = normalizeArtifactEchoContent(candidate.slice(gt + 1, close));
246
+ return ctx.recentWriteContents.some((content) => content === body);
247
+ }
248
+ export function assistantText(content) {
249
+ const parts = [];
250
+ for (const block of content) {
251
+ if (isRecord(block) && block.type === 'text' && typeof block.text === 'string') {
252
+ parts.push(block.text);
253
+ }
254
+ }
255
+ return parts.join('\n').trim();
256
+ }
257
+ export function isUnstreamedTextBlock(block, alreadyStreamed) {
258
+ return !alreadyStreamed && block.type === 'text' && typeof block.text === 'string' && block.text.length > 0;
259
+ }
260
+ export function isUnstreamedThinkingBlock(block, alreadyStreamed) {
261
+ return (!alreadyStreamed && block.type === 'thinking' && typeof block.thinking === 'string' && block.thinking.length > 0);
262
+ }
263
+ export function handleSystemMessage(obj, onEvent) {
264
+ if (obj.subtype === 'init') {
265
+ onEvent({
266
+ type: 'status',
267
+ label: 'initializing',
268
+ model: obj.model ?? null,
269
+ sessionId: obj.session_id ?? null,
270
+ });
271
+ return;
272
+ }
273
+ if (obj.subtype === 'status') {
274
+ onEvent({ type: 'status', label: typeof obj.status === 'string' ? obj.status : 'working' });
275
+ }
276
+ }
277
+ export function stringifyToolResult(content) {
278
+ if (typeof content === 'string')
279
+ return content;
280
+ if (Array.isArray(content)) {
281
+ return content
282
+ .map((c) => (isRecord(c) && c.type === 'text' ? String(c.text) : JSON.stringify(c)))
283
+ .join('\n');
284
+ }
285
+ return JSON.stringify(content);
286
+ }
287
+ // `user` messages in a stream-json transcript are usually tool_result
288
+ // wrappers from prior turns.
289
+ export function handleUserMessage(obj, onEvent) {
290
+ if (!isRecord(obj.message) || !Array.isArray(obj.message.content))
291
+ return;
292
+ for (const block of obj.message.content) {
293
+ if (!isRecord(block))
294
+ continue;
295
+ if (block.type === 'tool_result') {
296
+ onEvent({
297
+ type: 'tool_result',
298
+ toolUseId: block.tool_use_id,
299
+ content: stringifyToolResult(block.content),
300
+ isError: Boolean(block.is_error),
301
+ });
302
+ }
303
+ }
304
+ }
305
+ export function applyInputJsonDelta(state, partialJson, onEvent) {
306
+ if (!state || state.type !== 'tool_use')
307
+ return;
308
+ state.input += partialJson;
309
+ if (typeof state.id === 'string' && typeof state.name === 'string') {
310
+ onEvent({
311
+ type: 'tool_input_delta',
312
+ id: state.id,
313
+ name: state.name,
314
+ delta: partialJson,
315
+ });
316
+ }
317
+ }
318
+ export function emitAssistantErrorIfPresent(obj, content, onEvent) {
319
+ if (typeof obj.error !== 'string' || !obj.error.trim())
320
+ return;
321
+ onEvent({
322
+ type: 'error',
323
+ message: assistantText(content) || obj.error,
324
+ code: obj.error,
325
+ });
326
+ }
33
327
  export function createClaudeStreamHandler(onEvent, options = {}) {
34
328
  let buffer = '';
35
329
  // Per-content-block scratch, keyed by `${messageId}:${blockIndex}`.
@@ -58,11 +352,24 @@ export function createClaudeStreamHandler(onEvent, options = {}) {
58
352
  // Remember the message id that already emitted `turn_end` so accepting
59
353
  // both wire shapes cannot double-trigger stdin continuation/closure.
60
354
  let turnEndSignature = null;
355
+ // Stands in for `currentMessageId` in the dedup key when a frame carried no
356
+ // message id at all. Without it the key is null and the guard below cannot
357
+ // fire, so an id-less assistant wrapper plus the same-reason `result` frame
358
+ // that follows it emit two identical `turn_end` events. It is bumped only at
359
+ // `message_start` and at an id-less `assistant` wrapper — the two frames that
360
+ // actually begin a new assistant message — and deliberately NOT at
361
+ // `message_delta`/`result`, which report on the message already in flight.
362
+ // Bumping it on a real message boundary is what keeps this from over-deduping:
363
+ // two consecutive id-less `tool_use` turns still emit twice, and a suppressed
364
+ // `tool_use` turn_end would strand the daemon's stdin-close handler.
365
+ let anonymousMessageEpoch = 0;
61
366
  // Per-message role-marker guards for cross-chunk detection (#3247).
62
367
  const roleGuards = new Map();
63
- const runtimeTasks = new Map();
64
- const canonicalTaskToolUseIds = new Set();
65
- let nextRuntimeTaskId = 1;
368
+ const taskRegistry = {
369
+ tasks: new Map(),
370
+ counter: { next: 1 },
371
+ seenToolUseIds: new Set(),
372
+ };
66
373
  let suppressNextArtifactText = false;
67
374
  let suppressDuplicateArtifactText = false;
68
375
  let artifactOpenCandidate = '';
@@ -71,8 +378,11 @@ export function createClaudeStreamHandler(onEvent, options = {}) {
71
378
  const recentWriteContents = [];
72
379
  let wroteHtmlFileThisTurn = false;
73
380
  function emitTurnEnd(stopReason) {
74
- const signature = currentMessageId === null ? null : `${currentMessageId}\u0000${stopReason}`;
75
- if (signature !== null && turnEndSignature === signature)
381
+ // `\u0001` prefixes the synthetic key so it can never collide with a real
382
+ // message id, which is why this is safe to compare in the same slot.
383
+ const messageKey = currentMessageId ?? `\u0001anon${anonymousMessageEpoch}`;
384
+ const signature = `${messageKey}\u0000${stopReason}`;
385
+ if (turnEndSignature === signature)
76
386
  return;
77
387
  onEvent({ type: 'turn_end', stopReason });
78
388
  turnEndSignature = signature;
@@ -81,120 +391,8 @@ export function createClaudeStreamHandler(onEvent, options = {}) {
81
391
  wroteHtmlFileThisTurn = false;
82
392
  }
83
393
  }
84
- function normalizeTaskStatus(value) {
85
- if (value === 'completed' || value === 'in_progress' || value === 'stopped') {
86
- return value;
87
- }
88
- if (value === 'complete' || value === 'done')
89
- return 'completed';
90
- if (value === 'doing' || value === 'active')
91
- return 'in_progress';
92
- if (value === 'failed' || value === 'canceled' || value === 'cancelled')
93
- return 'stopped';
94
- return 'pending';
95
- }
96
- function nextGeneratedRuntimeTaskId() {
97
- // Coverage-driven refactor (2026-07-18, no behavior change): the
98
- // original OD source guarded the returned id with
99
- // `while (runtimeTasks.has(String(nextRuntimeTaskId))) nextRuntimeTaskId += 1;`.
100
- // Tracing every path that can insert a numeric id into `runtimeTasks`
101
- // shows the guard can never trigger: the only two insertion paths are
102
- // (a) this function itself, which always hands out the current
103
- // `nextRuntimeTaskId` and then increments it, so that exact value is
104
- // never in the map yet; and (b) `runtimeTaskIdFromCreate`'s
105
- // explicit-id branch, which bumps `nextRuntimeTaskId` to
106
- // `numericId + 1` (strictly past itself) BEFORE this function could
107
- // ever be asked to reuse that id. Since `nextRuntimeTaskId` only ever
108
- // increases, it can never equal an id already present in the map at
109
- // this point. Removed per the coverage skill's Phase 6.5 "dead
110
- // branch" classification rather than fake a test for an unreachable
111
- // loop; see source-map.md.
112
- const id = String(nextRuntimeTaskId);
113
- nextRuntimeTaskId += 1;
114
- return id;
115
- }
116
- function runtimeTaskIdFromCreate(input) {
117
- if (typeof input.taskId === 'string' && input.taskId) {
118
- const numericId = Number(input.taskId);
119
- if (Number.isSafeInteger(numericId) && numericId >= nextRuntimeTaskId) {
120
- nextRuntimeTaskId = numericId + 1;
121
- }
122
- return input.taskId;
123
- }
124
- return nextGeneratedRuntimeTaskId();
125
- }
126
- function emitCanonicalTaskSnapshot(toolUseId, name, input) {
127
- if (typeof toolUseId !== 'string' || typeof name !== 'string' || !isRecord(input))
128
- return false;
129
- if (canonicalTaskToolUseIds.has(toolUseId))
130
- return true;
131
- if (name === 'TaskCreate') {
132
- const content = typeof input.subject === 'string'
133
- ? input.subject
134
- : typeof input.description === 'string'
135
- ? input.description
136
- : '';
137
- if (!content)
138
- return false;
139
- const id = runtimeTaskIdFromCreate(input);
140
- const activeForm = typeof input.activeForm === 'string' ? input.activeForm : undefined;
141
- runtimeTasks.set(id, {
142
- id,
143
- content,
144
- status: normalizeTaskStatus(input.status),
145
- ...(activeForm ? { activeForm } : {}),
146
- });
147
- }
148
- else if (name === 'TaskUpdate') {
149
- if (typeof input.taskId !== 'string')
150
- return false;
151
- const existing = runtimeTasks.get(input.taskId);
152
- if (!existing)
153
- return false;
154
- const content = typeof input.subject === 'string'
155
- ? input.subject
156
- : typeof input.description === 'string'
157
- ? input.description
158
- : existing.content;
159
- const activeForm = typeof input.activeForm === 'string' ? input.activeForm : existing.activeForm;
160
- runtimeTasks.set(input.taskId, {
161
- ...existing,
162
- content,
163
- status: normalizeTaskStatus(input.status),
164
- ...(activeForm ? { activeForm } : {}),
165
- });
166
- }
167
- else {
168
- return false;
169
- }
170
- // Coverage-driven refactor (2026-07-18, no behavior change): the
171
- // original OD source guarded this emission with
172
- // `if (!changed || runtimeTasks.size === 0) return false;`. Tracing
173
- // every path into this point shows both conditions are unreachable —
174
- // the `TaskCreate` and `TaskUpdate` branches above only fall through
175
- // to here after an unconditional `runtimeTasks.set(...)` (so `size`
176
- // can never be 0), and every early-return path above returns before
177
- // reaching here (so the "changed" flag those returns guarded was
178
- // always true here). Removed the dead double-guard per the coverage
179
- // skill's Phase 6.5 "dead branch" classification rather than fake a
180
- // test for an unreachable condition; see source-map.md.
181
- canonicalTaskToolUseIds.add(toolUseId);
182
- onEvent({
183
- type: 'tool_use',
184
- id: `${toolUseId}:todo-task`,
185
- name: 'TodoWrite',
186
- input: {
187
- todos: Array.from(runtimeTasks.values()).map(({ content, status, activeForm }) => ({
188
- content,
189
- status,
190
- ...(activeForm ? { activeForm } : {}),
191
- })),
192
- },
193
- });
194
- return true;
195
- }
196
394
  function emitToolUse(id, name, input) {
197
- if (emitCanonicalTaskSnapshot(id, name, input))
395
+ if (emitCanonicalTaskSnapshot(id, name, input, taskRegistry, onEvent))
198
396
  return;
199
397
  if (isFileWriteToolUse(name, input)) {
200
398
  suppressNextArtifactText = true;
@@ -230,6 +428,14 @@ export function createClaudeStreamHandler(onEvent, options = {}) {
230
428
  // any compensating security benefit. See PR #3303 review
231
429
  // r3324xxxxxx. Thinking is passed through unguarded; only the
232
430
  // user-visible text channel is policed.
431
+ function getOrCreateRoleGuard(msgId) {
432
+ let guard = roleGuards.get(msgId);
433
+ if (!guard) {
434
+ guard = createRoleMarkerGuard(msgId);
435
+ roleGuards.set(msgId, guard);
436
+ }
437
+ return guard;
438
+ }
233
439
  function emitSafeText(msgId, text, eventType = 'text_delta') {
234
440
  if (eventType === 'text_delta') {
235
441
  text = stripDuplicateArtifactText(text);
@@ -240,11 +446,7 @@ export function createClaudeStreamHandler(onEvent, options = {}) {
240
446
  onEvent({ type: eventType, delta: text });
241
447
  return;
242
448
  }
243
- let guard = roleGuards.get(msgId);
244
- if (!guard) {
245
- guard = createRoleMarkerGuard(msgId);
246
- roleGuards.set(msgId, guard);
247
- }
449
+ const guard = getOrCreateRoleGuard(msgId);
248
450
  if (guard.contaminated)
249
451
  return;
250
452
  const safe = guard.feedText(text);
@@ -257,6 +459,51 @@ export function createClaudeStreamHandler(onEvent, options = {}) {
257
459
  onEvent(warn);
258
460
  }
259
461
  }
462
+ // Resolves an in-progress `<artifact>...</artifact>` echo once its close
463
+ // tag has arrived: drops it if it duplicates a recent file write, then
464
+ // resumes stripping on whatever text followed the close tag.
465
+ function resolveDuplicateArtifactCandidate(current) {
466
+ duplicateArtifactCandidate += current;
467
+ const closeIndex = duplicateArtifactCandidate.indexOf('</artifact>');
468
+ if (closeIndex === -1)
469
+ return '';
470
+ const closeEnd = closeIndex + '</artifact>'.length;
471
+ const candidate = duplicateArtifactCandidate.slice(0, closeEnd);
472
+ const rest = duplicateArtifactCandidate.slice(closeEnd);
473
+ duplicateArtifactCandidate = '';
474
+ suppressDuplicateArtifactText = false;
475
+ suppressNextArtifactText = false;
476
+ const duplicate = isRedundantWrittenArtifact(candidate, {
477
+ suppressHtmlArtifactsAfterFileWrite: options.suppressHtmlArtifactsAfterFileWrite === true,
478
+ wroteHtmlFileThisTurn,
479
+ recentWriteContents,
480
+ });
481
+ if (options.suppressHtmlArtifactsAfterFileWrite !== true) {
482
+ recentWriteContents.length = 0;
483
+ }
484
+ return `${duplicate ? '' : candidate}${stripDuplicateArtifactText(rest)}`;
485
+ }
486
+ // No open `<artifact` tag has been seen yet: either buffer a partial tag
487
+ // prefix at the end of `current` so it can match across chunk boundaries,
488
+ // or pass the text through untouched.
489
+ function bufferPartialArtifactOpenTag(current, openTag) {
490
+ const candidateLength = artifactOpenCandidateLength(current, openTag);
491
+ if ((suppressNextArtifactText || recentWriteContents.length > 0) && candidateLength > 0) {
492
+ artifactOpenCandidate = current.slice(-candidateLength);
493
+ return current.slice(0, -candidateLength);
494
+ }
495
+ return current;
496
+ }
497
+ // An `<artifact` open tag just arrived: start accumulating it as a
498
+ // duplicate-write candidate and flush whatever text preceded it.
499
+ function startDuplicateArtifactCandidate(current, openIndex) {
500
+ suppressDuplicateArtifactText = true;
501
+ suppressNextArtifactText = false;
502
+ duplicateArtifactCandidate = current.slice(openIndex);
503
+ const prefix = `${pendingArtifactText}${current.slice(0, openIndex)}`;
504
+ pendingArtifactText = '';
505
+ return `${prefix}${stripDuplicateArtifactText('')}`;
506
+ }
260
507
  function stripDuplicateArtifactText(text) {
261
508
  if (!suppressNextArtifactText &&
262
509
  !suppressDuplicateArtifactText &&
@@ -268,115 +515,13 @@ export function createClaudeStreamHandler(onEvent, options = {}) {
268
515
  const current = `${artifactOpenCandidate}${text}`;
269
516
  artifactOpenCandidate = '';
270
517
  if (suppressDuplicateArtifactText) {
271
- duplicateArtifactCandidate += current;
272
- const closeIndex = duplicateArtifactCandidate.indexOf('</artifact>');
273
- if (closeIndex === -1)
274
- return '';
275
- const closeEnd = closeIndex + '</artifact>'.length;
276
- const candidate = duplicateArtifactCandidate.slice(0, closeEnd);
277
- const rest = duplicateArtifactCandidate.slice(closeEnd);
278
- duplicateArtifactCandidate = '';
279
- suppressDuplicateArtifactText = false;
280
- suppressNextArtifactText = false;
281
- const duplicate = isRedundantWrittenArtifact(candidate);
282
- if (options.suppressHtmlArtifactsAfterFileWrite !== true) {
283
- recentWriteContents.length = 0;
284
- }
285
- return `${duplicate ? '' : candidate}${stripDuplicateArtifactText(rest)}`;
518
+ return resolveDuplicateArtifactCandidate(current);
286
519
  }
287
520
  const openIndex = current.indexOf(openTag);
288
521
  if (openIndex === -1) {
289
- const candidateLength = artifactOpenCandidateLength(current, openTag);
290
- if ((suppressNextArtifactText || recentWriteContents.length > 0) && candidateLength > 0) {
291
- artifactOpenCandidate = current.slice(-candidateLength);
292
- return current.slice(0, -candidateLength);
293
- }
294
- return current;
522
+ return bufferPartialArtifactOpenTag(current, openTag);
295
523
  }
296
- suppressDuplicateArtifactText = true;
297
- suppressNextArtifactText = false;
298
- duplicateArtifactCandidate = current.slice(openIndex);
299
- const prefix = `${pendingArtifactText}${current.slice(0, openIndex)}`;
300
- pendingArtifactText = '';
301
- return `${prefix}${stripDuplicateArtifactText('')}`;
302
- }
303
- function isRedundantWrittenArtifact(candidate) {
304
- const gt = candidate.indexOf('>');
305
- const close = candidate.lastIndexOf('</artifact>');
306
- if (gt === -1 || close === -1 || close <= gt)
307
- return false;
308
- if (options.suppressHtmlArtifactsAfterFileWrite === true &&
309
- isHtmlArtifact(candidate) &&
310
- wroteHtmlFileThisTurn)
311
- return true;
312
- const body = normalizeArtifactEchoContent(candidate.slice(gt + 1, close));
313
- return recentWriteContents.some((content) => content === body);
314
- }
315
- function isHtmlArtifact(candidate) {
316
- const openTag = candidate.slice(0, Math.max(0, candidate.indexOf('>') + 1));
317
- return /\btype\s*=\s*["']text\/html["']/i.test(openTag);
318
- }
319
- function normalizeArtifactEchoContent(value) {
320
- return value
321
- .replace(/^\uFEFF/, '')
322
- .replace(/\r\n?/g, '\n')
323
- .replace(/^(?:\s|\\r|\\n)+|(?:\s|\\r|\\n)+$/g, '');
324
- }
325
- function artifactOpenCandidateLength(text, openTag) {
326
- const max = Math.min(openTag.length - 1, text.length);
327
- for (let len = max; len > 0; len -= 1) {
328
- if (openTag.startsWith(text.slice(-len)))
329
- return len;
330
- }
331
- return 0;
332
- }
333
- function isFileWriteToolUse(name, input) {
334
- if (typeof name !== 'string' || !isRecord(input))
335
- return false;
336
- const path = typeof input.file_path === 'string'
337
- ? input.file_path
338
- : typeof input.path === 'string'
339
- ? input.path
340
- : '';
341
- const writesFile = name === 'Write' ||
342
- name === 'Edit' ||
343
- name === 'write_file' ||
344
- name === 'replace';
345
- if (!writesFile)
346
- return false;
347
- if (/\.(html|htm|css|js|jsx|ts|tsx|md)$/iu.test(path))
348
- return true;
349
- return typeof input.content === 'string' || typeof input.new_string === 'string';
350
- }
351
- // Coverage-driven refactor (2026-07-18, no behavior change): both
352
- // `fileWriteContent` and `isHtmlWriteToolInput` originally opened with
353
- // their own `if (!isRecord(input)) return ...;` guard. Each function
354
- // has exactly one call site (`emitToolUse`, for both), and both call
355
- // sites already sit inside an `if (isFileWriteToolUse(name, input))`
356
- // block, which itself already asserted `isRecord(input)` before
357
- // returning true. So `input` is always a record by the time either
358
- // function runs — the guards duplicated a check the shared caller
359
- // already made. Removed the runtime early-return per the coverage
360
- // skill's Phase 6.5 "dead branch" classification rather than fake a
361
- // test with a non-record `input` neither function can ever actually
362
- // receive (see source-map.md), replaced by a one-line type assertion
363
- // (classification 4: TS-required, no real runtime path) so the
364
- // parameter can stay `unknown` without disturbing the call sites.
365
- function fileWriteContent(input) {
366
- const record = input;
367
- if (typeof record.content === 'string')
368
- return record.content;
369
- if (typeof record.new_string === 'string')
370
- return record.new_string;
371
- return null;
372
- }
373
- function isHtmlWriteToolInput(input) {
374
- const record = input;
375
- const rawPath = record.file_path ?? record.filePath;
376
- if (typeof rawPath === 'string' && /\.(?:html?|xhtml)$/i.test(rawPath))
377
- return true;
378
- const content = fileWriteContent(input);
379
- return typeof content === 'string' && /<!doctype\s+html\b|<html\b/i.test(content);
524
+ return startDuplicateArtifactCandidate(current, openIndex);
380
525
  }
381
526
  function feed(chunk) {
382
527
  buffer += chunk;
@@ -410,232 +555,224 @@ export function createClaudeStreamHandler(onEvent, options = {}) {
410
555
  }
411
556
  flushPendingArtifactText();
412
557
  }
413
- function handleObject(obj) {
414
- if (!isRecord(obj))
558
+ function handleToolUseBlock(block) {
559
+ if (typeof block.id === 'string' && streamedToolUseIds.has(block.id))
415
560
  return;
416
- if (obj.type === 'system' && obj.subtype === 'init') {
417
- onEvent({
418
- type: 'status',
419
- label: 'initializing',
420
- model: obj.model ?? null,
421
- sessionId: obj.session_id ?? null,
422
- });
561
+ emitToolUse(block.id, block.name, block.input ?? null);
562
+ }
563
+ function handleAssistantContentBlock(block, ctx) {
564
+ if (!isRecord(block))
423
565
  return;
424
- }
425
- if (obj.type === 'system' && obj.subtype === 'status') {
426
- onEvent({ type: 'status', label: typeof obj.status === 'string' ? obj.status : 'working' });
566
+ if (block.type === 'tool_use') {
567
+ handleToolUseBlock(block);
427
568
  return;
428
569
  }
429
- if (obj.type === 'stream_event' && isRecord(obj.event)) {
430
- handleStreamEvent(obj.event);
570
+ if (isUnstreamedTextBlock(block, ctx.textAlreadyStreamed)) {
571
+ emitSafeText(ctx.textMsgId, block.text);
431
572
  return;
432
573
  }
433
- // `assistant` messages are the "block finished" signal for the current
434
- // content block. For tool_use blocks whose input finished assembling,
435
- // emit tool_use now with the final parsed input. For text blocks, emit
436
- // the text as a single delta — but only if no streaming deltas already
437
- // covered it (older Claude Code without --include-partial-messages
438
- // delivers text only here; newer builds stream it and would duplicate).
439
- if (obj.type === 'assistant' && isRecord(obj.message) && Array.isArray(obj.message.content)) {
440
- const explicitMsgId = typeof obj.message.id === 'string' ? obj.message.id : null;
441
- const textMsgId = explicitMsgId ?? (currentMessageStreamedText ? currentMessageId : null);
442
- const thinkingMsgId = explicitMsgId ?? (currentMessageStreamedThinking ? currentMessageId : null);
443
- if (explicitMsgId)
444
- currentMessageId = explicitMsgId;
445
- const textAlreadyStreamed = textMsgId ? textStreamed.has(textMsgId) : false;
446
- const thinkingAlreadyStreamed = thinkingMsgId ? thinkingStreamed.has(thinkingMsgId) : false;
447
- // Per-turn `stop_reason` is emitted as `turn_end` AFTER the content
448
- // blocks have been processed (see below). When `--include-partial-
449
- // messages` is unsupported, tool_use events surface only from the
450
- // assistant wrapper here — emitting `turn_end` before that loop would
451
- // let the daemon's stdin-close handler act on the turn before its
452
- // tool_use blocks were seen, closing stdin mid-tool. Read the
453
- // stop_reason now, emit after.
454
- const stopReason = typeof obj.message.stop_reason === 'string'
455
- ? obj.message.stop_reason
456
- : null;
457
- for (const block of obj.message.content) {
458
- if (!isRecord(block))
459
- continue;
460
- if (block.type === 'tool_use') {
461
- if (typeof block.id === 'string' && streamedToolUseIds.has(block.id)) {
462
- continue;
463
- }
464
- emitToolUse(block.id, block.name, block.input ?? null);
465
- }
466
- else if (!textAlreadyStreamed &&
467
- block.type === 'text' &&
468
- typeof block.text === 'string' &&
469
- block.text.length > 0) {
470
- emitSafeText(textMsgId, block.text);
471
- }
472
- else if (!thinkingAlreadyStreamed &&
473
- block.type === 'thinking' &&
474
- typeof block.thinking === 'string' &&
475
- block.thinking.length > 0) {
476
- emitSafeText(thinkingMsgId, block.thinking, 'thinking_delta');
477
- }
478
- }
479
- // Surface the turn_end signal now that every tool_use in this
480
- // assistant message has been emitted, so the daemon's stdin-close
481
- // handler sees the final `stop_reason` before deciding whether to
482
- // close stream-json input stdin.
483
- if (stopReason) {
484
- emitTurnEnd(stopReason);
485
- }
486
- if (typeof obj.error === 'string' && obj.error.trim()) {
487
- onEvent({
488
- type: 'error',
489
- message: assistantText(obj.message.content) || obj.error,
490
- code: obj.error,
491
- });
492
- }
493
- currentMessageStreamedText = false;
494
- currentMessageStreamedThinking = false;
495
- return;
574
+ if (isUnstreamedThinkingBlock(block, ctx.thinkingAlreadyStreamed)) {
575
+ emitSafeText(ctx.thinkingMsgId, block.thinking, 'thinking_delta');
496
576
  }
497
- // `user` messages in a stream-json transcript are usually tool_result
498
- // wrappers from prior turns.
499
- if (obj.type === 'user' && isRecord(obj.message) && Array.isArray(obj.message.content)) {
500
- for (const block of obj.message.content) {
501
- if (!isRecord(block))
502
- continue;
503
- if (block.type === 'tool_result') {
504
- onEvent({
505
- type: 'tool_result',
506
- toolUseId: block.tool_use_id,
507
- content: stringifyToolResult(block.content),
508
- isError: Boolean(block.is_error),
509
- });
510
- }
511
- }
577
+ }
578
+ // Resolves the message id assistant text/thinking blocks should be
579
+ // attributed to (an explicit id on the wrapper, or the currently-tracked
580
+ // streaming id), and whether that channel already streamed via deltas.
581
+ // Also owns the message-id/epoch bookkeeping side effects that must
582
+ // happen exactly once per assistant wrapper.
583
+ function resolveAssistantMessageIds(message) {
584
+ const explicitMsgId = typeof message.id === 'string' ? message.id : null;
585
+ const textMsgId = explicitMsgId ?? (currentMessageStreamedText ? currentMessageId : null);
586
+ const thinkingMsgId = explicitMsgId ?? (currentMessageStreamedThinking ? currentMessageId : null);
587
+ if (explicitMsgId)
588
+ currentMessageId = explicitMsgId;
589
+ // An id-less assistant wrapper is itself a new message boundary: no
590
+ // `message_start` announced it, so this is the only place the epoch can
591
+ // advance before its own `turn_end` is emitted below. See
592
+ // `anonymousMessageEpoch`'s declaration for why message_delta/result must
593
+ // NOT advance it.
594
+ if (currentMessageId === null)
595
+ anonymousMessageEpoch += 1;
596
+ return {
597
+ textMsgId,
598
+ thinkingMsgId,
599
+ textAlreadyStreamed: textMsgId ? textStreamed.has(textMsgId) : false,
600
+ thinkingAlreadyStreamed: thinkingMsgId ? thinkingStreamed.has(thinkingMsgId) : false,
601
+ };
602
+ }
603
+ // `assistant` messages are the "block finished" signal for the current
604
+ // content block. For tool_use blocks whose input finished assembling,
605
+ // emit tool_use now with the final parsed input. For text blocks, emit
606
+ // the text as a single delta — but only if no streaming deltas already
607
+ // covered it (older Claude Code without --include-partial-messages
608
+ // delivers text only here; newer builds stream it and would duplicate).
609
+ function handleAssistantMessage(obj) {
610
+ const message = obj.message;
611
+ if (!isRecord(message) || !Array.isArray(message.content))
512
612
  return;
613
+ const { textMsgId, thinkingMsgId, textAlreadyStreamed, thinkingAlreadyStreamed } = resolveAssistantMessageIds(message);
614
+ // Per-turn `stop_reason` is emitted as `turn_end` AFTER the content
615
+ // blocks have been processed (see below). When `--include-partial-
616
+ // messages` is unsupported, tool_use events surface only from the
617
+ // assistant wrapper here — emitting `turn_end` before that loop would
618
+ // let the daemon's stdin-close handler act on the turn before its
619
+ // tool_use blocks were seen, closing stdin mid-tool. Read the
620
+ // stop_reason now, emit after.
621
+ const stopReason = typeof message.stop_reason === 'string' ? message.stop_reason : null;
622
+ for (const block of message.content) {
623
+ handleAssistantContentBlock(block, { textMsgId, thinkingMsgId, textAlreadyStreamed, thinkingAlreadyStreamed });
513
624
  }
514
- if (obj.type === 'result') {
515
- // Explicit annotation: the `&&`/`||` chain's inferred type includes a phantom `false` (TS
516
- // widens each unresolved `&&` branch to its check's boolean), even though no runtime path
517
- // through this chain can ever produce the literal `false` — the last `|| null` guarantees a
518
- // string or null. Pinning the declared type to what's actually true, not casting past it.
519
- const stopReason = (typeof obj.stop_reason === 'string' && obj.stop_reason) ||
520
- (typeof obj.terminal_reason === 'string' && obj.terminal_reason) ||
521
- null;
522
- // Claude Code 2.1.201 does not always emit a terminal
523
- // stream_event/message_delta. In that wire shape, the top-level result
524
- // is the only frame carrying `end_turn`. Surface it as a turn boundary
525
- // so AgentExecutor can close stream-json stdin and reap the process.
526
- if (stopReason) {
527
- emitTurnEnd(stopReason);
528
- }
529
- onEvent({
530
- type: 'usage',
531
- usage: obj.usage ?? null,
532
- costUsd: obj.total_cost_usd ?? null,
533
- durationMs: obj.duration_ms ?? null,
534
- stopReason,
535
- });
625
+ // Surface the turn_end signal now that every tool_use in this
626
+ // assistant message has been emitted, so the daemon's stdin-close
627
+ // handler sees the final `stop_reason` before deciding whether to
628
+ // close stream-json input stdin.
629
+ if (stopReason) {
630
+ emitTurnEnd(stopReason);
631
+ }
632
+ emitAssistantErrorIfPresent(obj, message.content, onEvent);
633
+ currentMessageStreamedText = false;
634
+ currentMessageStreamedThinking = false;
635
+ }
636
+ function handleResultMessage(obj) {
637
+ // Explicit annotation: the `&&`/`||` chain's inferred type includes a phantom `false` (TS
638
+ // widens each unresolved `&&` branch to its check's boolean), even though no runtime path
639
+ // through this chain can ever produce the literal `false` — the last `|| null` guarantees a
640
+ // string or null. Pinning the declared type to what's actually true, not casting past it.
641
+ const stopReason = (typeof obj.stop_reason === 'string' && obj.stop_reason) ||
642
+ (typeof obj.terminal_reason === 'string' && obj.terminal_reason) ||
643
+ null;
644
+ // Claude Code 2.1.201 does not always emit a terminal
645
+ // stream_event/message_delta. In that wire shape, the top-level result
646
+ // is the only frame carrying `end_turn`. Surface it as a turn boundary
647
+ // so AgentExecutor can close stream-json stdin and reap the process.
648
+ if (stopReason) {
649
+ emitTurnEnd(stopReason);
650
+ }
651
+ onEvent({
652
+ type: 'usage',
653
+ usage: obj.usage ?? null,
654
+ costUsd: obj.total_cost_usd ?? null,
655
+ durationMs: obj.duration_ms ?? null,
656
+ stopReason,
657
+ });
658
+ }
659
+ function handleObject(obj) {
660
+ if (!isRecord(obj))
536
661
  return;
662
+ switch (obj.type) {
663
+ case 'system':
664
+ handleSystemMessage(obj, onEvent);
665
+ return;
666
+ case 'stream_event':
667
+ if (isRecord(obj.event))
668
+ handleStreamEvent(obj.event);
669
+ return;
670
+ case 'assistant':
671
+ handleAssistantMessage(obj);
672
+ return;
673
+ case 'user':
674
+ handleUserMessage(obj, onEvent);
675
+ return;
676
+ case 'result':
677
+ handleResultMessage(obj);
678
+ return;
537
679
  }
538
680
  }
539
- function assistantText(content) {
540
- const parts = [];
541
- for (const block of content) {
542
- if (isRecord(block) && block.type === 'text' && typeof block.text === 'string') {
543
- parts.push(block.text);
544
- }
681
+ function handleMessageStart(ev) {
682
+ flushPendingArtifactText();
683
+ // Clean up per-message role-marker guard from the previous message.
684
+ if (currentMessageId)
685
+ roleGuards.delete(currentMessageId);
686
+ currentMessageId = isRecord(ev.message) && typeof ev.message.id === 'string' ? ev.message.id : null;
687
+ // New message boundary — see `anonymousMessageEpoch`'s declaration.
688
+ if (currentMessageId === null)
689
+ anonymousMessageEpoch += 1;
690
+ currentMessageStreamedText = false;
691
+ currentMessageStreamedThinking = false;
692
+ if (typeof ev.ttft_ms === 'number') {
693
+ onEvent({ type: 'status', label: 'streaming', ttftMs: ev.ttft_ms });
545
694
  }
546
- return parts.join('\n').trim();
547
695
  }
548
- function handleStreamEvent(ev) {
549
- if (ev.type === 'message_start') {
550
- flushPendingArtifactText();
551
- // Clean up per-message role-marker guard from the previous message.
696
+ function handleContentBlockStart(index, block) {
697
+ const key = blockKey(index);
698
+ blocks.set(key, {
699
+ type: block.type,
700
+ name: block.name,
701
+ id: block.id,
702
+ input: '',
703
+ inputValue: 'input' in block ? block.input : undefined,
704
+ });
705
+ if (block.type === 'thinking') {
706
+ onEvent({ type: 'thinking_start' });
707
+ }
708
+ }
709
+ function handleContentBlockDelta(index, delta) {
710
+ const state = blocks.get(blockKey(index));
711
+ if (delta.type === 'text_delta' && typeof delta.text === 'string') {
552
712
  if (currentMessageId)
553
- roleGuards.delete(currentMessageId);
554
- currentMessageId = isRecord(ev.message) && typeof ev.message.id === 'string' ? ev.message.id : null;
555
- currentMessageStreamedText = false;
556
- currentMessageStreamedThinking = false;
557
- if (typeof ev.ttft_ms === 'number') {
558
- onEvent({ type: 'status', label: 'streaming', ttftMs: ev.ttft_ms });
559
- }
713
+ textStreamed.add(currentMessageId);
714
+ currentMessageStreamedText = true;
715
+ emitSafeText(currentMessageId, delta.text);
560
716
  return;
561
717
  }
562
- if (ev.type === 'content_block_start' && isRecord(ev.content_block)) {
563
- const key = blockKey(ev.index);
564
- const block = ev.content_block;
565
- blocks.set(key, {
566
- type: block.type,
567
- name: block.name,
568
- id: block.id,
569
- input: '',
570
- inputValue: 'input' in block ? block.input : undefined,
571
- });
572
- if (block.type === 'thinking') {
573
- onEvent({ type: 'thinking_start' });
574
- }
718
+ if (delta.type === 'thinking_delta' && typeof delta.thinking === 'string') {
719
+ if (currentMessageId)
720
+ thinkingStreamed.add(currentMessageId);
721
+ currentMessageStreamedThinking = true;
722
+ emitSafeText(currentMessageId, delta.thinking, 'thinking_delta');
575
723
  return;
576
724
  }
577
- if (ev.type === 'content_block_delta' && isRecord(ev.delta)) {
578
- const state = blocks.get(blockKey(ev.index));
579
- const delta = ev.delta;
580
- if (delta.type === 'text_delta' && typeof delta.text === 'string') {
581
- if (currentMessageId)
582
- textStreamed.add(currentMessageId);
583
- currentMessageStreamedText = true;
584
- emitSafeText(currentMessageId, delta.text);
585
- return;
586
- }
587
- if (delta.type === 'thinking_delta' && typeof delta.thinking === 'string') {
588
- if (currentMessageId)
589
- thinkingStreamed.add(currentMessageId);
590
- currentMessageStreamedThinking = true;
591
- emitSafeText(currentMessageId, delta.thinking, 'thinking_delta');
592
- return;
593
- }
594
- if (delta.type === 'input_json_delta' && typeof delta.partial_json === 'string') {
595
- if (state && state.type === 'tool_use') {
596
- state.input += delta.partial_json;
597
- if (typeof state.id === 'string' && typeof state.name === 'string') {
598
- onEvent({
599
- type: 'tool_input_delta',
600
- id: state.id,
601
- name: state.name,
602
- delta: delta.partial_json,
603
- });
604
- }
605
- }
606
- return;
607
- }
725
+ if (delta.type === 'input_json_delta' && typeof delta.partial_json === 'string') {
726
+ applyInputJsonDelta(state, delta.partial_json, onEvent);
608
727
  }
609
- if (ev.type === 'content_block_stop') {
610
- const key = blockKey(ev.index);
611
- const state = blocks.get(key);
612
- if (state && state.type === 'tool_use' && typeof state.id === 'string' && state.input.trim()) {
613
- try {
614
- emitToolUse(state.id, state.name, JSON.parse(state.input));
615
- streamedToolUseIds.add(state.id);
616
- }
617
- catch {
618
- // Fall through to the final assistant wrapper's input if the
619
- // streamed JSON is malformed or incomplete.
620
- }
621
- }
622
- else if (state &&
623
- state.type === 'tool_use' &&
624
- typeof state.id === 'string' &&
625
- state.inputValue !== undefined) {
626
- emitToolUse(state.id, state.name, state.inputValue);
728
+ }
729
+ function handleContentBlockStop(ev) {
730
+ const key = blockKey(ev.index);
731
+ const state = blocks.get(key);
732
+ if (state && state.type === 'tool_use' && typeof state.id === 'string' && state.input.trim()) {
733
+ try {
734
+ emitToolUse(state.id, state.name, JSON.parse(state.input));
627
735
  streamedToolUseIds.add(state.id);
628
736
  }
629
- blocks.delete(key);
630
- return;
737
+ catch {
738
+ // Fall through to the final assistant wrapper's input if the
739
+ // streamed JSON is malformed or incomplete.
740
+ }
631
741
  }
632
- if (ev.type === 'message_delta' && isRecord(ev.delta)) {
633
- const stopReason = typeof ev.delta.stop_reason === 'string'
634
- ? ev.delta.stop_reason
635
- : null;
636
- if (stopReason)
637
- emitTurnEnd(stopReason);
638
- return;
742
+ else if (state &&
743
+ state.type === 'tool_use' &&
744
+ typeof state.id === 'string' &&
745
+ state.inputValue !== undefined) {
746
+ emitToolUse(state.id, state.name, state.inputValue);
747
+ streamedToolUseIds.add(state.id);
748
+ }
749
+ blocks.delete(key);
750
+ }
751
+ function handleMessageDeltaEvent(delta) {
752
+ const stopReason = typeof delta.stop_reason === 'string' ? delta.stop_reason : null;
753
+ if (stopReason)
754
+ emitTurnEnd(stopReason);
755
+ }
756
+ function handleStreamEvent(ev) {
757
+ switch (ev.type) {
758
+ case 'message_start':
759
+ handleMessageStart(ev);
760
+ return;
761
+ case 'content_block_start':
762
+ if (isRecord(ev.content_block))
763
+ handleContentBlockStart(ev.index, ev.content_block);
764
+ return;
765
+ case 'content_block_delta':
766
+ if (isRecord(ev.delta))
767
+ handleContentBlockDelta(ev.index, ev.delta);
768
+ return;
769
+ case 'content_block_stop':
770
+ handleContentBlockStop(ev);
771
+ return;
772
+ case 'message_delta':
773
+ if (isRecord(ev.delta))
774
+ handleMessageDeltaEvent(ev.delta);
775
+ return;
639
776
  }
640
777
  }
641
778
  function flushPendingArtifactText() {
@@ -653,14 +790,4 @@ export function createClaudeStreamHandler(onEvent, options = {}) {
653
790
  }
654
791
  return { feed, flush };
655
792
  }
656
- function stringifyToolResult(content) {
657
- if (typeof content === 'string')
658
- return content;
659
- if (Array.isArray(content)) {
660
- return content
661
- .map((c) => (isRecord(c) && c.type === 'text' ? String(c.text) : JSON.stringify(c)))
662
- .join('\n');
663
- }
664
- return JSON.stringify(content);
665
- }
666
793
  //# sourceMappingURL=claude-stream.js.map