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.
- package/NOTICE +12 -0
- package/README.md +119 -96
- package/dist/hub.js +18 -0
- package/dist/launcher.js +22 -0
- package/dist/main.js +4 -3
- package/dist/persona.js +2 -2
- package/dist/recipes.js +37 -14
- package/dist/room.js +3 -2
- package/dist/server.js +16 -3
- package/package.json +8 -5
- package/scripts/vendor-acp.mjs +85 -0
- package/ui/app.css +7 -0
- package/ui/app.js +116 -26
- package/ui/index.html +2 -0
- package/vendor/acp/claude-agent-acp/LICENSE +191 -0
- package/vendor/acp/claude-agent-acp/dist/acp-agent.js +7694 -0
- package/vendor/acp/claude-agent-acp/dist/acp-subagents.js +13 -0
- package/vendor/acp/claude-agent-acp/dist/air-extension.js +63 -0
- package/vendor/acp/claude-agent-acp/dist/async-tasks.js +613 -0
- package/vendor/acp/claude-agent-acp/dist/clear-context-coordinator.js +80 -0
- package/vendor/acp/claude-agent-acp/dist/elicitation.js +304 -0
- package/vendor/acp/claude-agent-acp/dist/exit-plan.js +154 -0
- package/vendor/acp/claude-agent-acp/dist/file-change-audit.js +350 -0
- package/vendor/acp/claude-agent-acp/dist/fork-session.js +41 -0
- package/vendor/acp/claude-agent-acp/dist/goal-extension.js +50 -0
- package/vendor/acp/claude-agent-acp/dist/index.js +98 -0
- package/vendor/acp/claude-agent-acp/dist/lib.js +5 -0
- package/vendor/acp/claude-agent-acp/dist/native-subagents.js +422 -0
- package/vendor/acp/claude-agent-acp/dist/permissions/effects.js +166 -0
- package/vendor/acp/claude-agent-acp/dist/permissions/modes.js +41 -0
- package/vendor/acp/claude-agent-acp/dist/permissions/normalization.js +100 -0
- package/vendor/acp/claude-agent-acp/dist/permissions/options/filesystem.js +124 -0
- package/vendor/acp/claude-agent-acp/dist/permissions/options/shared.js +64 -0
- package/vendor/acp/claude-agent-acp/dist/permissions/options/shell.js +100 -0
- package/vendor/acp/claude-agent-acp/dist/permissions/options/tools.js +135 -0
- package/vendor/acp/claude-agent-acp/dist/permissions/options.js +60 -0
- package/vendor/acp/claude-agent-acp/dist/permissions/presentation.js +83 -0
- package/vendor/acp/claude-agent-acp/dist/permissions/response.js +19 -0
- package/vendor/acp/claude-agent-acp/dist/session-config-ids.js +4 -0
- package/vendor/acp/claude-agent-acp/dist/session-failure-extension.js +324 -0
- package/vendor/acp/claude-agent-acp/dist/session-mode.js +234 -0
- package/vendor/acp/claude-agent-acp/dist/session-titles.js +199 -0
- package/vendor/acp/claude-agent-acp/dist/settings.js +185 -0
- package/vendor/acp/claude-agent-acp/dist/tool-result-meta.js +19 -0
- package/vendor/acp/claude-agent-acp/dist/tools.js +1235 -0
- package/vendor/acp/claude-agent-acp/dist/utils.js +81 -0
- package/vendor/acp/claude-agent-acp/package.json +7 -0
- package/vendor/acp/claude-agent-sdk/LICENSE.md +1 -0
- package/vendor/acp/claude-agent-sdk/agentSdkTypes.d.ts +1 -0
- package/vendor/acp/claude-agent-sdk/bridge.d.ts +378 -0
- package/vendor/acp/claude-agent-sdk/bridge.mjs +221 -0
- package/vendor/acp/claude-agent-sdk/browser-sdk.d.ts +107 -0
- package/vendor/acp/claude-agent-sdk/browser-sdk.js +185 -0
- package/vendor/acp/claude-agent-sdk/extractFromBunfs.d.ts +1 -0
- package/vendor/acp/claude-agent-sdk/extractFromBunfs.js +156 -0
- package/vendor/acp/claude-agent-sdk/manifest.json +65 -0
- package/vendor/acp/claude-agent-sdk/manifest.zst.json +73 -0
- package/vendor/acp/claude-agent-sdk/package.json +7 -0
- package/vendor/acp/claude-agent-sdk/sdk-tools.d.ts +4129 -0
- package/vendor/acp/claude-agent-sdk/sdk.d.ts +8687 -0
- package/vendor/acp/claude-agent-sdk/sdk.mjs +204 -0
- package/vendor/acp/codex-acp/LICENSE +190 -0
- package/vendor/acp/codex-acp/dist/index.js +34238 -0
- 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
|
+
};
|