viberoom 0.3.0 → 0.4.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 (64) hide show
  1. package/NOTICE +12 -0
  2. package/README.md +119 -96
  3. package/dist/hub.js +18 -0
  4. package/dist/launcher.js +22 -0
  5. package/dist/main.js +4 -3
  6. package/dist/persona.js +2 -2
  7. package/dist/recipes.js +37 -14
  8. package/dist/room.js +3 -2
  9. package/dist/server.js +16 -3
  10. package/package.json +8 -5
  11. package/scripts/vendor-acp.mjs +85 -0
  12. package/ui/app.css +7 -0
  13. package/ui/app.js +116 -26
  14. package/ui/index.html +2 -0
  15. package/vendor/acp/claude-agent-acp/LICENSE +191 -0
  16. package/vendor/acp/claude-agent-acp/dist/acp-agent.js +7694 -0
  17. package/vendor/acp/claude-agent-acp/dist/acp-subagents.js +13 -0
  18. package/vendor/acp/claude-agent-acp/dist/air-extension.js +63 -0
  19. package/vendor/acp/claude-agent-acp/dist/async-tasks.js +613 -0
  20. package/vendor/acp/claude-agent-acp/dist/clear-context-coordinator.js +80 -0
  21. package/vendor/acp/claude-agent-acp/dist/elicitation.js +304 -0
  22. package/vendor/acp/claude-agent-acp/dist/exit-plan.js +154 -0
  23. package/vendor/acp/claude-agent-acp/dist/file-change-audit.js +350 -0
  24. package/vendor/acp/claude-agent-acp/dist/fork-session.js +41 -0
  25. package/vendor/acp/claude-agent-acp/dist/goal-extension.js +50 -0
  26. package/vendor/acp/claude-agent-acp/dist/index.js +98 -0
  27. package/vendor/acp/claude-agent-acp/dist/lib.js +5 -0
  28. package/vendor/acp/claude-agent-acp/dist/native-subagents.js +422 -0
  29. package/vendor/acp/claude-agent-acp/dist/permissions/effects.js +166 -0
  30. package/vendor/acp/claude-agent-acp/dist/permissions/modes.js +41 -0
  31. package/vendor/acp/claude-agent-acp/dist/permissions/normalization.js +100 -0
  32. package/vendor/acp/claude-agent-acp/dist/permissions/options/filesystem.js +124 -0
  33. package/vendor/acp/claude-agent-acp/dist/permissions/options/shared.js +64 -0
  34. package/vendor/acp/claude-agent-acp/dist/permissions/options/shell.js +100 -0
  35. package/vendor/acp/claude-agent-acp/dist/permissions/options/tools.js +135 -0
  36. package/vendor/acp/claude-agent-acp/dist/permissions/options.js +60 -0
  37. package/vendor/acp/claude-agent-acp/dist/permissions/presentation.js +83 -0
  38. package/vendor/acp/claude-agent-acp/dist/permissions/response.js +19 -0
  39. package/vendor/acp/claude-agent-acp/dist/session-config-ids.js +4 -0
  40. package/vendor/acp/claude-agent-acp/dist/session-failure-extension.js +324 -0
  41. package/vendor/acp/claude-agent-acp/dist/session-mode.js +234 -0
  42. package/vendor/acp/claude-agent-acp/dist/session-titles.js +199 -0
  43. package/vendor/acp/claude-agent-acp/dist/settings.js +185 -0
  44. package/vendor/acp/claude-agent-acp/dist/tool-result-meta.js +19 -0
  45. package/vendor/acp/claude-agent-acp/dist/tools.js +1235 -0
  46. package/vendor/acp/claude-agent-acp/dist/utils.js +81 -0
  47. package/vendor/acp/claude-agent-acp/package.json +7 -0
  48. package/vendor/acp/claude-agent-sdk/LICENSE.md +1 -0
  49. package/vendor/acp/claude-agent-sdk/agentSdkTypes.d.ts +1 -0
  50. package/vendor/acp/claude-agent-sdk/bridge.d.ts +378 -0
  51. package/vendor/acp/claude-agent-sdk/bridge.mjs +221 -0
  52. package/vendor/acp/claude-agent-sdk/browser-sdk.d.ts +107 -0
  53. package/vendor/acp/claude-agent-sdk/browser-sdk.js +185 -0
  54. package/vendor/acp/claude-agent-sdk/extractFromBunfs.d.ts +1 -0
  55. package/vendor/acp/claude-agent-sdk/extractFromBunfs.js +156 -0
  56. package/vendor/acp/claude-agent-sdk/manifest.json +65 -0
  57. package/vendor/acp/claude-agent-sdk/manifest.zst.json +73 -0
  58. package/vendor/acp/claude-agent-sdk/package.json +7 -0
  59. package/vendor/acp/claude-agent-sdk/sdk-tools.d.ts +4129 -0
  60. package/vendor/acp/claude-agent-sdk/sdk.d.ts +8687 -0
  61. package/vendor/acp/claude-agent-sdk/sdk.mjs +204 -0
  62. package/vendor/acp/codex-acp/LICENSE +190 -0
  63. package/vendor/acp/codex-acp/dist/index.js +34238 -0
  64. package/vendor/acp/codex-acp/package.json +7 -0
@@ -0,0 +1,1235 @@
1
+ import path from "node:path";
2
+ /**
3
+ * Convert an absolute file path to a project-relative path for display.
4
+ * Returns the original path if it's outside the project directory or if no cwd is provided.
5
+ */
6
+ export function toDisplayPath(filePath, cwd) {
7
+ if (!cwd)
8
+ return filePath;
9
+ const resolvedCwd = path.resolve(cwd);
10
+ const resolvedFile = path.resolve(filePath);
11
+ if (resolvedFile.startsWith(resolvedCwd + path.sep) || resolvedFile === resolvedCwd) {
12
+ return path.relative(resolvedCwd, resolvedFile);
13
+ }
14
+ return filePath;
15
+ }
16
+ export function toolInfoFromToolUse(toolUse, supportsTerminalOutput = false, cwd) {
17
+ const name = toolUse.name;
18
+ switch (name) {
19
+ case "Agent":
20
+ case "Task": {
21
+ const input = toolUse.input;
22
+ return {
23
+ title: input?.description ? input.description : "Task",
24
+ kind: "think",
25
+ content: input && "prompt" in input
26
+ ? [
27
+ {
28
+ type: "content",
29
+ content: { type: "text", text: input.prompt },
30
+ },
31
+ ]
32
+ : [],
33
+ };
34
+ }
35
+ case "Bash": {
36
+ const input = toolUse.input;
37
+ return {
38
+ title: input?.command ? input.command : "Terminal",
39
+ kind: "execute",
40
+ content: supportsTerminalOutput
41
+ ? [{ type: "terminal", terminalId: toolUse.id }]
42
+ : input && input.description
43
+ ? [
44
+ {
45
+ type: "content",
46
+ content: { type: "text", text: input.description },
47
+ },
48
+ ]
49
+ : [],
50
+ };
51
+ }
52
+ case "Read": {
53
+ const input = toolUse.input;
54
+ let limit = "";
55
+ if (input?.limit && input.limit > 0) {
56
+ limit = " (" + (input.offset ?? 1) + " - " + ((input.offset ?? 1) + input.limit - 1) + ")";
57
+ }
58
+ else if (input?.offset) {
59
+ limit = " (from line " + input.offset + ")";
60
+ }
61
+ const displayPath = input?.file_path ? toDisplayPath(input.file_path, cwd) : "File";
62
+ return {
63
+ title: "Read " + displayPath + limit,
64
+ kind: "read",
65
+ locations: input?.file_path
66
+ ? [
67
+ {
68
+ path: input.file_path,
69
+ line: input.offset ?? 1,
70
+ },
71
+ ]
72
+ : [],
73
+ content: [],
74
+ };
75
+ }
76
+ case "Write": {
77
+ const input = toolUse.input;
78
+ let content = [];
79
+ if (input && input.file_path) {
80
+ content = [
81
+ {
82
+ type: "diff",
83
+ path: input.file_path,
84
+ oldText: null,
85
+ newText: input.content,
86
+ },
87
+ ];
88
+ }
89
+ else if (input && input.content) {
90
+ content = [
91
+ {
92
+ type: "content",
93
+ content: { type: "text", text: input.content },
94
+ },
95
+ ];
96
+ }
97
+ const displayPath = input?.file_path ? toDisplayPath(input.file_path, cwd) : undefined;
98
+ return {
99
+ title: displayPath ? `Write ${displayPath}` : "Preparing file…",
100
+ kind: "edit",
101
+ content,
102
+ locations: input?.file_path ? [{ path: input.file_path }] : [],
103
+ };
104
+ }
105
+ case "Edit": {
106
+ const input = toolUse.input;
107
+ let content = [];
108
+ if (input && input.file_path && (input.old_string || input.new_string)) {
109
+ content = [
110
+ {
111
+ type: "diff",
112
+ path: input.file_path,
113
+ oldText: input.old_string || null,
114
+ newText: input.new_string ?? "",
115
+ },
116
+ ];
117
+ }
118
+ const displayPath = input?.file_path ? toDisplayPath(input.file_path, cwd) : undefined;
119
+ return {
120
+ title: displayPath ? `Edit ${displayPath}` : "Edit",
121
+ kind: "edit",
122
+ content,
123
+ locations: input?.file_path ? [{ path: input.file_path }] : [],
124
+ };
125
+ }
126
+ case "Glob": {
127
+ const input = toolUse.input;
128
+ let label = "Find";
129
+ if (input?.path) {
130
+ label += ` \`${input.path}\``;
131
+ }
132
+ if (input?.pattern) {
133
+ label += ` \`${input.pattern}\``;
134
+ }
135
+ return {
136
+ title: label,
137
+ kind: "search",
138
+ content: [],
139
+ locations: input?.path ? [{ path: input.path }] : [],
140
+ };
141
+ }
142
+ case "Grep": {
143
+ const input = toolUse.input;
144
+ let label = "grep";
145
+ if (input?.["-i"]) {
146
+ label += " -i";
147
+ }
148
+ if (input?.["-n"]) {
149
+ label += " -n";
150
+ }
151
+ if (input?.["-A"] !== undefined) {
152
+ label += ` -A ${input["-A"]}`;
153
+ }
154
+ if (input?.["-B"] !== undefined) {
155
+ label += ` -B ${input["-B"]}`;
156
+ }
157
+ if (input?.["-C"] !== undefined) {
158
+ label += ` -C ${input["-C"]}`;
159
+ }
160
+ if (input?.output_mode) {
161
+ switch (input.output_mode) {
162
+ case "files_with_matches":
163
+ label += " -l";
164
+ break;
165
+ case "count":
166
+ label += " -c";
167
+ break;
168
+ case "content":
169
+ default:
170
+ break;
171
+ }
172
+ }
173
+ if (input?.head_limit !== undefined) {
174
+ label += ` | head -${input.head_limit}`;
175
+ }
176
+ if (input?.glob) {
177
+ label += ` --include="${input.glob}"`;
178
+ }
179
+ if (input?.type) {
180
+ label += ` --type=${input.type}`;
181
+ }
182
+ if (input?.multiline) {
183
+ label += " -P";
184
+ }
185
+ if (input?.pattern) {
186
+ label += ` "${input.pattern}"`;
187
+ }
188
+ if (input?.path) {
189
+ label += ` ${input.path}`;
190
+ }
191
+ return {
192
+ title: label,
193
+ kind: "search",
194
+ content: [],
195
+ };
196
+ }
197
+ case "WebFetch": {
198
+ const input = toolUse.input;
199
+ return {
200
+ title: input?.url ? `Fetch ${input.url}` : "Fetch",
201
+ kind: "fetch",
202
+ content: input && input.prompt
203
+ ? [
204
+ {
205
+ type: "content",
206
+ content: { type: "text", text: input.prompt },
207
+ },
208
+ ]
209
+ : [],
210
+ };
211
+ }
212
+ case "WebSearch": {
213
+ const input = toolUse.input;
214
+ return {
215
+ title: input?.query ? `Search "${input.query}"` : "Web search",
216
+ kind: "fetch",
217
+ content: [],
218
+ };
219
+ }
220
+ case "TodoWrite": {
221
+ const input = toolUse.input;
222
+ return {
223
+ title: Array.isArray(input?.todos)
224
+ ? `Update TODOs: ${input.todos.map((todo) => todo.content).join(", ")}`
225
+ : "Update TODOs",
226
+ kind: "think",
227
+ content: [],
228
+ };
229
+ }
230
+ case "ReportFindings": {
231
+ const input = toolUse.input;
232
+ const findings = input?.findings ?? [];
233
+ return {
234
+ title: findings.length === 0
235
+ ? "Report findings: none found"
236
+ : `Report ${findings.length} finding${findings.length === 1 ? "" : "s"}`,
237
+ kind: "think",
238
+ content: findings.map((finding) => ({
239
+ type: "content",
240
+ content: {
241
+ type: "text",
242
+ text: `**${finding.file}${finding.line ? `:${finding.line}` : ""}** — ${finding.summary}`,
243
+ },
244
+ })),
245
+ };
246
+ }
247
+ case "TaskCreate": {
248
+ const input = toolUse.input;
249
+ return {
250
+ title: input?.subject ? `Create task: ${input.subject}` : "Create task",
251
+ kind: "think",
252
+ content: [],
253
+ };
254
+ }
255
+ case "TaskUpdate": {
256
+ const input = toolUse.input;
257
+ return {
258
+ title: input?.subject ? `Update task: ${input.subject}` : "Update task",
259
+ kind: "think",
260
+ content: [],
261
+ };
262
+ }
263
+ case "TaskList": {
264
+ return {
265
+ title: "List tasks",
266
+ kind: "think",
267
+ content: [],
268
+ };
269
+ }
270
+ case "TaskGet": {
271
+ return {
272
+ title: "Get task",
273
+ kind: "think",
274
+ content: [],
275
+ };
276
+ }
277
+ case "ExitPlanMode": {
278
+ const planInput = toolUse.input;
279
+ return {
280
+ title: "Approve Plan",
281
+ kind: "switch_mode",
282
+ content: planInput?.plan
283
+ ? [{ type: "content", content: { type: "text", text: planInput.plan } }]
284
+ : [],
285
+ };
286
+ }
287
+ case "Skill": {
288
+ const input = toolUse.input;
289
+ const skillName = input?.skill;
290
+ return {
291
+ title: skillName ? `Load skill: ${skillName}` : "Load skill",
292
+ kind: "other",
293
+ content: [],
294
+ };
295
+ }
296
+ case "AskUserQuestion": {
297
+ const input = toolUse.input;
298
+ const questions = Array.isArray(input?.questions) ? input.questions : [];
299
+ return {
300
+ title: questions.length === 1 && questions[0]?.question
301
+ ? questions[0].question
302
+ : "Asking for your input",
303
+ kind: "other",
304
+ content: questions
305
+ .filter((q) => typeof q?.question === "string")
306
+ .map((q) => ({
307
+ type: "content",
308
+ content: { type: "text", text: q.question },
309
+ })),
310
+ };
311
+ }
312
+ case "Other": {
313
+ const input = toolUse.input;
314
+ let output;
315
+ try {
316
+ output = JSON.stringify(input, null, 2);
317
+ }
318
+ catch {
319
+ output = typeof input === "string" ? input : "{}";
320
+ }
321
+ return {
322
+ title: name || "Unknown Tool",
323
+ kind: "other",
324
+ content: [
325
+ {
326
+ type: "content",
327
+ content: {
328
+ type: "text",
329
+ text: `\`\`\`json\n${output}\`\`\``,
330
+ },
331
+ },
332
+ ],
333
+ };
334
+ }
335
+ default:
336
+ return {
337
+ title: name || "Unknown Tool",
338
+ kind: "other",
339
+ content: [],
340
+ };
341
+ }
342
+ }
343
+ /**
344
+ * Narrow the untyped message-level `tool_use_result` toward a per-tool Output
345
+ * shape: rejects everything but a plain non-null object (arrays pass a bare
346
+ * `typeof === "object"` check, so they're excluded here). The returned value
347
+ * is only *nominally* typed — it arrives over the wire from arbitrary CLI
348
+ * versions, so each caller must still guard the specific fields it reads
349
+ * before trusting them.
350
+ */
351
+ function structuredResult(toolUseResult) {
352
+ return toolUseResult !== null &&
353
+ typeof toolUseResult === "object" &&
354
+ !Array.isArray(toolUseResult)
355
+ ? toolUseResult
356
+ : undefined;
357
+ }
358
+ /**
359
+ * Strip the model-directed trailer from a raw Agent/Task tool_result text:
360
+ * a `<usage>…</usage>` totals block and/or an
361
+ * `agentId: <id> (use SendMessage …)` continuation line at the end of the
362
+ * text. Both patterns are tail-anchored and independent (older CLIs emit
363
+ * variants with only one of them), so a format change makes them stop
364
+ * matching rather than mangle the report.
365
+ */
366
+ function stripAgentTrailer(text) {
367
+ return stripAgentIdLine(stripUsageBlock(text));
368
+ }
369
+ const USAGE_OPEN = "<usage>";
370
+ const USAGE_CLOSE = "</usage>";
371
+ /** Remove a trailing `<usage>…</usage>` block, plus trailing whitespace and
372
+ * one preceding newline. Matches from the *last* `<usage>` so a report that
373
+ * merely mentions the marker earlier isn't truncated at the mention. */
374
+ function stripUsageBlock(text) {
375
+ const body = text.trimEnd();
376
+ if (!body.endsWith(USAGE_CLOSE)) {
377
+ return text;
378
+ }
379
+ const open = body.lastIndexOf(USAGE_OPEN, body.length - USAGE_CLOSE.length - USAGE_OPEN.length);
380
+ if (open === -1) {
381
+ return text;
382
+ }
383
+ return body.slice(0, open > 0 && body[open - 1] === "\n" ? open - 1 : open);
384
+ }
385
+ /** The continuation line, anchored to a whole line so the regex has a single
386
+ * start position and no ambiguous repetition (`[\w-]+` can't consume the
387
+ * following space, `[^)]*` can't consume the closing paren) — it runs in
388
+ * linear time on any input. */
389
+ const AGENT_ID_LINE = /^agentId: [\w-]+ \([^)]*\)$/;
390
+ /** Remove a final `agentId: <id> (…)` line, plus trailing whitespace and the
391
+ * newline that preceded the line. */
392
+ function stripAgentIdLine(text) {
393
+ const body = text.trimEnd();
394
+ const lineStart = body.lastIndexOf("\n") + 1;
395
+ if (!AGENT_ID_LINE.test(body.slice(lineStart))) {
396
+ return text;
397
+ }
398
+ return body.slice(0, Math.max(lineStart - 1, 0));
399
+ }
400
+ /** Apply {@link stripAgentTrailer} across a raw tool_result `content` (plain
401
+ * string or block array), leaving non-text blocks untouched. */
402
+ function stripAgentTrailerFromContent(content) {
403
+ if (typeof content === "string") {
404
+ return stripAgentTrailer(content);
405
+ }
406
+ if (Array.isArray(content)) {
407
+ return content.map((block) => block !== null &&
408
+ typeof block === "object" &&
409
+ block.type === "text" &&
410
+ typeof block.text === "string"
411
+ ? { ...block, text: stripAgentTrailer(block.text) }
412
+ : block);
413
+ }
414
+ return content;
415
+ }
416
+ /** Leading model-directed note the CLI prepends to a subagent's report when
417
+ * the agent stopped at its maxTurns limit (CLI 2.1.246+); the result still
418
+ * ships as `status: "completed"`. Two body variants follow this prefix, and
419
+ * the trailing "Send the agent a message (SendMessage) …" sentence is
420
+ * omitted for some agent types — anchor only the stable prefix so a format
421
+ * change makes the replacement stop matching rather than mangle a report. */
422
+ const PARTIAL_OUTPUT_NOTE = /^NOTE: this agent stopped at its \d+-turn limit before finishing\./;
423
+ /** Client-facing replacement: the partial-output fact matters to the user,
424
+ * but the SendMessage continuation instruction is model-directed and
425
+ * meaningless over ACP. */
426
+ const PARTIAL_OUTPUT_LABEL = "[Agent stopped at its turn limit — the output below is partial]";
427
+ /** Replace a leading partial-output note paragraph with the concise
428
+ * client-facing label, leaving the report that follows intact. */
429
+ function replacePartialNoteInText(text) {
430
+ if (!PARTIAL_OUTPUT_NOTE.test(text))
431
+ return text;
432
+ const paragraphEnd = text.indexOf("\n\n");
433
+ const report = paragraphEnd === -1 ? "" : text.slice(paragraphEnd + 2).trimStart();
434
+ return report ? `${PARTIAL_OUTPUT_LABEL}\n\n${report}` : PARTIAL_OUTPUT_LABEL;
435
+ }
436
+ /** Apply {@link replacePartialNoteInText} to an Agent/Task result `content`.
437
+ * In the structured AgentOutput lane the note is its own leading text block;
438
+ * in the raw lane it is the first paragraph of the text — both reduce to
439
+ * transforming the first text block (or the plain string). */
440
+ function replacePartialOutputNote(content) {
441
+ if (typeof content === "string") {
442
+ return replacePartialNoteInText(content);
443
+ }
444
+ if (Array.isArray(content) && content.length > 0) {
445
+ const [first, ...rest] = content;
446
+ if (first !== null &&
447
+ typeof first === "object" &&
448
+ first.type === "text" &&
449
+ typeof first.text === "string") {
450
+ return [{ ...first, text: replacePartialNoteInText(first.text) }, ...rest];
451
+ }
452
+ }
453
+ return content;
454
+ }
455
+ export function toolUpdateFromToolResult(toolResult, toolUse, supportsTerminalOutput = false, toolUseResult) {
456
+ if ("is_error" in toolResult &&
457
+ toolResult.is_error &&
458
+ toolResult.content &&
459
+ toolResult.content.length > 0 &&
460
+ !(toolUse?.name === "Bash" && supportsTerminalOutput)) {
461
+ // Only return errors
462
+ return toAcpContentUpdate(toolResult.content, true);
463
+ }
464
+ // Shared raw-text fallback: renders the tool_result content the model saw.
465
+ // The structured cases below fall back to this when `tool_use_result` is
466
+ // absent or fails its shape guard (older CLIs, replayed sessions).
467
+ const rawContentUpdate = () => toAcpContentUpdate(toolResult.content, "is_error" in toolResult ? toolResult.is_error : false);
468
+ switch (toolUse?.name) {
469
+ case "Read": {
470
+ // The raw tool_result text is the model-facing view: line-numbered
471
+ // content plus any appended <system-reminder> blocks (malicious-code
472
+ // checks, memory staleness notes, …) that clients shouldn't see. The
473
+ // structured FileReadOutput carries the clean content — rebuild the
474
+ // line-numbered view from it. Non-text variants (image/notebook/pdf)
475
+ // fall back to the raw content blocks, which already render fine.
476
+ const structuredRead = structuredResult(toolUseResult);
477
+ if (structuredRead?.type === "text" &&
478
+ typeof structuredRead.file?.content === "string" &&
479
+ // An empty file has nothing to line-number; keep the raw view (the
480
+ // model-facing "file is empty" note) rather than a phantom blank line.
481
+ structuredRead.file.content.length > 0) {
482
+ // startLine is typed non-optional but defended anyway; a Read's
483
+ // `offset` input is the same 1-based starting line, so it beats a
484
+ // blind 1 when an emitter omits the field.
485
+ const startLine = structuredRead.file.startLine ??
486
+ toolUse.input?.offset ??
487
+ 1;
488
+ // A trailing newline is a line terminator, not an extra line — don't
489
+ // number a phantom empty line after it.
490
+ let numbered = structuredRead.file.content
491
+ .replace(/\n$/, "")
492
+ .split("\n")
493
+ .map((line, i) => `${startLine + i}\t${line}`)
494
+ .join("\n");
495
+ // The model-facing truncation banner doesn't survive reconstruction
496
+ // from file.content (the SDK flag exists for exactly this case) —
497
+ // re-establish it so a partial first page doesn't read as the whole
498
+ // file.
499
+ if (structuredRead.file.truncatedByTokenCap) {
500
+ const { numLines, totalLines } = structuredRead.file;
501
+ const detail = typeof numLines === "number" && typeof totalLines === "number"
502
+ ? `: showing ${numLines} of ${totalLines} lines`
503
+ : "";
504
+ numbered += `\n[File truncated${detail}]`;
505
+ }
506
+ return {
507
+ content: [
508
+ {
509
+ type: "content",
510
+ content: { type: "text", text: markdownEscape(numbered) },
511
+ },
512
+ ],
513
+ };
514
+ }
515
+ if (Array.isArray(toolResult.content) && toolResult.content.length > 0) {
516
+ return {
517
+ content: toolResult.content.map((content) => ({
518
+ type: "content",
519
+ content: content.type === "text"
520
+ ? {
521
+ type: "text",
522
+ text: markdownEscape(content.text),
523
+ }
524
+ : toAcpContentBlock(content, false),
525
+ })),
526
+ };
527
+ }
528
+ else if (typeof toolResult.content === "string" && toolResult.content.length > 0) {
529
+ return {
530
+ content: [
531
+ {
532
+ type: "content",
533
+ content: {
534
+ type: "text",
535
+ text: markdownEscape(toolResult.content),
536
+ },
537
+ },
538
+ ],
539
+ };
540
+ }
541
+ return {};
542
+ }
543
+ case "Bash": {
544
+ const result = toolResult.content;
545
+ // The terminal was announced under the tool_use's own id (see
546
+ // `toolInfoFromToolUse`), so key the output/exit metas off that: it is the
547
+ // id the client actually created a terminal for. `toolResult.tool_use_id`
548
+ // is the same value whenever present — the caller looks the tool_use up by
549
+ // it — so preferring `toolUse.id` only adds a source for the case where the
550
+ // result block carries no id at all. Anything that isn't a non-empty
551
+ // string is no id at all: `""` matches no terminal, and stringifying a
552
+ // present-but-undefined field would invent the literal `"undefined"`.
553
+ const terminalIdOf = (id) => typeof id === "string" && id.length > 0 ? id : undefined;
554
+ const terminalId = terminalIdOf(toolUse?.id) ??
555
+ terminalIdOf("tool_use_id" in toolResult ? toolResult.tool_use_id : undefined);
556
+ const isError = "is_error" in toolResult && toolResult.is_error;
557
+ // Extract output and exit code from either format:
558
+ // 1. The structured BashOutput (message-level tool_use_result): its
559
+ // stdout/stderr exclude the model-directed suffixes the raw text
560
+ // carries (stale-read hints, gh rate-limit hints, the
561
+ // persisted-output wrapper for too-large outputs — the interruption
562
+ // and truncation facts those carried are re-established from the
563
+ // structured flags below). Skipped for image output (the raw content
564
+ // array carries the actual image blocks) and backgrounded commands
565
+ // (the raw text carries the background-task notice; structured
566
+ // stdout may be empty).
567
+ // 2. BetaBashCodeExecutionResultBlock: { type: "bash_code_execution_result", stdout, stderr, return_code }
568
+ // 3. Plain string content from a regular tool_result
569
+ // 4. Array content (e.g. [{ type: "text", text: "..." }] for stdout,
570
+ // or [{ type: "image", source: {...} }] when the local Bash tool
571
+ // produces an image, e.g. piping a base64 data URI)
572
+ let output = "";
573
+ let exitCode = isError ? 1 : 0;
574
+ const structuredBash = structuredResult(toolUseResult);
575
+ if (structuredBash &&
576
+ typeof structuredBash.stdout === "string" &&
577
+ typeof structuredBash.stderr === "string" &&
578
+ !structuredBash.isImage &&
579
+ structuredBash.backgroundTaskId === undefined) {
580
+ output = [structuredBash.stdout, structuredBash.stderr].filter(Boolean).join("\n");
581
+ // Two raw-text notices don't survive the structured stdout/stderr —
582
+ // re-establish them so the client isn't shown a clean-looking result:
583
+ // the CLI appends its abort marker only to the model-facing text, and
584
+ // an aborted command isn't a success, so synthesize a failing exit
585
+ // code when the result wasn't already an error.
586
+ if (structuredBash.interrupted) {
587
+ output = [output, "[Command was aborted before completion]"].filter(Boolean).join("\n");
588
+ exitCode = 1;
589
+ }
590
+ // Structured stdout is clipped (~30k chars) when the full output was
591
+ // persisted to disk; without this note the clip is silent and the
592
+ // path to the full output is lost.
593
+ if (typeof structuredBash.persistedOutputPath === "string") {
594
+ const size = typeof structuredBash.persistedOutputSize === "number"
595
+ ? ` (${structuredBash.persistedOutputSize} bytes total)`
596
+ : "";
597
+ output = [
598
+ output,
599
+ `[Output truncated${size}: full output saved to ${structuredBash.persistedOutputPath}]`,
600
+ ]
601
+ .filter(Boolean)
602
+ .join("\n");
603
+ }
604
+ }
605
+ else if (result &&
606
+ typeof result === "object" &&
607
+ "type" in result &&
608
+ result.type === "bash_code_execution_result") {
609
+ const bashResult = result;
610
+ output = [bashResult.stdout, bashResult.stderr].filter(Boolean).join("\n");
611
+ exitCode = bashResult.return_code;
612
+ }
613
+ else if (typeof result === "string") {
614
+ output = result;
615
+ }
616
+ else if (Array.isArray(result) && result.length > 0) {
617
+ const textOnly = result.every((c) => c && typeof c === "object" && typeof c.text === "string");
618
+ if (textOnly) {
619
+ output = result.map((c) => c.text).join("\n");
620
+ }
621
+ else {
622
+ // Image (or mixed non-text) content. Binary payloads can't be
623
+ // streamed through the terminal-output _meta channel, so bypass
624
+ // it and surface the blocks as ACP content. This handles the
625
+ // local Bash tool's image output, which previously failed the
626
+ // text-only guard and was silently dropped.
627
+ return toAcpContentUpdate(result, isError);
628
+ }
629
+ }
630
+ // Without a terminal id there is nothing the client can reconcile these
631
+ // metas against, and emitting them anyway strands the output: a client that
632
+ // buffers output/exit for terminals it has not been told about (Zed keeps
633
+ // them in `pending_terminal_output`/`pending_terminal_exit`, drained only on
634
+ // a matching create) would hold them forever behind an id that never
635
+ // arrives, showing an empty terminal. Fall through to the code-block
636
+ // rendering below instead.
637
+ if (supportsTerminalOutput && terminalId !== undefined) {
638
+ return {
639
+ content: [{ type: "terminal", terminalId }],
640
+ _meta: {
641
+ terminal_info: {
642
+ terminal_id: terminalId,
643
+ },
644
+ terminal_output: {
645
+ terminal_id: terminalId,
646
+ data: output,
647
+ },
648
+ terminal_exit: {
649
+ terminal_id: terminalId,
650
+ exit_code: exitCode,
651
+ signal: null,
652
+ },
653
+ },
654
+ };
655
+ }
656
+ // Fallback: format output as a code block without terminal _meta
657
+ if (output.trim()) {
658
+ return {
659
+ content: [
660
+ {
661
+ type: "content",
662
+ content: {
663
+ type: "text",
664
+ text: `\`\`\`console\n${output.trimEnd()}\n\`\`\``,
665
+ },
666
+ },
667
+ ],
668
+ };
669
+ }
670
+ return {};
671
+ }
672
+ case "Agent":
673
+ case "Task": {
674
+ // The raw tool_result text ends with a model-directed trailer (an
675
+ // `agentId: … (use SendMessage …)` line plus a `<usage>` totals block)
676
+ // that ACP clients shouldn't see. The message-level `tool_use_result`
677
+ // carries the structured AgentOutput whose `content` is the subagent's
678
+ // report without the trailer — render from it when present (per the SDK
679
+ // 0.3.207 guidance) and fall back to the raw text otherwise (older CLIs,
680
+ // replayed sessions).
681
+ // Narrowed to the full union, not the completed variant — the status
682
+ // check below is what discriminates it, and pre-narrowing would let
683
+ // future field reads typecheck against a variant the runtime value may
684
+ // not be.
685
+ const structured = structuredResult(toolUseResult);
686
+ if (structured?.status === "completed" &&
687
+ Array.isArray(structured.content) &&
688
+ // A completed subagent can end with zero text blocks; an empty
689
+ // structured render would beat the raw fallback for no benefit.
690
+ structured.content.length > 0) {
691
+ return toAcpContentUpdate(
692
+ // A maxTurns-stopped subagent still completes, with a model-directed
693
+ // partial-output note prepended to its report — swap it for a
694
+ // client-facing label (see PARTIAL_OUTPUT_NOTE).
695
+ replacePartialOutputNote(structured.content), "is_error" in toolResult ? toolResult.is_error : false);
696
+ }
697
+ // No structured report to render from (replayed sessions —
698
+ // getSessionMessages doesn't expose the transcript's toolUseResult —
699
+ // and older CLIs). The SDK advises rendering from tool_use_result
700
+ // instead of parsing the text, but with no structured value the
701
+ // tail-anchored strip is the only cleanup available; if the trailer
702
+ // format changes it simply stops matching and the full raw text
703
+ // renders, no worse than before.
704
+ return toAcpContentUpdate(
705
+ // Head and tail cleanups are independent: the partial-output note
706
+ // leads the raw text the same way it leads the structured content.
707
+ replacePartialOutputNote(stripAgentTrailerFromContent(toolResult.content)), "is_error" in toolResult ? toolResult.is_error : false);
708
+ }
709
+ case "Skill": {
710
+ return {};
711
+ }
712
+ case "Edit": // Edit is handled in hooks
713
+ case "Write": {
714
+ return {};
715
+ }
716
+ case "ExitPlanMode": {
717
+ return { title: "Exited Plan Mode" };
718
+ }
719
+ case "WebSearch": {
720
+ // The raw tool_result text is a model-directed dump ("Web search
721
+ // results for query: …\n\nLinks: [{…json…}]"). The structured
722
+ // WebSearchOutput carries the hits — render them the way server-side
723
+ // web_search_result blocks render ("Title (url)").
724
+ const structuredSearch = structuredResult(toolUseResult);
725
+ if (structuredSearch && Array.isArray(structuredSearch.results)) {
726
+ const lines = structuredSearch.results.flatMap((entry) => typeof entry === "string"
727
+ ? [entry]
728
+ : Array.isArray(entry?.content)
729
+ ? // tool_use_result arrives untyped across CLI version skew —
730
+ // skip off-spec hits rather than rendering
731
+ // "undefined (undefined)" lines.
732
+ entry.content.flatMap((hit) => typeof hit?.title === "string" && typeof hit?.url === "string"
733
+ ? [formatWebSearchHit(hit)]
734
+ : [])
735
+ : []);
736
+ if (lines.length > 0) {
737
+ return {
738
+ content: [
739
+ {
740
+ type: "content",
741
+ content: { type: "text", text: lines.join("\n") },
742
+ },
743
+ ],
744
+ };
745
+ }
746
+ }
747
+ return rawContentUpdate();
748
+ }
749
+ default: {
750
+ return rawContentUpdate();
751
+ }
752
+ }
753
+ }
754
+ /** One display format for a web-search hit, shared by the structured
755
+ * WebSearchOutput render and the server-side `web_search_result` block so
756
+ * the two paths can't drift. */
757
+ function formatWebSearchHit(hit) {
758
+ return `${hit.title} (${hit.url})`;
759
+ }
760
+ /** Human-readable size for the document placeholder ("312 B", "2.4 KB",
761
+ * "1.3 MB"). */
762
+ function formatByteSize(bytes) {
763
+ if (bytes < 1024)
764
+ return `${bytes} B`;
765
+ if (bytes < 1024 * 1024)
766
+ return `${(bytes / 1024).toFixed(1)} KB`;
767
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
768
+ }
769
+ function toAcpContentUpdate(content, isError = false) {
770
+ if (Array.isArray(content) && content.length > 0) {
771
+ return {
772
+ content: content.map((c) => ({
773
+ type: "content",
774
+ content: toAcpContentBlock(c, isError),
775
+ })),
776
+ };
777
+ }
778
+ else if (typeof content === "object" && content !== null && "type" in content) {
779
+ return {
780
+ content: [
781
+ {
782
+ type: "content",
783
+ content: toAcpContentBlock(content, isError),
784
+ },
785
+ ],
786
+ };
787
+ }
788
+ else if (typeof content === "string" && content.length > 0) {
789
+ return {
790
+ content: [
791
+ {
792
+ type: "content",
793
+ content: {
794
+ type: "text",
795
+ text: isError ? `\`\`\`\n${content}\n\`\`\`` : content,
796
+ },
797
+ },
798
+ ],
799
+ };
800
+ }
801
+ return {};
802
+ }
803
+ function toAcpContentBlock(content, isError) {
804
+ const wrapText = (text) => ({
805
+ type: "text",
806
+ text: isError ? `\`\`\`\n${text}\n\`\`\`` : text,
807
+ });
808
+ switch (content.type) {
809
+ case "text":
810
+ return {
811
+ type: "text",
812
+ text: isError ? `\`\`\`\n${content.text}\n\`\`\`` : content.text,
813
+ };
814
+ case "image":
815
+ if (content.source.type === "base64") {
816
+ return {
817
+ type: "image",
818
+ data: content.source.data,
819
+ mimeType: content.source.media_type,
820
+ };
821
+ }
822
+ // URL and file-based images can't be converted to ACP format (requires data)
823
+ return wrapText(content.source.type === "url"
824
+ ? `[image: ${content.source.url}]`
825
+ : "[image: file reference]");
826
+ case "document": {
827
+ // A PDF Read delivers its raw `document` block inside the tool_result
828
+ // content (SDK 0.3.243 moved it here from a separate follow-up user
829
+ // message; the MultiRead documents lane always lived here). ACP has no
830
+ // document block and the base64 payload can be megabytes — render a
831
+ // compact placeholder, never the data, matching the CLI's own compact
832
+ // "Read PDF (size)" rendering.
833
+ const title = typeof content.title === "string" && content.title.length > 0 ? ` "${content.title}"` : "";
834
+ const source = content.source;
835
+ switch (source.type) {
836
+ case "url":
837
+ return wrapText(`[document${title}: ${source.url}]`);
838
+ case "base64":
839
+ case "text":
840
+ // base64 inflates the byte count by 4/3; plain text is 1:1.
841
+ return wrapText(`[document${title}: ${source.media_type}, ${formatByteSize(source.type === "base64"
842
+ ? Math.floor((source.data.length * 3) / 4)
843
+ : source.data.length)}]`);
844
+ default:
845
+ return wrapText(`[document${title}]`);
846
+ }
847
+ }
848
+ case "tool_reference":
849
+ return wrapText(`Tool: ${content.tool_name}`);
850
+ case "tool_search_tool_search_result":
851
+ return wrapText(`Tools found: ${content.tool_references.map((r) => r.tool_name).join(", ") || "none"}`);
852
+ case "tool_search_tool_result_error":
853
+ return wrapText(`Error: ${content.error_code}${content.error_message ? ` - ${content.error_message}` : ""}`);
854
+ case "web_search_result":
855
+ return wrapText(formatWebSearchHit(content));
856
+ case "web_search_tool_result_error":
857
+ return wrapText(`Error: ${content.error_code}`);
858
+ case "web_fetch_result":
859
+ return wrapText(`Fetched: ${content.url}`);
860
+ case "web_fetch_tool_result_error":
861
+ return wrapText(`Error: ${content.error_code}`);
862
+ case "code_execution_result":
863
+ return wrapText(`Output: ${content.stdout || content.stderr || ""}`);
864
+ case "bash_code_execution_result":
865
+ return wrapText(`Output: ${content.stdout || content.stderr || ""}`);
866
+ case "code_execution_tool_result_error":
867
+ case "bash_code_execution_tool_result_error":
868
+ return wrapText(`Error: ${content.error_code}`);
869
+ case "text_editor_code_execution_view_result":
870
+ return wrapText(content.content);
871
+ case "text_editor_code_execution_create_result":
872
+ return wrapText(content.is_file_update ? "File updated" : "File created");
873
+ case "text_editor_code_execution_str_replace_result":
874
+ return wrapText(content.lines?.join("\n") || "");
875
+ case "text_editor_code_execution_tool_result_error":
876
+ return wrapText(`Error: ${content.error_code}${content.error_message ? ` - ${content.error_message}` : ""}`);
877
+ default:
878
+ return wrapText(JSON.stringify(content));
879
+ }
880
+ }
881
+ export function planEntries(input) {
882
+ return (input?.todos ?? []).map((todo) => ({
883
+ content: todo.status === "in_progress" && todo.activeForm ? todo.activeForm : todo.content,
884
+ status: todo.status,
885
+ priority: "medium",
886
+ }));
887
+ }
888
+ /**
889
+ * Best-effort parse of a structured Task* tool_result. The SDK delivers tool
890
+ * outputs either as a string or as an array of TextBlockParam-like blocks
891
+ * containing JSON text; try both.
892
+ */
893
+ function parseJsonToolOutput(content, isExpectedOutput) {
894
+ const tryParse = (text) => {
895
+ try {
896
+ const parsed = JSON.parse(text);
897
+ return isExpectedOutput(parsed) ? parsed : undefined;
898
+ }
899
+ catch {
900
+ return undefined;
901
+ }
902
+ };
903
+ if (typeof content === "string") {
904
+ return tryParse(content);
905
+ }
906
+ if (content && typeof content === "object" && !Array.isArray(content)) {
907
+ return isExpectedOutput(content) ? content : undefined;
908
+ }
909
+ if (Array.isArray(content)) {
910
+ for (const block of content) {
911
+ if (block && typeof block === "object" && "type" in block && block.type === "text") {
912
+ const text = block.text;
913
+ if (typeof text === "string") {
914
+ const parsed = tryParse(text);
915
+ if (parsed)
916
+ return parsed;
917
+ }
918
+ }
919
+ }
920
+ }
921
+ return undefined;
922
+ }
923
+ function toolOutputTexts(content) {
924
+ if (typeof content === "string")
925
+ return [content];
926
+ if (!Array.isArray(content))
927
+ return [];
928
+ return content.flatMap((block) => block &&
929
+ typeof block === "object" &&
930
+ "type" in block &&
931
+ block.type === "text" &&
932
+ "text" in block &&
933
+ typeof block.text === "string"
934
+ ? [block.text]
935
+ : []);
936
+ }
937
+ export function parseTaskCreateOutput(content) {
938
+ const structured = parseJsonToolOutput(content, (parsed) => Boolean(parsed &&
939
+ typeof parsed === "object" &&
940
+ "task" in parsed &&
941
+ parsed.task &&
942
+ typeof parsed.task === "object" &&
943
+ "id" in parsed.task &&
944
+ typeof parsed.task.id === "string"));
945
+ if (structured)
946
+ return structured;
947
+ for (const text of toolOutputTexts(content)) {
948
+ const match = /^Task #(\S+) created successfully: (.+)$/.exec(text.trim());
949
+ if (match)
950
+ return { task: { id: match[1], subject: match[2] } };
951
+ }
952
+ return undefined;
953
+ }
954
+ export function parseTaskListOutput(content) {
955
+ const validStatuses = new Set(["pending", "in_progress", "completed"]);
956
+ const structured = parseJsonToolOutput(content, (parsed) => Boolean(parsed &&
957
+ typeof parsed === "object" &&
958
+ "tasks" in parsed &&
959
+ Array.isArray(parsed.tasks) &&
960
+ parsed.tasks.every((task) => task &&
961
+ typeof task === "object" &&
962
+ typeof task.id === "string" &&
963
+ typeof task.subject === "string" &&
964
+ typeof task.status === "string" &&
965
+ validStatuses.has(task.status))));
966
+ if (structured)
967
+ return structured;
968
+ for (const text of toolOutputTexts(content)) {
969
+ if (text.trim() === "No tasks found")
970
+ return { tasks: [] };
971
+ const tasks = [];
972
+ const lines = text.trim().split("\n");
973
+ for (const line of lines) {
974
+ const match = /^#(\S+) \[(pending|in_progress|completed)\] (.+?)(?: \(([^()]*)\))?(?: \[blocked by ((?:#[^,\]]+(?:, )?)+)\])?$/.exec(line);
975
+ if (!match) {
976
+ tasks.length = 0;
977
+ break;
978
+ }
979
+ tasks.push({
980
+ id: match[1],
981
+ subject: match[3],
982
+ status: match[2],
983
+ ...(match[4] ? { owner: match[4] } : {}),
984
+ blockedBy: match[5] ? match[5].split(", ").map((id) => id.slice(1)) : [],
985
+ });
986
+ }
987
+ if (tasks.length > 0)
988
+ return { tasks };
989
+ }
990
+ return undefined;
991
+ }
992
+ export function parseTaskUpdateOutput(content, expectedTaskId) {
993
+ const structured = parseJsonToolOutput(content, (parsed) => Boolean(parsed &&
994
+ typeof parsed === "object" &&
995
+ "success" in parsed &&
996
+ typeof parsed.success === "boolean" &&
997
+ "taskId" in parsed &&
998
+ typeof parsed.taskId === "string" &&
999
+ "updatedFields" in parsed &&
1000
+ Array.isArray(parsed.updatedFields) &&
1001
+ parsed.updatedFields.every((field) => typeof field === "string")));
1002
+ if (structured)
1003
+ return structured;
1004
+ for (const text of toolOutputTexts(content)) {
1005
+ const notFound = /^Task #(\S+) not found$/.exec(text.trim());
1006
+ const taskId = notFound?.[1] ?? expectedTaskId;
1007
+ if (taskId && (notFound || text.trim() === "Failed to delete task")) {
1008
+ return { success: false, taskId, updatedFields: [], error: text.trim() };
1009
+ }
1010
+ }
1011
+ return undefined;
1012
+ }
1013
+ export function applyTaskCreate(state, input, output) {
1014
+ const taskId = output?.task?.id;
1015
+ if (!taskId || !input)
1016
+ return;
1017
+ state.set(taskId, {
1018
+ subject: input.subject,
1019
+ status: "pending",
1020
+ activeForm: input.activeForm,
1021
+ description: input.description,
1022
+ });
1023
+ }
1024
+ export function applyTaskUpdate(state, input) {
1025
+ if (!input?.taskId)
1026
+ return;
1027
+ if (input.status === "deleted") {
1028
+ state.delete(input.taskId);
1029
+ return;
1030
+ }
1031
+ const existing = state.get(input.taskId);
1032
+ // Without a subject from either the existing entry or the update payload,
1033
+ // we'd produce a plan entry with empty `content` — drop the update.
1034
+ const subject = input.subject ?? existing?.subject;
1035
+ if (!subject)
1036
+ return;
1037
+ state.set(input.taskId, {
1038
+ subject,
1039
+ status: input.status ?? existing?.status ?? "pending",
1040
+ activeForm: input.activeForm ?? existing?.activeForm,
1041
+ description: input.description ?? existing?.description,
1042
+ });
1043
+ }
1044
+ export function applyTaskList(state, output) {
1045
+ const previous = new Map(state);
1046
+ state.clear();
1047
+ for (const task of output.tasks) {
1048
+ const existing = previous.get(task.id);
1049
+ state.set(task.id, {
1050
+ subject: task.subject,
1051
+ status: task.status,
1052
+ activeForm: existing?.activeForm,
1053
+ description: existing?.description,
1054
+ });
1055
+ }
1056
+ }
1057
+ export function taskStateToPlanEntries(state) {
1058
+ return Array.from(state.values()).map((task) => ({
1059
+ content: task.status === "in_progress" && task.activeForm ? task.activeForm : task.subject,
1060
+ status: task.status,
1061
+ priority: "medium",
1062
+ }));
1063
+ }
1064
+ export function markdownEscape(text) {
1065
+ let escape = "```";
1066
+ for (const [m] of text.matchAll(/^```+/gm)) {
1067
+ while (m.length >= escape.length) {
1068
+ escape += "`";
1069
+ }
1070
+ }
1071
+ return escape + "\n" + text + (text.endsWith("\n") ? "" : "\n") + escape;
1072
+ }
1073
+ /**
1074
+ * Builds diff ToolUpdate content from the structured toolResponse provided by
1075
+ * the PostToolUse hook for diff-producing tools (Edit, Write). Unlike parsing
1076
+ * the plain unified diff string, this uses the pre-parsed structuredPatch
1077
+ * which supports multiple replacement sites (replaceAll) and always includes
1078
+ * context lines for better readability.
1079
+ */
1080
+ export function toolUpdateFromDiffToolResponse(toolResponse) {
1081
+ if (!toolResponse || typeof toolResponse !== "object")
1082
+ return {};
1083
+ const response = toolResponse;
1084
+ if (!response.filePath || !Array.isArray(response.structuredPatch))
1085
+ return {};
1086
+ const content = [];
1087
+ const locations = [];
1088
+ for (const { lines, newStart } of response.structuredPatch) {
1089
+ const oldText = [];
1090
+ const newText = [];
1091
+ for (const line of lines) {
1092
+ if (line.startsWith("-")) {
1093
+ oldText.push(line.slice(1));
1094
+ }
1095
+ else if (line.startsWith("+")) {
1096
+ newText.push(line.slice(1));
1097
+ }
1098
+ else {
1099
+ oldText.push(line.slice(1));
1100
+ newText.push(line.slice(1));
1101
+ }
1102
+ }
1103
+ if (oldText.length > 0 || newText.length > 0) {
1104
+ locations.push({ path: response.filePath, line: newStart });
1105
+ content.push({
1106
+ type: "diff",
1107
+ path: response.filePath,
1108
+ oldText: oldText.join("\n") || null,
1109
+ newText: newText.join("\n"),
1110
+ });
1111
+ }
1112
+ }
1113
+ // A Write `update` can arrive with an empty structuredPatch — nothing
1114
+ // changed, the diff timed out, or the previous content was too large to
1115
+ // diff (originalFile null; SDK 0.3.252 documents the lane). Returning `{}`
1116
+ // would leave Write's optimistic tool_use-time content standing, and that
1117
+ // was built with `oldText: null` — "creation" semantics — so an overwrite
1118
+ // of a large existing file would render as creating it. Emit a truthful
1119
+ // replacement instead. Gated on `type` so Edit (whose output carries no
1120
+ // `type` and whose optimistic old/new diff is already truthful) keeps the
1121
+ // empty-return behavior.
1122
+ if (content.length === 0 && response.type === "update" && typeof response.content === "string") {
1123
+ locations.push({ path: response.filePath });
1124
+ content.push(typeof response.originalFile === "string"
1125
+ ? {
1126
+ type: "diff",
1127
+ path: response.filePath,
1128
+ oldText: response.originalFile,
1129
+ newText: response.content,
1130
+ }
1131
+ : {
1132
+ type: "content",
1133
+ content: {
1134
+ type: "text",
1135
+ text: `Updated \`${response.filePath}\` (previous content too large to diff)`,
1136
+ },
1137
+ });
1138
+ }
1139
+ const result = {};
1140
+ if (content.length > 0)
1141
+ result.content = content;
1142
+ if (locations.length > 0)
1143
+ result.locations = locations;
1144
+ return result;
1145
+ }
1146
+ /* Callbacks are keyed globally because the SDK hook is process-wide, but each
1147
+ * entry retains its owning ACP session so cancellation/teardown can release it. */
1148
+ const toolUseCallbacks = new Map();
1149
+ /* Setup callbacks that will be called when receiving hooks from Claude Code */
1150
+ export const registerHookCallback = (toolUseID, { onPostToolUseHook, }, ownerId) => {
1151
+ unregisterHookCallback(toolUseID);
1152
+ toolUseCallbacks.set(toolUseID, {
1153
+ ownerId,
1154
+ onPostToolUseHook,
1155
+ });
1156
+ };
1157
+ export function unregisterHookCallback(toolUseID) {
1158
+ const callback = toolUseCallbacks.get(toolUseID);
1159
+ if (callback?.cleanupTimer)
1160
+ clearTimeout(callback.cleanupTimer);
1161
+ toolUseCallbacks.delete(toolUseID);
1162
+ }
1163
+ /** PostToolUse normally follows tool_result, so keep the callback for a short
1164
+ * grace period while still bounding retention when the hook never arrives. */
1165
+ export function completeHookCallback(toolUseID) {
1166
+ const callback = toolUseCallbacks.get(toolUseID);
1167
+ if (!callback || callback.cleanupTimer)
1168
+ return;
1169
+ callback.cleanupTimer = setTimeout(() => unregisterHookCallback(toolUseID), 30_000);
1170
+ callback.cleanupTimer.unref?.();
1171
+ }
1172
+ export function clearHookCallbacks(ownerId) {
1173
+ for (const [toolUseID, callback] of toolUseCallbacks) {
1174
+ if (callback.ownerId === ownerId)
1175
+ unregisterHookCallback(toolUseID);
1176
+ }
1177
+ }
1178
+ /* A callback for Claude Code that is called when receiving a PostToolUse hook */
1179
+ export const createPostToolUseHook = (options) => async (input, toolUseID) => {
1180
+ if (input.hook_event_name === "PostToolUse") {
1181
+ // Handle EnterPlanMode tool - notify client of mode change after successful execution
1182
+ if (input.tool_name === "EnterPlanMode" && options?.onEnterPlanMode) {
1183
+ await options.onEnterPlanMode();
1184
+ }
1185
+ if (toolUseID) {
1186
+ const onPostToolUseHook = toolUseCallbacks.get(toolUseID)?.onPostToolUseHook;
1187
+ try {
1188
+ if (onPostToolUseHook) {
1189
+ await onPostToolUseHook(toolUseID, input.tool_input, input.tool_response);
1190
+ }
1191
+ }
1192
+ finally {
1193
+ unregisterHookCallback(toolUseID);
1194
+ }
1195
+ }
1196
+ }
1197
+ return { continue: true };
1198
+ };
1199
+ /**
1200
+ * Hook callback for `TaskCreated` / `TaskCompleted` events. The SDK fires
1201
+ * these for both user-facing TaskCreate tool calls and subagent task
1202
+ * creation, giving us `task_id` + `task_subject` without having to parse
1203
+ * tool_result payloads.
1204
+ *
1205
+ * Populating `taskState` from the hook means a later `TaskUpdate` (which
1206
+ * typically only carries `taskId` + `status`) finds an existing entry with
1207
+ * a real subject, instead of synthesizing a placeholder with empty content.
1208
+ */
1209
+ export const createTaskHook = (options) => async (input) => {
1210
+ const taskId = "task_id" in input && typeof input.task_id === "string" ? input.task_id : undefined;
1211
+ if (!taskId)
1212
+ return { continue: true };
1213
+ if (input.hook_event_name === "TaskCreated") {
1214
+ if (!input.task_subject)
1215
+ return { continue: true };
1216
+ if (options.taskState.has(taskId))
1217
+ return { continue: true };
1218
+ options.taskState.set(taskId, {
1219
+ subject: input.task_subject,
1220
+ status: "pending",
1221
+ description: input.task_description,
1222
+ });
1223
+ if (options.onChange)
1224
+ await options.onChange();
1225
+ }
1226
+ else if (input.hook_event_name === "TaskCompleted") {
1227
+ const existing = options.taskState.get(taskId);
1228
+ if (!existing || existing.status === "completed")
1229
+ return { continue: true };
1230
+ options.taskState.set(taskId, { ...existing, status: "completed" });
1231
+ if (options.onChange)
1232
+ await options.onChange();
1233
+ }
1234
+ return { continue: true };
1235
+ };