@posthog/agent 2.0.0 → 2.0.2

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 (131) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +221 -219
  3. package/dist/adapters/claude/conversion/tool-use-to-acp.d.ts +21 -0
  4. package/dist/adapters/claude/conversion/tool-use-to-acp.js +547 -0
  5. package/dist/adapters/claude/conversion/tool-use-to-acp.js.map +1 -0
  6. package/dist/adapters/claude/permissions/permission-options.d.ts +13 -0
  7. package/dist/adapters/claude/permissions/permission-options.js +117 -0
  8. package/dist/adapters/claude/permissions/permission-options.js.map +1 -0
  9. package/dist/adapters/claude/questions/utils.d.ts +132 -0
  10. package/dist/adapters/claude/questions/utils.js +63 -0
  11. package/dist/adapters/claude/questions/utils.js.map +1 -0
  12. package/dist/adapters/claude/tools.d.ts +18 -0
  13. package/dist/adapters/claude/tools.js +95 -0
  14. package/dist/adapters/claude/tools.js.map +1 -0
  15. package/dist/agent-DBQY1BfC.d.ts +123 -0
  16. package/dist/agent.d.ts +5 -0
  17. package/dist/agent.js +3656 -0
  18. package/dist/agent.js.map +1 -0
  19. package/dist/claude-cli/cli.js +3695 -2746
  20. package/dist/claude-cli/vendor/ripgrep/COPYING +3 -0
  21. package/dist/claude-cli/vendor/ripgrep/arm64-darwin/rg +0 -0
  22. package/dist/claude-cli/vendor/ripgrep/arm64-darwin/ripgrep.node +0 -0
  23. package/dist/claude-cli/vendor/ripgrep/arm64-linux/rg +0 -0
  24. package/dist/claude-cli/vendor/ripgrep/arm64-linux/ripgrep.node +0 -0
  25. package/dist/claude-cli/vendor/ripgrep/x64-darwin/rg +0 -0
  26. package/dist/claude-cli/vendor/ripgrep/x64-darwin/ripgrep.node +0 -0
  27. package/dist/claude-cli/vendor/ripgrep/x64-linux/rg +0 -0
  28. package/dist/claude-cli/vendor/ripgrep/x64-linux/ripgrep.node +0 -0
  29. package/dist/claude-cli/vendor/ripgrep/x64-win32/rg.exe +0 -0
  30. package/dist/claude-cli/vendor/ripgrep/x64-win32/ripgrep.node +0 -0
  31. package/dist/gateway-models.d.ts +24 -0
  32. package/dist/gateway-models.js +93 -0
  33. package/dist/gateway-models.js.map +1 -0
  34. package/dist/index.d.ts +170 -1157
  35. package/dist/index.js +9373 -5135
  36. package/dist/index.js.map +1 -1
  37. package/dist/logger-DDBiMOOD.d.ts +24 -0
  38. package/dist/posthog-api.d.ts +40 -0
  39. package/dist/posthog-api.js +175 -0
  40. package/dist/posthog-api.js.map +1 -0
  41. package/dist/server/agent-server.d.ts +41 -0
  42. package/dist/server/agent-server.js +10503 -0
  43. package/dist/server/agent-server.js.map +1 -0
  44. package/dist/server/bin.d.ts +1 -0
  45. package/dist/server/bin.js +10558 -0
  46. package/dist/server/bin.js.map +1 -0
  47. package/dist/types.d.ts +129 -0
  48. package/dist/types.js +1 -0
  49. package/dist/types.js.map +1 -0
  50. package/package.json +65 -13
  51. package/src/acp-extensions.ts +98 -16
  52. package/src/adapters/acp-connection.ts +494 -0
  53. package/src/adapters/base-acp-agent.ts +150 -0
  54. package/src/adapters/claude/claude-agent.ts +596 -0
  55. package/src/adapters/claude/conversion/acp-to-sdk.ts +102 -0
  56. package/src/adapters/claude/conversion/sdk-to-acp.ts +571 -0
  57. package/src/adapters/claude/conversion/tool-use-to-acp.ts +618 -0
  58. package/src/adapters/claude/hooks.ts +64 -0
  59. package/src/adapters/claude/mcp/tool-metadata.ts +102 -0
  60. package/src/adapters/claude/permissions/permission-handlers.ts +433 -0
  61. package/src/adapters/claude/permissions/permission-options.ts +103 -0
  62. package/src/adapters/claude/plan/utils.ts +56 -0
  63. package/src/adapters/claude/questions/utils.ts +92 -0
  64. package/src/adapters/claude/session/commands.ts +38 -0
  65. package/src/adapters/claude/session/mcp-config.ts +37 -0
  66. package/src/adapters/claude/session/models.ts +12 -0
  67. package/src/adapters/claude/session/options.ts +236 -0
  68. package/src/adapters/claude/tool-meta.ts +143 -0
  69. package/src/adapters/claude/tools.ts +53 -688
  70. package/src/adapters/claude/types.ts +61 -0
  71. package/src/adapters/codex/spawn.ts +130 -0
  72. package/src/agent.ts +96 -587
  73. package/src/execution-mode.ts +43 -0
  74. package/src/gateway-models.ts +135 -0
  75. package/src/index.ts +79 -0
  76. package/src/otel-log-writer.test.ts +105 -0
  77. package/src/otel-log-writer.ts +94 -0
  78. package/src/posthog-api.ts +75 -235
  79. package/src/resume.ts +115 -0
  80. package/src/sagas/apply-snapshot-saga.test.ts +690 -0
  81. package/src/sagas/apply-snapshot-saga.ts +88 -0
  82. package/src/sagas/capture-tree-saga.test.ts +892 -0
  83. package/src/sagas/capture-tree-saga.ts +141 -0
  84. package/src/sagas/resume-saga.test.ts +558 -0
  85. package/src/sagas/resume-saga.ts +332 -0
  86. package/src/sagas/test-fixtures.ts +250 -0
  87. package/src/server/agent-server.test.ts +220 -0
  88. package/src/server/agent-server.ts +748 -0
  89. package/src/server/bin.ts +88 -0
  90. package/src/server/jwt.ts +65 -0
  91. package/src/server/schemas.ts +47 -0
  92. package/src/server/types.ts +13 -0
  93. package/src/server/utils/retry.test.ts +122 -0
  94. package/src/server/utils/retry.ts +61 -0
  95. package/src/server/utils/sse-parser.test.ts +93 -0
  96. package/src/server/utils/sse-parser.ts +46 -0
  97. package/src/session-log-writer.test.ts +140 -0
  98. package/src/session-log-writer.ts +137 -0
  99. package/src/test/assertions.ts +114 -0
  100. package/src/test/controllers/sse-controller.ts +107 -0
  101. package/src/test/fixtures/api.ts +111 -0
  102. package/src/test/fixtures/config.ts +33 -0
  103. package/src/test/fixtures/notifications.ts +92 -0
  104. package/src/test/mocks/claude-sdk.ts +251 -0
  105. package/src/test/mocks/msw-handlers.ts +48 -0
  106. package/src/test/setup.ts +114 -0
  107. package/src/test/wait.ts +41 -0
  108. package/src/tree-tracker.ts +173 -0
  109. package/src/types.ts +54 -137
  110. package/src/utils/acp-content.ts +58 -0
  111. package/src/utils/async-mutex.test.ts +104 -0
  112. package/src/utils/async-mutex.ts +31 -0
  113. package/src/utils/common.ts +15 -0
  114. package/src/utils/gateway.ts +9 -6
  115. package/src/utils/logger.ts +0 -30
  116. package/src/utils/streams.ts +220 -0
  117. package/CLAUDE.md +0 -331
  118. package/src/adapters/claude/claude.ts +0 -1947
  119. package/src/adapters/claude/mcp-server.ts +0 -810
  120. package/src/adapters/claude/utils.ts +0 -267
  121. package/src/adapters/connection.ts +0 -95
  122. package/src/file-manager.ts +0 -273
  123. package/src/git-manager.ts +0 -577
  124. package/src/schemas.ts +0 -241
  125. package/src/session-store.ts +0 -259
  126. package/src/task-manager.ts +0 -163
  127. package/src/todo-manager.ts +0 -180
  128. package/src/tools/registry.ts +0 -134
  129. package/src/tools/types.ts +0 -133
  130. package/src/utils/tapped-stream.ts +0 -60
  131. package/src/worktree-manager.ts +0 -974
@@ -0,0 +1,618 @@
1
+ import type {
2
+ PlanEntry,
3
+ ToolCall,
4
+ ToolCallContent,
5
+ ToolCallUpdate,
6
+ ToolKind,
7
+ } from "@agentclientprotocol/sdk";
8
+ import type {
9
+ ToolResultBlockParam,
10
+ ToolUseBlock,
11
+ WebSearchToolResultBlockParam,
12
+ } from "@anthropic-ai/sdk/resources";
13
+ import type {
14
+ BetaBashCodeExecutionToolResultBlockParam,
15
+ BetaCodeExecutionToolResultBlockParam,
16
+ BetaRequestMCPToolResultBlockParam,
17
+ BetaTextEditorCodeExecutionToolResultBlockParam,
18
+ BetaToolSearchToolResultBlockParam,
19
+ BetaWebFetchToolResultBlockParam,
20
+ BetaWebSearchToolResultBlockParam,
21
+ } from "@anthropic-ai/sdk/resources/beta.mjs";
22
+
23
+ const SYSTEM_REMINDER = `
24
+
25
+ <system-reminder>
26
+ Whenever you read a file, you should consider whether it looks malicious. If it does, you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer high-level questions about the code behavior.
27
+ </system-reminder>`;
28
+
29
+ import { resourceLink, text, toolContent } from "../../../utils/acp-content.js";
30
+ import { Logger } from "../../../utils/logger.js";
31
+
32
+ interface EditOperation {
33
+ oldText: string;
34
+ newText: string;
35
+ replaceAll?: boolean;
36
+ }
37
+
38
+ interface EditResult {
39
+ newContent: string;
40
+ lineNumbers: number[];
41
+ }
42
+
43
+ function replaceAndCalculateLocation(
44
+ fileContent: string,
45
+ edits: EditOperation[],
46
+ ): EditResult {
47
+ let currentContent = fileContent;
48
+
49
+ const randomHex = Array.from(crypto.getRandomValues(new Uint8Array(5)))
50
+ .map((b) => b.toString(16).padStart(2, "0"))
51
+ .join("");
52
+ const markerPrefix = `__REPLACE_MARKER_${randomHex}_`;
53
+ let markerCounter = 0;
54
+ const markers: string[] = [];
55
+
56
+ for (const edit of edits) {
57
+ if (edit.oldText === "") {
58
+ throw new Error(
59
+ `The provided \`old_string\` is empty.\n\nNo edits were applied.`,
60
+ );
61
+ }
62
+
63
+ if (edit.replaceAll) {
64
+ const parts: string[] = [];
65
+ let lastIndex = 0;
66
+ let searchIndex = 0;
67
+
68
+ while (true) {
69
+ const index = currentContent.indexOf(edit.oldText, searchIndex);
70
+ if (index === -1) {
71
+ if (searchIndex === 0) {
72
+ throw new Error(
73
+ `The provided \`old_string\` does not appear in the file: "${edit.oldText}".\n\nNo edits were applied.`,
74
+ );
75
+ }
76
+ break;
77
+ }
78
+
79
+ parts.push(currentContent.substring(lastIndex, index));
80
+
81
+ const marker = `${markerPrefix}${markerCounter++}__`;
82
+ markers.push(marker);
83
+ parts.push(marker + edit.newText);
84
+
85
+ lastIndex = index + edit.oldText.length;
86
+ searchIndex = lastIndex;
87
+ }
88
+
89
+ parts.push(currentContent.substring(lastIndex));
90
+ currentContent = parts.join("");
91
+ } else {
92
+ const index = currentContent.indexOf(edit.oldText);
93
+ if (index === -1) {
94
+ throw new Error(
95
+ `The provided \`old_string\` does not appear in the file: "${edit.oldText}".\n\nNo edits were applied.`,
96
+ );
97
+ } else {
98
+ const marker = `${markerPrefix}${markerCounter++}__`;
99
+ markers.push(marker);
100
+ currentContent =
101
+ currentContent.substring(0, index) +
102
+ marker +
103
+ edit.newText +
104
+ currentContent.substring(index + edit.oldText.length);
105
+ }
106
+ }
107
+ }
108
+
109
+ const lineNumbers: number[] = [];
110
+ for (const marker of markers) {
111
+ const index = currentContent.indexOf(marker);
112
+ if (index !== -1) {
113
+ const lineNumber = Math.max(
114
+ 0,
115
+ currentContent.substring(0, index).split(/\r\n|\r|\n/).length - 1,
116
+ );
117
+ lineNumbers.push(lineNumber);
118
+ }
119
+ }
120
+
121
+ let finalContent = currentContent;
122
+ for (const marker of markers) {
123
+ finalContent = finalContent.replace(marker, "");
124
+ }
125
+
126
+ const uniqueLineNumbers = [...new Set(lineNumbers)].sort();
127
+
128
+ return { newContent: finalContent, lineNumbers: uniqueLineNumbers };
129
+ }
130
+
131
+ type ToolInfo = Pick<ToolCall, "title" | "kind" | "content" | "locations">;
132
+
133
+ export function toolInfoFromToolUse(
134
+ toolUse: Pick<ToolUseBlock, "name" | "input">,
135
+ cachedFileContent: { [key: string]: string },
136
+ logger: Logger = new Logger({ debug: false, prefix: "[ClaudeTools]" }),
137
+ ): ToolInfo {
138
+ const name = toolUse.name;
139
+ const input = toolUse.input as Record<string, unknown> | undefined;
140
+
141
+ switch (name) {
142
+ case "Task":
143
+ return {
144
+ title: input?.description ? String(input.description) : "Task",
145
+ kind: "think",
146
+ content: input?.prompt
147
+ ? toolContent().text(String(input.prompt)).build()
148
+ : [],
149
+ };
150
+
151
+ case "NotebookRead":
152
+ return {
153
+ title: input?.notebook_path
154
+ ? `Read Notebook ${String(input.notebook_path)}`
155
+ : "Read Notebook",
156
+ kind: "read",
157
+ content: [],
158
+ locations: input?.notebook_path
159
+ ? [{ path: String(input.notebook_path) }]
160
+ : [],
161
+ };
162
+
163
+ case "NotebookEdit":
164
+ return {
165
+ title: input?.notebook_path
166
+ ? `Edit Notebook ${String(input.notebook_path)}`
167
+ : "Edit Notebook",
168
+ kind: "edit",
169
+ content: input?.new_source
170
+ ? toolContent().text(String(input.new_source)).build()
171
+ : [],
172
+ locations: input?.notebook_path
173
+ ? [{ path: String(input.notebook_path) }]
174
+ : [],
175
+ };
176
+
177
+ case "Bash":
178
+ return {
179
+ title: input?.description
180
+ ? String(input.description)
181
+ : "Execute command",
182
+ kind: "execute",
183
+ content: input?.command
184
+ ? toolContent().text(String(input.command)).build()
185
+ : [],
186
+ };
187
+
188
+ case "BashOutput":
189
+ return {
190
+ title: "Tail Logs",
191
+ kind: "execute",
192
+ content: [],
193
+ };
194
+
195
+ case "KillShell":
196
+ return {
197
+ title: "Kill Process",
198
+ kind: "execute",
199
+ content: [],
200
+ };
201
+
202
+ case "Read": {
203
+ let limit = "";
204
+ const inputLimit = input?.limit as number | undefined;
205
+ const inputOffset = (input?.offset as number | undefined) ?? 0;
206
+ if (inputLimit) {
207
+ limit = ` (${inputOffset + 1} - ${inputOffset + inputLimit})`;
208
+ } else if (inputOffset) {
209
+ limit = ` (from line ${inputOffset + 1})`;
210
+ }
211
+ return {
212
+ title: `Read ${input?.file_path ? String(input.file_path) : "File"}${limit}`,
213
+ kind: "read",
214
+ locations: input?.file_path
215
+ ? [
216
+ {
217
+ path: String(input.file_path),
218
+ line: inputOffset,
219
+ },
220
+ ]
221
+ : [],
222
+ content: [],
223
+ };
224
+ }
225
+
226
+ case "LS":
227
+ return {
228
+ title: `List the ${input?.path ? `\`${String(input.path)}\`` : "current"} directory's contents`,
229
+ kind: "search",
230
+ content: [],
231
+ locations: [],
232
+ };
233
+
234
+ case "Edit": {
235
+ const path = input?.file_path ? String(input.file_path) : undefined;
236
+ let oldText = input?.old_string ? String(input.old_string) : null;
237
+ let newText = input?.new_string ? String(input.new_string) : "";
238
+ let affectedLines: number[] = [];
239
+
240
+ if (path && oldText) {
241
+ try {
242
+ const oldContent = cachedFileContent[path] || "";
243
+ const newContent = replaceAndCalculateLocation(oldContent, [
244
+ {
245
+ oldText,
246
+ newText,
247
+ replaceAll: false,
248
+ },
249
+ ]);
250
+ oldText = oldContent;
251
+ newText = newContent.newContent;
252
+ affectedLines = newContent.lineNumbers;
253
+ } catch (e) {
254
+ logger.error("Failed to edit file", e);
255
+ }
256
+ }
257
+ return {
258
+ title: path ? `Edit \`${path}\`` : "Edit",
259
+ kind: "edit",
260
+ content:
261
+ input && path
262
+ ? [
263
+ {
264
+ type: "diff",
265
+ path,
266
+ oldText,
267
+ newText,
268
+ },
269
+ ]
270
+ : [],
271
+ locations: path
272
+ ? affectedLines.length > 0
273
+ ? affectedLines.map((line) => ({ line, path }))
274
+ : [{ path }]
275
+ : [],
276
+ };
277
+ }
278
+
279
+ case "Write": {
280
+ let contentResult: ToolCallContent[] = [];
281
+ const filePath = input?.file_path ? String(input.file_path) : undefined;
282
+ const contentStr = input?.content ? String(input.content) : undefined;
283
+ if (filePath) {
284
+ contentResult = toolContent()
285
+ .diff(filePath, null, contentStr ?? "")
286
+ .build();
287
+ } else if (contentStr) {
288
+ contentResult = toolContent().text(contentStr).build();
289
+ }
290
+ return {
291
+ title: filePath ? `Write ${filePath}` : "Write",
292
+ kind: "edit",
293
+ content: contentResult,
294
+ locations: filePath ? [{ path: filePath }] : [],
295
+ };
296
+ }
297
+
298
+ case "Glob": {
299
+ let label = "Find";
300
+ const pathStr = input?.path ? String(input.path) : undefined;
301
+ if (pathStr) {
302
+ label += ` "${pathStr}"`;
303
+ }
304
+ if (input?.pattern) {
305
+ label += ` "${String(input.pattern)}"`;
306
+ }
307
+ return {
308
+ title: label,
309
+ kind: "search",
310
+ content: [],
311
+ locations: pathStr ? [{ path: pathStr }] : [],
312
+ };
313
+ }
314
+
315
+ case "Grep": {
316
+ let label = "grep";
317
+
318
+ if (input?.["-i"]) {
319
+ label += " -i";
320
+ }
321
+ if (input?.["-n"]) {
322
+ label += " -n";
323
+ }
324
+
325
+ if (input?.["-A"] !== undefined) {
326
+ label += ` -A ${input["-A"]}`;
327
+ }
328
+ if (input?.["-B"] !== undefined) {
329
+ label += ` -B ${input["-B"]}`;
330
+ }
331
+ if (input?.["-C"] !== undefined) {
332
+ label += ` -C ${input["-C"]}`;
333
+ }
334
+
335
+ if (input?.output_mode) {
336
+ switch (input.output_mode) {
337
+ case "FilesWithMatches":
338
+ label += " -l";
339
+ break;
340
+ case "Count":
341
+ label += " -c";
342
+ break;
343
+ default:
344
+ break;
345
+ }
346
+ }
347
+
348
+ if (input?.head_limit !== undefined) {
349
+ label += ` | head -${input.head_limit}`;
350
+ }
351
+
352
+ if (input?.glob) {
353
+ label += ` --include="${String(input.glob)}"`;
354
+ }
355
+
356
+ if (input?.type) {
357
+ label += ` --type=${String(input.type)}`;
358
+ }
359
+
360
+ if (input?.multiline) {
361
+ label += " -P";
362
+ }
363
+
364
+ label += ` "${input?.pattern ? String(input.pattern) : ""}"`;
365
+
366
+ if (input?.path) {
367
+ label += ` ${String(input.path)}`;
368
+ }
369
+
370
+ return {
371
+ title: label,
372
+ kind: "search",
373
+ content: [],
374
+ };
375
+ }
376
+
377
+ case "WebFetch":
378
+ return {
379
+ title: "Fetch",
380
+ kind: "fetch",
381
+ content: input?.url
382
+ ? [
383
+ {
384
+ type: "content",
385
+ content: resourceLink(String(input.url), String(input.url), {
386
+ description: input?.prompt ? String(input.prompt) : undefined,
387
+ }),
388
+ },
389
+ ]
390
+ : [],
391
+ };
392
+
393
+ case "WebSearch": {
394
+ let label = `"${input?.query ? String(input.query) : ""}"`;
395
+ const allowedDomains = input?.allowed_domains as string[] | undefined;
396
+ const blockedDomains = input?.blocked_domains as string[] | undefined;
397
+
398
+ if (allowedDomains && allowedDomains.length > 0) {
399
+ label += ` (allowed: ${allowedDomains.join(", ")})`;
400
+ }
401
+
402
+ if (blockedDomains && blockedDomains.length > 0) {
403
+ label += ` (blocked: ${blockedDomains.join(", ")})`;
404
+ }
405
+
406
+ return {
407
+ title: label,
408
+ kind: "fetch",
409
+ content: [],
410
+ };
411
+ }
412
+
413
+ case "TodoWrite":
414
+ return {
415
+ title: Array.isArray(input?.todos)
416
+ ? `Update TODOs: ${input.todos.map((todo: { content?: string }) => todo.content).join(", ")}`
417
+ : "Update TODOs",
418
+ kind: "think",
419
+ content: [],
420
+ };
421
+
422
+ case "ExitPlanMode":
423
+ return {
424
+ title: "Ready to code?",
425
+ kind: "switch_mode",
426
+ content: input?.plan
427
+ ? toolContent().text(String(input.plan)).build()
428
+ : [],
429
+ };
430
+
431
+ case "AskUserQuestion": {
432
+ const questions = input?.questions as
433
+ | Array<{ question?: string }>
434
+ | undefined;
435
+ return {
436
+ title: questions?.[0]?.question || "Question",
437
+ kind: "other" as ToolKind,
438
+ content: questions
439
+ ? toolContent()
440
+ .text(JSON.stringify(questions, null, 2))
441
+ .build()
442
+ : [],
443
+ };
444
+ }
445
+
446
+ case "Other": {
447
+ let output: string;
448
+ try {
449
+ output = JSON.stringify(input, null, 2);
450
+ } catch {
451
+ output = typeof input === "string" ? input : "{}";
452
+ }
453
+ return {
454
+ title: name || "Unknown Tool",
455
+ kind: "other",
456
+ content: toolContent().text(`\`\`\`json\n${output}\`\`\``).build(),
457
+ };
458
+ }
459
+
460
+ default:
461
+ return {
462
+ title: name || "Unknown Tool",
463
+ kind: "other",
464
+ content: [],
465
+ };
466
+ }
467
+ }
468
+
469
+ export function toolUpdateFromToolResult(
470
+ toolResult:
471
+ | ToolResultBlockParam
472
+ | BetaWebSearchToolResultBlockParam
473
+ | BetaWebFetchToolResultBlockParam
474
+ | WebSearchToolResultBlockParam
475
+ | BetaCodeExecutionToolResultBlockParam
476
+ | BetaBashCodeExecutionToolResultBlockParam
477
+ | BetaTextEditorCodeExecutionToolResultBlockParam
478
+ | BetaRequestMCPToolResultBlockParam
479
+ | BetaToolSearchToolResultBlockParam,
480
+ toolUse: Pick<ToolUseBlock, "name" | "input"> | undefined,
481
+ ): Pick<ToolCallUpdate, "title" | "content" | "locations"> {
482
+ switch (toolUse?.name) {
483
+ case "Read":
484
+ if (Array.isArray(toolResult.content) && toolResult.content.length > 0) {
485
+ return {
486
+ content: toolResult.content.map((item) => {
487
+ const itemObj = item as { type?: string; text?: string };
488
+ if (itemObj.type === "text") {
489
+ return {
490
+ type: "content" as const,
491
+ content: text(
492
+ markdownEscape(
493
+ (itemObj.text ?? "").replace(SYSTEM_REMINDER, ""),
494
+ ),
495
+ ),
496
+ };
497
+ }
498
+ return {
499
+ type: "content" as const,
500
+ content: item as { type: "text"; text: string },
501
+ };
502
+ }),
503
+ };
504
+ } else if (
505
+ typeof toolResult.content === "string" &&
506
+ toolResult.content.length > 0
507
+ ) {
508
+ return {
509
+ content: toolContent()
510
+ .text(
511
+ markdownEscape(toolResult.content.replace(SYSTEM_REMINDER, "")),
512
+ )
513
+ .build(),
514
+ };
515
+ }
516
+ return {};
517
+
518
+ case "Bash": {
519
+ return toAcpContentUpdate(
520
+ toolResult.content,
521
+ "is_error" in toolResult ? toolResult.is_error : false,
522
+ );
523
+ }
524
+ case "Edit":
525
+ case "Write": {
526
+ if (
527
+ "is_error" in toolResult &&
528
+ toolResult.is_error &&
529
+ toolResult.content &&
530
+ toolResult.content.length > 0
531
+ ) {
532
+ return toAcpContentUpdate(toolResult.content, true);
533
+ }
534
+ return {};
535
+ }
536
+
537
+ case "ExitPlanMode": {
538
+ return { title: "Exited Plan Mode" };
539
+ }
540
+ case "AskUserQuestion": {
541
+ const content = toolResult.content;
542
+ if (Array.isArray(content) && content.length > 0) {
543
+ const firstItem = content[0];
544
+ if (
545
+ typeof firstItem === "object" &&
546
+ firstItem !== null &&
547
+ "text" in firstItem
548
+ ) {
549
+ return {
550
+ title: "Answer received",
551
+ content: toolContent().text(String(firstItem.text)).build(),
552
+ };
553
+ }
554
+ }
555
+ return { title: "Question answered" };
556
+ }
557
+ default: {
558
+ return toAcpContentUpdate(
559
+ toolResult.content,
560
+ "is_error" in toolResult ? toolResult.is_error : false,
561
+ );
562
+ }
563
+ }
564
+ }
565
+
566
+ function toAcpContentUpdate(
567
+ content: unknown,
568
+ isError: boolean = false,
569
+ ): Pick<ToolCallUpdate, "content"> {
570
+ if (Array.isArray(content) && content.length > 0) {
571
+ return {
572
+ content: content.map((item) => {
573
+ const itemObj = item as { type?: string; text?: string };
574
+ if (isError && itemObj.type === "text") {
575
+ return {
576
+ type: "content" as const,
577
+ content: text(`\`\`\`\n${itemObj.text ?? ""}\n\`\`\``),
578
+ };
579
+ }
580
+ return {
581
+ type: "content" as const,
582
+ content: item as { type: "text"; text: string },
583
+ };
584
+ }),
585
+ };
586
+ } else if (typeof content === "string" && content.length > 0) {
587
+ return {
588
+ content: toolContent()
589
+ .text(isError ? `\`\`\`\n${content}\n\`\`\`` : content)
590
+ .build(),
591
+ };
592
+ }
593
+ return {};
594
+ }
595
+
596
+ export type ClaudePlanEntry = {
597
+ content: string;
598
+ status: "pending" | "in_progress" | "completed";
599
+ activeForm: string;
600
+ };
601
+
602
+ export function planEntries(input: { todos: ClaudePlanEntry[] }): PlanEntry[] {
603
+ return input.todos.map((input) => ({
604
+ content: input.content,
605
+ status: input.status,
606
+ priority: "medium",
607
+ }));
608
+ }
609
+
610
+ function markdownEscape(text: string): string {
611
+ let escapedText = "```";
612
+ for (const [m] of text.matchAll(/^```+/gm)) {
613
+ while (m.length >= escapedText.length) {
614
+ escapedText += "`";
615
+ }
616
+ }
617
+ return `${escapedText}\n${text}${text.endsWith("\n") ? "" : "\n"}${escapedText}`;
618
+ }
@@ -0,0 +1,64 @@
1
+ import type { HookCallback, HookInput } from "@anthropic-ai/claude-agent-sdk";
2
+ import type { TwigExecutionMode } from "./tools.js";
3
+
4
+ const toolUseCallbacks: {
5
+ [toolUseId: string]: {
6
+ onPostToolUseHook?: (
7
+ toolUseID: string,
8
+ toolInput: unknown,
9
+ toolResponse: unknown,
10
+ ) => Promise<void>;
11
+ };
12
+ } = {};
13
+
14
+ export const registerHookCallback = (
15
+ toolUseID: string,
16
+ {
17
+ onPostToolUseHook,
18
+ }: {
19
+ onPostToolUseHook?: (
20
+ toolUseID: string,
21
+ toolInput: unknown,
22
+ toolResponse: unknown,
23
+ ) => Promise<void>;
24
+ },
25
+ ) => {
26
+ toolUseCallbacks[toolUseID] = {
27
+ onPostToolUseHook,
28
+ };
29
+ };
30
+
31
+ export type OnModeChange = (mode: TwigExecutionMode) => Promise<void>;
32
+
33
+ interface CreatePostToolUseHookParams {
34
+ onModeChange?: OnModeChange;
35
+ }
36
+
37
+ export const createPostToolUseHook =
38
+ ({ onModeChange }: CreatePostToolUseHookParams): HookCallback =>
39
+ async (
40
+ input: HookInput,
41
+ toolUseID: string | undefined,
42
+ ): Promise<{ continue: boolean }> => {
43
+ if (input.hook_event_name === "PostToolUse") {
44
+ const toolName = input.tool_name;
45
+
46
+ if (onModeChange && toolName === "EnterPlanMode") {
47
+ await onModeChange("plan");
48
+ }
49
+
50
+ if (toolUseID) {
51
+ const onPostToolUseHook =
52
+ toolUseCallbacks[toolUseID]?.onPostToolUseHook;
53
+ if (onPostToolUseHook) {
54
+ await onPostToolUseHook(
55
+ toolUseID,
56
+ input.tool_input,
57
+ input.tool_response,
58
+ );
59
+ delete toolUseCallbacks[toolUseID];
60
+ }
61
+ }
62
+ }
63
+ return { continue: true };
64
+ };