agent-sh 0.8.0 → 0.10.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 (106) hide show
  1. package/README.md +27 -43
  2. package/dist/agent/agent-loop.d.ts +69 -6
  3. package/dist/agent/agent-loop.js +954 -153
  4. package/dist/agent/conversation-state.d.ts +74 -21
  5. package/dist/agent/conversation-state.js +361 -150
  6. package/dist/agent/history-file.d.ts +13 -4
  7. package/dist/agent/history-file.js +110 -36
  8. package/dist/agent/nuclear-form.d.ts +28 -3
  9. package/dist/agent/nuclear-form.js +88 -6
  10. package/dist/agent/skills.d.ts +2 -4
  11. package/dist/agent/skills.js +10 -4
  12. package/dist/agent/subagent.d.ts +23 -0
  13. package/dist/agent/subagent.js +53 -11
  14. package/dist/agent/system-prompt.d.ts +37 -5
  15. package/dist/agent/system-prompt.js +100 -67
  16. package/dist/{token-budget.d.ts → agent/token-budget.d.ts} +5 -4
  17. package/dist/{token-budget.js → agent/token-budget.js} +15 -20
  18. package/dist/agent/tool-protocol.d.ts +105 -0
  19. package/dist/agent/tool-protocol.js +551 -0
  20. package/dist/agent/tools/bash.js +3 -3
  21. package/dist/agent/tools/edit-file.js +9 -6
  22. package/dist/agent/tools/glob.js +4 -2
  23. package/dist/agent/tools/grep.js +27 -3
  24. package/dist/agent/tools/ls.js +5 -6
  25. package/dist/agent/types.d.ts +22 -2
  26. package/dist/context-manager.d.ts +17 -0
  27. package/dist/context-manager.js +37 -4
  28. package/dist/core.d.ts +7 -7
  29. package/dist/core.js +99 -196
  30. package/dist/event-bus.d.ts +85 -2
  31. package/dist/event-bus.js +20 -1
  32. package/dist/executor.d.ts +4 -3
  33. package/dist/executor.js +18 -15
  34. package/dist/extension-loader.d.ts +5 -0
  35. package/dist/extension-loader.js +143 -19
  36. package/dist/extensions/agent-backend.d.ts +14 -0
  37. package/dist/extensions/agent-backend.js +188 -0
  38. package/dist/extensions/command-suggest.d.ts +3 -3
  39. package/dist/extensions/command-suggest.js +4 -3
  40. package/dist/extensions/index.d.ts +19 -0
  41. package/dist/extensions/index.js +24 -0
  42. package/dist/extensions/slash-commands.d.ts +1 -1
  43. package/dist/extensions/slash-commands.js +30 -10
  44. package/dist/extensions/tui-renderer.js +117 -113
  45. package/dist/index.js +39 -26
  46. package/dist/settings.d.ts +40 -3
  47. package/dist/settings.js +57 -10
  48. package/dist/{input-handler.d.ts → shell/input-handler.d.ts} +3 -2
  49. package/dist/{input-handler.js → shell/input-handler.js} +111 -85
  50. package/dist/{output-parser.d.ts → shell/output-parser.d.ts} +1 -1
  51. package/dist/{output-parser.js → shell/output-parser.js} +1 -1
  52. package/dist/{shell.d.ts → shell/shell.d.ts} +8 -2
  53. package/dist/{shell.js → shell/shell.js} +39 -8
  54. package/dist/types.d.ts +61 -10
  55. package/dist/utils/ansi.d.ts +5 -0
  56. package/dist/utils/ansi.js +1 -1
  57. package/dist/utils/compositor.d.ts +67 -0
  58. package/dist/utils/compositor.js +116 -0
  59. package/dist/utils/diff-renderer.d.ts +9 -0
  60. package/dist/utils/diff-renderer.js +312 -146
  61. package/dist/utils/diff.d.ts +21 -2
  62. package/dist/utils/diff.js +165 -89
  63. package/dist/utils/floating-panel.d.ts +2 -0
  64. package/dist/utils/floating-panel.js +30 -14
  65. package/dist/utils/handler-registry.d.ts +31 -10
  66. package/dist/utils/handler-registry.js +58 -16
  67. package/dist/utils/line-editor.d.ts +33 -3
  68. package/dist/utils/line-editor.js +221 -44
  69. package/dist/utils/markdown.d.ts +1 -0
  70. package/dist/utils/markdown.js +1 -1
  71. package/dist/utils/message-utils.d.ts +35 -0
  72. package/dist/utils/message-utils.js +75 -0
  73. package/dist/utils/terminal-buffer.d.ts +5 -1
  74. package/dist/utils/terminal-buffer.js +18 -2
  75. package/dist/utils/tool-display.d.ts +1 -1
  76. package/dist/utils/tool-display.js +4 -4
  77. package/dist/utils/tool-interactive.d.ts +12 -0
  78. package/dist/utils/tool-interactive.js +53 -0
  79. package/examples/extensions/ash-acp-bridge/README.md +39 -0
  80. package/examples/extensions/ash-acp-bridge/package.json +23 -0
  81. package/examples/extensions/ash-acp-bridge/src/index.ts +574 -0
  82. package/examples/extensions/ash-acp-bridge/tsconfig.json +14 -0
  83. package/examples/extensions/ash-mcp-bridge/README.md +72 -0
  84. package/examples/extensions/ash-mcp-bridge/index.ts +164 -0
  85. package/examples/extensions/ash-mcp-bridge/package.json +9 -0
  86. package/examples/extensions/claude-code-bridge/index.ts +198 -51
  87. package/examples/extensions/claude-code-bridge/package.json +1 -0
  88. package/examples/extensions/interactive-prompts.ts +98 -112
  89. package/examples/extensions/overlay-agent.ts +84 -38
  90. package/examples/extensions/peer-mesh.ts +565 -0
  91. package/examples/extensions/pi-bridge/index.ts +2 -2
  92. package/examples/extensions/questionnaire.ts +260 -0
  93. package/examples/extensions/subagents.ts +19 -4
  94. package/examples/extensions/terminal-buffer.ts +32 -53
  95. package/examples/extensions/tmux-pane.ts +307 -0
  96. package/examples/extensions/user-shell.ts +136 -0
  97. package/examples/extensions/web-access.ts +335 -0
  98. package/package.json +44 -2
  99. package/dist/agent/tools/display.d.ts +0 -13
  100. package/dist/agent/tools/display.js +0 -70
  101. package/dist/agent/tools/user-shell.d.ts +0 -13
  102. package/dist/agent/tools/user-shell.js +0 -87
  103. package/dist/extensions/overlay-agent.d.ts +0 -14
  104. package/dist/extensions/overlay-agent.js +0 -147
  105. package/dist/extensions/terminal-buffer.d.ts +0 -14
  106. package/dist/extensions/terminal-buffer.js +0 -125
@@ -0,0 +1,551 @@
1
+ // ── API mode (current behavior) ──────────────────────────────────
2
+ export class ApiToolProtocol {
3
+ mode = "api";
4
+ getApiTools(tools) {
5
+ if (tools.length === 0)
6
+ return undefined;
7
+ return tools.map((t) => ({
8
+ type: "function",
9
+ function: {
10
+ name: t.name,
11
+ description: t.description,
12
+ parameters: t.input_schema,
13
+ },
14
+ }));
15
+ }
16
+ getToolPrompt() {
17
+ return "";
18
+ }
19
+ extractToolCalls(_text, streamedCalls) {
20
+ return streamedCalls;
21
+ }
22
+ rewriteToolCall(tc) {
23
+ return tc;
24
+ }
25
+ recordAssistant(conv, text, toolCalls) {
26
+ const calls = toolCalls.length
27
+ ? toolCalls.map((tc) => ({
28
+ id: tc.id,
29
+ function: { name: tc.name, arguments: tc.argumentsJson },
30
+ }))
31
+ : undefined;
32
+ conv.addAssistantMessage(text || null, calls);
33
+ }
34
+ recordResults(conv, results) {
35
+ for (const r of results) {
36
+ const content = r.isError ? `Error: ${r.content}` : r.content;
37
+ conv.addToolResult(r.callId, content, r.isError);
38
+ }
39
+ }
40
+ createStreamFilter() {
41
+ return null;
42
+ }
43
+ }
44
+ // ── Inline mode (JSON code block tool calls) ─────────────────────
45
+ export class InlineToolProtocol {
46
+ mode = "inline";
47
+ callCounter = 0;
48
+ getApiTools() {
49
+ return undefined;
50
+ }
51
+ getToolPrompt(tools) {
52
+ if (tools.length === 0)
53
+ return "";
54
+ const lines = [
55
+ "",
56
+ "# Tools",
57
+ "",
58
+ "To call a tool, write a ```tool fenced block with JSON:",
59
+ "",
60
+ "```tool",
61
+ '{"tool": "grep", "pattern": "TODO", "path": "src/"}',
62
+ "```",
63
+ "",
64
+ "The `tool` field selects which tool. All other fields are arguments.",
65
+ "Multiple tool blocks allowed per response.",
66
+ "",
67
+ "Available: " + tools.map((t) => `${t.name}${formatParams(t.input_schema)}`).join(", "),
68
+ ];
69
+ return lines.join("\n");
70
+ }
71
+ rewriteToolCall(tc) {
72
+ return tc;
73
+ }
74
+ extractToolCalls(text, _streamedCalls) {
75
+ const calls = [];
76
+ // Match ```tool ... ``` blocks
77
+ const regex = /```tool\s*\n([\s\S]*?)```/g;
78
+ let match;
79
+ while ((match = regex.exec(text)) !== null) {
80
+ const body = match[1].trim();
81
+ try {
82
+ const obj = JSON.parse(body);
83
+ const name = obj.tool;
84
+ if (typeof name !== "string")
85
+ continue;
86
+ // Separate tool name from args
87
+ const { tool: _, ...args } = obj;
88
+ calls.push({
89
+ id: `inline_${++this.callCounter}`,
90
+ name,
91
+ argumentsJson: JSON.stringify(args),
92
+ });
93
+ }
94
+ catch {
95
+ // Not valid JSON — skip
96
+ }
97
+ }
98
+ return calls;
99
+ }
100
+ recordAssistant(conv, text, _toolCalls) {
101
+ conv.addAssistantMessage(text || null);
102
+ }
103
+ recordResults(conv, results) {
104
+ if (results.length === 0)
105
+ return;
106
+ const parts = results.map((r) => {
107
+ const status = r.isError ? "error" : "ok";
108
+ return `[${r.toolName} ${r.callId} ${status}]\n${r.content}`;
109
+ });
110
+ conv.addToolResultInline(parts.join("\n\n"));
111
+ }
112
+ createStreamFilter(_toolNames) {
113
+ return new CodeBlockFilter();
114
+ }
115
+ }
116
+ // ── Code block stream filter ────────────────────────────────────
117
+ /**
118
+ * Strips ```tool ... ``` blocks from streamed text.
119
+ * Simple state machine: normal → in_fence → normal.
120
+ */
121
+ class CodeBlockFilter {
122
+ buf = "";
123
+ inFence = false;
124
+ lastEmittedNewlines = 0; // track trailing newlines to collapse blanks
125
+ feed(chunk) {
126
+ this.buf += chunk;
127
+ let raw = "";
128
+ while (this.buf.length > 0) {
129
+ if (this.inFence) {
130
+ // Look for closing ```
131
+ const closeIdx = this.buf.indexOf("```");
132
+ if (closeIdx !== -1) {
133
+ // Skip past closing ``` and any trailing whitespace on that line
134
+ let end = closeIdx + 3;
135
+ while (end < this.buf.length && this.buf[end] === "\n")
136
+ end++;
137
+ this.buf = this.buf.slice(end);
138
+ this.inFence = false;
139
+ continue;
140
+ }
141
+ // No closing yet — keep buffering
142
+ break;
143
+ }
144
+ // Look for opening ```tool
145
+ const openIdx = this.buf.indexOf("```tool");
146
+ if (openIdx !== -1) {
147
+ // Emit everything before the fence, trimming trailing newline
148
+ let before = this.buf.slice(0, openIdx);
149
+ if (before.endsWith("\n"))
150
+ before = before.slice(0, -1);
151
+ raw += before;
152
+ this.buf = this.buf.slice(openIdx + 7); // skip ```tool
153
+ this.inFence = true;
154
+ continue;
155
+ }
156
+ // Stray ``` on its own line (residual closing fence)
157
+ const strayIdx = this.buf.indexOf("```");
158
+ if (strayIdx !== -1) {
159
+ // Check if it's just backticks on a line (possibly with whitespace)
160
+ const lineStart = this.buf.lastIndexOf("\n", strayIdx - 1) + 1;
161
+ const lineEnd = this.buf.indexOf("\n", strayIdx);
162
+ const line = this.buf.slice(lineStart, lineEnd === -1 ? undefined : lineEnd).trim();
163
+ if (line === "```") {
164
+ raw += this.buf.slice(0, lineStart);
165
+ this.buf = this.buf.slice(lineEnd === -1 ? this.buf.length : lineEnd + 1);
166
+ continue;
167
+ }
168
+ }
169
+ // Could be a partial match at the end
170
+ const marker = "```tool";
171
+ let partial = false;
172
+ for (let i = Math.min(marker.length - 1, this.buf.length); i >= 1; i--) {
173
+ if (this.buf.endsWith(marker.slice(0, i))) {
174
+ raw += this.buf.slice(0, this.buf.length - i);
175
+ this.buf = this.buf.slice(this.buf.length - i);
176
+ partial = true;
177
+ break;
178
+ }
179
+ }
180
+ if (partial)
181
+ break;
182
+ // No fence anywhere — emit all
183
+ raw += this.buf;
184
+ this.buf = "";
185
+ }
186
+ // Collapse runs of 3+ newlines into 2 (one blank line max)
187
+ return this.collapseNewlines(raw);
188
+ }
189
+ flush() {
190
+ const out = this.collapseNewlines(this.buf);
191
+ this.buf = "";
192
+ this.inFence = false;
193
+ return out;
194
+ }
195
+ collapseNewlines(text) {
196
+ if (!text)
197
+ return text;
198
+ // Count leading newlines and merge with trailing from last emit
199
+ let i = 0;
200
+ while (i < text.length && text[i] === "\n")
201
+ i++;
202
+ const leading = i;
203
+ const totalNewlines = this.lastEmittedNewlines + leading;
204
+ // Allow at most 2 consecutive newlines
205
+ let prefix = "";
206
+ if (leading > 0) {
207
+ const allowed = Math.max(0, 2 - this.lastEmittedNewlines);
208
+ prefix = "\n".repeat(Math.min(leading, allowed));
209
+ text = text.slice(leading);
210
+ }
211
+ // Collapse internal runs
212
+ text = text.replace(/\n{3,}/g, "\n\n");
213
+ // Track trailing newlines for next call
214
+ let trailing = 0;
215
+ let j = text.length;
216
+ while (j > 0 && text[j - 1] === "\n") {
217
+ j--;
218
+ trailing++;
219
+ }
220
+ this.lastEmittedNewlines = trailing > 0 ? trailing : (prefix ? totalNewlines - leading + prefix.length : 0);
221
+ return prefix + text;
222
+ }
223
+ }
224
+ // ── Helpers ──────────────────────────────────────────────────────
225
+ function formatParams(schema) {
226
+ const props = schema.properties;
227
+ if (!props || Object.keys(props).length === 0)
228
+ return "()";
229
+ const required = new Set(schema.required ?? []);
230
+ const params = Object.entries(props).map(([name, prop]) => {
231
+ const opt = required.has(name) ? "" : "?";
232
+ const enumVals = prop.enum;
233
+ if (enumVals)
234
+ return `${name}${opt}: ${enumVals.join("|")}`;
235
+ return `${name}${opt}`;
236
+ });
237
+ return `(${params.join(", ")})`;
238
+ }
239
+ // ── Deferred mode (core tools full schema, extensions via meta-tool) ──
240
+ const META_TOOL_NAME = "use_extension";
241
+ export class DeferredToolProtocol {
242
+ mode = "deferred";
243
+ coreNames;
244
+ /** Cached extension tool schemas for arg validation. */
245
+ extSchemas = new Map();
246
+ constructor(coreNames) {
247
+ this.coreNames = new Set(coreNames);
248
+ }
249
+ getApiTools(tools) {
250
+ const core = tools.filter((t) => this.coreNames.has(t.name));
251
+ const ext = tools.filter((t) => !this.coreNames.has(t.name));
252
+ // Cache extension schemas for validation in rewriteToolCall
253
+ this.extSchemas.clear();
254
+ for (const t of ext) {
255
+ this.extSchemas.set(t.name, t.input_schema);
256
+ }
257
+ const apiTools = core.map((t) => ({
258
+ type: "function",
259
+ function: {
260
+ name: t.name,
261
+ description: t.description,
262
+ parameters: t.input_schema,
263
+ },
264
+ }));
265
+ if (ext.length > 0) {
266
+ const catalog = ext
267
+ .map((t) => `${t.name}${formatParams(t.input_schema)}`)
268
+ .join(", ");
269
+ apiTools.push({
270
+ type: "function",
271
+ function: {
272
+ name: META_TOOL_NAME,
273
+ description: `Call an extension tool. Available: ${catalog}`,
274
+ parameters: {
275
+ type: "object",
276
+ properties: {
277
+ name: { type: "string", description: "Tool name to call" },
278
+ args: {
279
+ type: "object",
280
+ description: "Tool arguments",
281
+ properties: {},
282
+ additionalProperties: true,
283
+ },
284
+ },
285
+ required: ["name"],
286
+ },
287
+ },
288
+ });
289
+ }
290
+ return apiTools.length > 0 ? apiTools : undefined;
291
+ }
292
+ getToolPrompt() {
293
+ return "";
294
+ }
295
+ extractToolCalls(_text, streamedCalls) {
296
+ return streamedCalls;
297
+ }
298
+ rewriteToolCall(tc) {
299
+ if (tc.name !== META_TOOL_NAME)
300
+ return tc;
301
+ // Unwrap: use_extension(name="foo", args={...}) → foo({...})
302
+ try {
303
+ const parsed = JSON.parse(tc.argumentsJson);
304
+ const targetName = parsed.name;
305
+ const targetArgs = (parsed.args ?? {});
306
+ // Validate: does the extension exist?
307
+ const schema = this.extSchemas.get(targetName);
308
+ if (!schema) {
309
+ const available = [...this.extSchemas.keys()].join(", ");
310
+ return {
311
+ id: tc.id,
312
+ name: META_TOOL_NAME,
313
+ argumentsJson: JSON.stringify({
314
+ _error: `Unknown extension "${targetName}". Available: ${available}`,
315
+ }),
316
+ };
317
+ }
318
+ // Validate: check for unknown/missing params against schema
319
+ const schemaProps = schema.properties;
320
+ const requiredParams = new Set(schema.required ?? []);
321
+ if (schemaProps) {
322
+ const validParams = new Set(Object.keys(schemaProps));
323
+ const providedParams = Object.keys(targetArgs);
324
+ // Check for unknown params (likely wrong names)
325
+ const unknown = providedParams.filter((p) => !validParams.has(p));
326
+ // Check for missing required params
327
+ const missing = [...requiredParams].filter((p) => !targetArgs[p]);
328
+ if (unknown.length > 0 || missing.length > 0) {
329
+ const expected = [...validParams]
330
+ .map((p) => `${p}${requiredParams.has(p) ? " (required)" : ""}`)
331
+ .join(", ");
332
+ let hint = `Wrong arguments for "${targetName}". Expected params: ${expected}.`;
333
+ if (unknown.length > 0)
334
+ hint += ` Unknown: ${unknown.join(", ")}.`;
335
+ if (missing.length > 0)
336
+ hint += ` Missing: ${missing.join(", ")}.`;
337
+ return {
338
+ id: tc.id,
339
+ name: META_TOOL_NAME,
340
+ argumentsJson: JSON.stringify({ _error: hint }),
341
+ };
342
+ }
343
+ }
344
+ return {
345
+ id: tc.id,
346
+ name: targetName,
347
+ argumentsJson: JSON.stringify(targetArgs),
348
+ };
349
+ }
350
+ catch {
351
+ return tc; // Let it fail naturally downstream
352
+ }
353
+ }
354
+ recordAssistant(conv, text, toolCalls) {
355
+ const calls = toolCalls.length
356
+ ? toolCalls.map((tc) => ({
357
+ id: tc.id,
358
+ function: { name: tc.name, arguments: tc.argumentsJson },
359
+ }))
360
+ : undefined;
361
+ conv.addAssistantMessage(text || null, calls);
362
+ }
363
+ recordResults(conv, results) {
364
+ for (const r of results) {
365
+ const content = r.isError ? `Error: ${r.content}` : r.content;
366
+ conv.addToolResult(r.callId, content, r.isError);
367
+ }
368
+ }
369
+ createStreamFilter() {
370
+ return null;
371
+ }
372
+ }
373
+ // ── Deferred-lookup mode (load-on-demand with full schema) ──────
374
+ //
375
+ // Like deferred, but instead of wrapping extension calls through a meta-
376
+ // tool dispatcher, we expose a `load_tool` meta-tool that returns the
377
+ // full schema as a tool result AND mutates the protocol's loaded set.
378
+ // Loaded tools become first-class on the NEXT LLM call — the model calls
379
+ // them natively with complete schema fidelity. One round-trip per group
380
+ // of tools loaded, not per call. Prevents the whole class of bugs where
381
+ // models guess arg names from a schema they can only see partially.
382
+ export class DeferredLookupProtocol {
383
+ mode = "deferred-lookup";
384
+ coreNames;
385
+ loadedExt = new Set();
386
+ /** Cache of the current tools list so load_tool's execute can find schemas. */
387
+ toolsRef = [];
388
+ constructor(coreNames) {
389
+ this.coreNames = new Set(coreNames);
390
+ }
391
+ getApiTools(tools) {
392
+ this.toolsRef = tools;
393
+ const visible = [];
394
+ const unloadedExt = [];
395
+ for (const t of tools) {
396
+ if (t.name === "load_tool")
397
+ continue; // rebuilt below with fresh catalog
398
+ const isCore = this.coreNames.has(t.name);
399
+ const isLoaded = this.loadedExt.has(t.name);
400
+ if (isCore || isLoaded) {
401
+ visible.push({
402
+ type: "function",
403
+ function: {
404
+ name: t.name,
405
+ description: t.description,
406
+ parameters: t.input_schema,
407
+ },
408
+ });
409
+ }
410
+ else {
411
+ unloadedExt.push(t.name);
412
+ }
413
+ }
414
+ if (unloadedExt.length > 0) {
415
+ visible.push({
416
+ type: "function",
417
+ function: {
418
+ name: "load_tool",
419
+ description: `Load extension tool schemas so you can call them on the next turn. ` +
420
+ `Unloaded: ${unloadedExt.join(", ")}. ` +
421
+ `After load_tool succeeds, call those tools directly — not through load_tool again.`,
422
+ parameters: {
423
+ type: "object",
424
+ properties: {
425
+ names: {
426
+ type: "array",
427
+ items: { type: "string" },
428
+ description: "Names of extension tools to load.",
429
+ },
430
+ },
431
+ required: ["names"],
432
+ },
433
+ },
434
+ });
435
+ }
436
+ return visible.length > 0 ? visible : undefined;
437
+ }
438
+ getToolPrompt() {
439
+ return "";
440
+ }
441
+ extractToolCalls(_text, streamedCalls) {
442
+ return streamedCalls;
443
+ }
444
+ rewriteToolCall(tc) {
445
+ return tc; // no dispatching needed — load_tool is a real registered tool
446
+ }
447
+ recordAssistant(conv, text, toolCalls) {
448
+ const calls = toolCalls.length
449
+ ? toolCalls.map((tc) => ({
450
+ id: tc.id,
451
+ function: { name: tc.name, arguments: tc.argumentsJson },
452
+ }))
453
+ : undefined;
454
+ conv.addAssistantMessage(text || null, calls);
455
+ }
456
+ recordResults(conv, results) {
457
+ for (const r of results) {
458
+ const content = r.isError ? `Error: ${r.content}` : r.content;
459
+ conv.addToolResult(r.callId, content, r.isError);
460
+ }
461
+ }
462
+ createStreamFilter() {
463
+ return null;
464
+ }
465
+ getProtocolTools() {
466
+ // load_tool is registered as a real tool so the executor can run it
467
+ // through the normal dispatch path. Its execute closes over the protocol
468
+ // instance to mutate the loadedExt set and return schemas.
469
+ const self = this;
470
+ return [
471
+ {
472
+ name: "load_tool",
473
+ description: "Load extension tool schemas so you can call them natively on the next turn.",
474
+ input_schema: {
475
+ type: "object",
476
+ properties: {
477
+ names: {
478
+ type: "array",
479
+ items: { type: "string" },
480
+ description: "Names of extension tools to load.",
481
+ },
482
+ },
483
+ required: ["names"],
484
+ },
485
+ showOutput: false,
486
+ async execute(args) {
487
+ const names = Array.isArray(args.names) ? args.names : [];
488
+ if (names.length === 0) {
489
+ return { content: "No tool names provided. Pass { names: [...] }.", exitCode: 1, isError: true };
490
+ }
491
+ const loaded = [];
492
+ const alreadyLoaded = [];
493
+ const errors = [];
494
+ const sections = [];
495
+ for (const name of names) {
496
+ const tool = self.toolsRef.find((t) => t.name === name);
497
+ if (!tool) {
498
+ errors.push(`Unknown tool: ${name}`);
499
+ continue;
500
+ }
501
+ if (self.coreNames.has(name) || name === "load_tool") {
502
+ errors.push(`${name} is already available — no need to load.`);
503
+ continue;
504
+ }
505
+ if (self.loadedExt.has(name)) {
506
+ alreadyLoaded.push(name);
507
+ continue;
508
+ }
509
+ self.loadedExt.add(name);
510
+ loaded.push(name);
511
+ sections.push(`## ${name}\n${tool.description}\n\nSchema:\n\`\`\`json\n${JSON.stringify(tool.input_schema, null, 2)}\n\`\`\``);
512
+ }
513
+ const lines = [];
514
+ if (loaded.length > 0) {
515
+ lines.push(`Loaded ${loaded.length} tool(s): ${loaded.join(", ")}. ` +
516
+ `They are now available as first-class tools on your next turn — call directly.`);
517
+ lines.push("");
518
+ lines.push(sections.join("\n\n"));
519
+ }
520
+ if (alreadyLoaded.length > 0) {
521
+ lines.push(`Already loaded: ${alreadyLoaded.join(", ")}.`);
522
+ }
523
+ if (errors.length > 0) {
524
+ lines.push(`Errors:\n${errors.map((e) => `- ${e}`).join("\n")}`);
525
+ }
526
+ return {
527
+ content: lines.join("\n") || "Nothing to do.",
528
+ exitCode: 0,
529
+ isError: loaded.length === 0 && alreadyLoaded.length === 0 && errors.length > 0,
530
+ };
531
+ },
532
+ },
533
+ ];
534
+ }
535
+ }
536
+ // ── Factory ─────────────────────────────────────────────────────
537
+ /** Core tool names — always sent with full schema. */
538
+ const CORE_TOOLS = [
539
+ "bash", "read_file", "write_file", "edit_file",
540
+ "grep", "glob", "ls",
541
+ "list_skills",
542
+ ];
543
+ export function createToolProtocol(mode) {
544
+ if (mode === "inline")
545
+ return new InlineToolProtocol();
546
+ if (mode === "deferred")
547
+ return new DeferredToolProtocol(CORE_TOOLS);
548
+ if (mode === "deferred-lookup")
549
+ return new DeferredLookupProtocol(CORE_TOOLS);
550
+ return new ApiToolProtocol();
551
+ }
@@ -3,10 +3,10 @@ export function createBashTool(opts) {
3
3
  return {
4
4
  name: "bash",
5
5
  description: "Execute a bash command in an isolated subprocess. Output is captured and returned. " +
6
- "Does not affect the user's shell state (use user_shell for cd, export, source). " +
6
+ "Does not affect the user's shell state. " +
7
+ "cwd is set to the working directory from the shell context. " +
7
8
  "Do NOT use bash for file searching — use grep/glob instead. " +
8
- "Do NOT use bash for reading files — use read_file instead. " +
9
- "Provide a description parameter to explain what the command does.",
9
+ "Do NOT use bash for reading files — use read_file instead.",
10
10
  input_schema: {
11
11
  type: "object",
12
12
  properties: {
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
- import { computeDiff } from "../../utils/diff.js";
3
+ import { computeEditDiff } from "../../utils/diff.js";
4
4
  /**
5
5
  * Find the closest matching region in the file content to help diagnose
6
6
  * why an exact match failed. Returns a hint string.
@@ -103,9 +103,12 @@ export function createEditFileTool(getCwd) {
103
103
  };
104
104
  }
105
105
  const normalizedNew = newText.replace(/\r\n/g, "\n");
106
- const newContent = replaceAll
107
- ? normalized.split(normalizedOld).join(normalizedNew)
108
- : normalized.replace(normalizedOld, normalizedNew);
106
+ // Use split/join for literal replacement everywhere. String.replace()
107
+ // treats dollar-sign patterns in the replacement as special substitution
108
+ // variables, which corrupts file content containing regex escape sequences.
109
+ const newContent = normalized.split(normalizedOld).join(normalizedNew);
110
+ // Note: when !replaceAll, we rely on the occurrence check above to ensure
111
+ // normalizedOld appears exactly once, so split/join replaces only that one.
109
112
  // Restore original line endings — only convert if the file was
110
113
  // predominantly CRLF (>50% of line endings), to avoid corrupting
111
114
  // mixed-ending files.
@@ -116,8 +119,8 @@ export function createEditFileTool(getCwd) {
116
119
  ? newContent.replace(/\n/g, "\r\n")
117
120
  : newContent;
118
121
  await fs.writeFile(absPath, finalContent);
119
- // Compute and stream diff for display
120
- const diff = computeDiff(normalized, newContent);
122
+ // Compute and stream diff for display (windowed — only diffs the edit region)
123
+ const diff = computeEditDiff(normalized, normalizedOld, normalizedNew, replaceAll);
121
124
  if (onChunk && diff.hunks.length > 0) {
122
125
  for (const hunk of diff.hunks) {
123
126
  for (const line of hunk.lines) {
@@ -4,9 +4,11 @@ import { executeCommand } from "../../executor.js";
4
4
  export function createGlobTool(getCwd) {
5
5
  return {
6
6
  name: "glob",
7
- description: "Find files by name pattern. Returns paths sorted by modification time (newest first). " +
7
+ description: "Use this when you know a FILENAME or PATH SHAPE (e.g. `**/*.ts`, `src/**/*.md`, `package.json`). " +
8
+ "Returns matching file paths sorted by modification time (newest first). " +
9
+ "This does NOT search file contents — use `grep` for that. " +
8
10
  "ALWAYS use this instead of find/ls via bash. " +
9
- "Use glob to locate files, then read_file or grep to inspect contents.",
11
+ "Typical flow: `glob` to locate files, then `read_file` or `grep` to inspect contents.",
10
12
  input_schema: {
11
13
  type: "object",
12
14
  properties: {
@@ -2,7 +2,9 @@ import { executeCommand } from "../../executor.js";
2
2
  export function createGrepTool(getCwd) {
3
3
  return {
4
4
  name: "grep",
5
- description: "Search file contents using ripgrep. ALWAYS use this instead of running grep/rg via bash. " +
5
+ description: "Use this when you know something INSIDE the file (text, identifier, regex). " +
6
+ "To find files by filename alone, use `glob` instead. " +
7
+ "Search file contents using ripgrep. ALWAYS use this instead of running grep/rg via bash. " +
6
8
  "Supports three output modes: " +
7
9
  "'files_with_matches' (default, returns file paths only — use this to find which files contain a pattern), " +
8
10
  "'content' (matching lines with optional context_before/context_after), and " +
@@ -13,7 +15,7 @@ export function createGrepTool(getCwd) {
13
15
  properties: {
14
16
  pattern: {
15
17
  type: "string",
16
- description: "Regex pattern to search for",
18
+ description: "Regex pattern to search for (NOT a glob — `*.md` is invalid here; use `.*\\.md` for regex, or use the glob tool to find files by name). For filename filtering while searching content, use the `include` parameter.",
17
19
  },
18
20
  path: {
19
21
  type: "string",
@@ -124,12 +126,34 @@ export function createGrepTool(getCwd) {
124
126
  });
125
127
  await done;
126
128
  if (session.exitCode === 1 && !session.output.trim()) {
129
+ // If the pattern looks like a filename (e.g. "SKILL.md", "package.json"),
130
+ // the agent probably meant to find files by name, not search inside them.
131
+ // Surface a redirect hint instead of a silent zero.
132
+ const looksLikeFilename = /^[A-Za-z0-9_.\-*/]+\.[A-Za-z0-9]{1,6}$/.test(pattern) &&
133
+ !/[\\()\[\]|^$+{}]/.test(pattern);
134
+ const hint = looksLikeFilename
135
+ ? ` Hint: "${pattern}" looks like a filename. grep searches file *contents* — to find files by name, use the \`glob\` tool instead.`
136
+ : "";
127
137
  return {
128
- content: "No matches found.",
138
+ content: `No matches found.${hint}`,
129
139
  exitCode: 0,
130
140
  isError: false,
131
141
  };
132
142
  }
143
+ // exit code >= 2 is a ripgrep error (invalid regex, unreadable path, etc).
144
+ // Surface it as an error so the model retries with a correct pattern
145
+ // rather than treating "no useful output" as a successful no-match.
146
+ if (session.exitCode != null && session.exitCode >= 2) {
147
+ const looksLikeGlob = /^[*?]|\*\./.test(pattern) && !/[\\()\[\]|^$]/.test(pattern);
148
+ const hint = looksLikeGlob
149
+ ? " Hint: `*.md` is a glob, not a regex — use the glob tool to find files by name, or pass `include: \"*.md\"` here to filter files while searching content for a regex pattern."
150
+ : "";
151
+ return {
152
+ content: `grep failed (rg exit ${session.exitCode}): ${session.output.trim() || "no output"}${hint}`,
153
+ exitCode: session.exitCode,
154
+ isError: true,
155
+ };
156
+ }
133
157
  let output = session.output;
134
158
  // Cap individual line lengths to 500 chars to prevent minified/base64 flood
135
159
  if (mode === "content") {