skyloom 1.16.2 → 1.18.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 (109) hide show
  1. package/README.md +15 -3
  2. package/dist/cli/loom_chat.d.ts.map +1 -1
  3. package/dist/cli/loom_chat.js +17 -0
  4. package/dist/cli/loom_chat.js.map +1 -1
  5. package/dist/cli/main.js +37 -1
  6. package/dist/cli/main.js.map +1 -1
  7. package/dist/core/agent.d.ts +2 -0
  8. package/dist/core/agent.d.ts.map +1 -1
  9. package/dist/core/agent.js +21 -5
  10. package/dist/core/agent.js.map +1 -1
  11. package/dist/core/bgproc.d.ts +59 -0
  12. package/dist/core/bgproc.d.ts.map +1 -0
  13. package/dist/core/bgproc.js +135 -0
  14. package/dist/core/bgproc.js.map +1 -0
  15. package/dist/core/commands.d.ts.map +1 -1
  16. package/dist/core/commands.js +20 -0
  17. package/dist/core/commands.js.map +1 -1
  18. package/dist/core/diagnostics.d.ts +39 -0
  19. package/dist/core/diagnostics.d.ts.map +1 -0
  20. package/dist/core/diagnostics.js +206 -0
  21. package/dist/core/diagnostics.js.map +1 -0
  22. package/dist/core/diff.d.ts +31 -0
  23. package/dist/core/diff.d.ts.map +1 -0
  24. package/dist/core/diff.js +82 -0
  25. package/dist/core/diff.js.map +1 -0
  26. package/dist/core/envcontext.d.ts +25 -0
  27. package/dist/core/envcontext.d.ts.map +1 -0
  28. package/dist/core/envcontext.js +112 -0
  29. package/dist/core/envcontext.js.map +1 -0
  30. package/dist/core/factory.d.ts +2 -0
  31. package/dist/core/factory.d.ts.map +1 -1
  32. package/dist/core/factory.js +35 -2
  33. package/dist/core/factory.js.map +1 -1
  34. package/dist/core/patch.d.ts +59 -0
  35. package/dist/core/patch.d.ts.map +1 -0
  36. package/dist/core/patch.js +220 -0
  37. package/dist/core/patch.js.map +1 -0
  38. package/dist/core/protocol.d.ts +11 -0
  39. package/dist/core/protocol.d.ts.map +1 -0
  40. package/dist/core/protocol.js +39 -0
  41. package/dist/core/protocol.js.map +1 -0
  42. package/dist/core/sandbox.d.ts +1 -0
  43. package/dist/core/sandbox.d.ts.map +1 -1
  44. package/dist/core/sandbox.js +1 -0
  45. package/dist/core/sandbox.js.map +1 -1
  46. package/dist/core/search.d.ts +41 -0
  47. package/dist/core/search.d.ts.map +1 -0
  48. package/dist/core/search.js +156 -0
  49. package/dist/core/search.js.map +1 -0
  50. package/dist/core/security.d.ts +22 -2
  51. package/dist/core/security.d.ts.map +1 -1
  52. package/dist/core/security.js +55 -24
  53. package/dist/core/security.js.map +1 -1
  54. package/dist/core/skill.d.ts +4 -0
  55. package/dist/core/skill.d.ts.map +1 -1
  56. package/dist/core/skill.js +1 -0
  57. package/dist/core/skill.js.map +1 -1
  58. package/dist/core/subagent.d.ts +75 -0
  59. package/dist/core/subagent.d.ts.map +1 -0
  60. package/dist/core/subagent.js +287 -0
  61. package/dist/core/subagent.js.map +1 -0
  62. package/dist/core/tool.d.ts +23 -1
  63. package/dist/core/tool.d.ts.map +1 -1
  64. package/dist/core/tool.js +95 -30
  65. package/dist/core/tool.js.map +1 -1
  66. package/dist/plugins/loader.d.ts +49 -8
  67. package/dist/plugins/loader.d.ts.map +1 -1
  68. package/dist/plugins/loader.js +129 -16
  69. package/dist/plugins/loader.js.map +1 -1
  70. package/dist/tools/builtin.d.ts.map +1 -1
  71. package/dist/tools/builtin.js +183 -17
  72. package/dist/tools/builtin.js.map +1 -1
  73. package/dist/tools/spawn.d.ts +23 -0
  74. package/dist/tools/spawn.d.ts.map +1 -0
  75. package/dist/tools/spawn.js +77 -0
  76. package/dist/tools/spawn.js.map +1 -0
  77. package/docs/OPTIMIZATION_PLAN.md +21 -4
  78. package/package.json +1 -1
  79. package/src/cli/loom_chat.ts +11 -0
  80. package/src/cli/main.ts +31 -1
  81. package/src/core/agent.ts +20 -5
  82. package/src/core/bgproc.ts +153 -0
  83. package/src/core/commands.ts +20 -0
  84. package/src/core/diagnostics.ts +178 -0
  85. package/src/core/diff.ts +98 -0
  86. package/src/core/envcontext.ts +79 -0
  87. package/src/core/factory.ts +31 -2
  88. package/src/core/patch.ts +176 -0
  89. package/src/core/protocol.ts +36 -0
  90. package/src/core/sandbox.ts +1 -1
  91. package/src/core/search.ts +138 -0
  92. package/src/core/security.ts +63 -21
  93. package/src/core/skill.ts +1 -1
  94. package/src/core/subagent.ts +272 -0
  95. package/src/core/tool.ts +101 -31
  96. package/src/plugins/loader.ts +145 -18
  97. package/src/tools/builtin.ts +167 -17
  98. package/src/tools/spawn.ts +92 -0
  99. package/tests/bgproc.test.ts +65 -0
  100. package/tests/diagnostics.test.ts +86 -0
  101. package/tests/edit_diff.test.ts +102 -0
  102. package/tests/envcontext.test.ts +67 -0
  103. package/tests/patch.test.ts +128 -0
  104. package/tests/plugins.test.ts +84 -0
  105. package/tests/protocol.test.ts +27 -0
  106. package/tests/search.test.ts +87 -0
  107. package/tests/security.test.ts +87 -0
  108. package/tests/subagent.test.ts +211 -0
  109. package/tests/tool.test.ts +120 -0
package/src/core/tool.ts CHANGED
@@ -46,6 +46,14 @@ export interface ToolDefinition {
46
46
  */
47
47
  idempotent?: boolean;
48
48
  timeout?: number;
49
+ /**
50
+ * Optional output guard: inspect the handler's result and return an error
51
+ * message if it's not valid (else null/undefined). A non-null return makes
52
+ * the call fail — routed through the same retry + circuit-breaker path as a
53
+ * thrown error — so a tool/plugin can reject malformed output instead of
54
+ * passing garbage back to the model as "success".
55
+ */
56
+ validateOutput?: (result: string, params: Record<string, unknown>) => string | null | undefined;
49
57
  }
50
58
 
51
59
  /** Order-stable JSON key so {a,b} and {b,a} hash to the same cache/dedup key. */
@@ -144,15 +152,22 @@ function coerceValue(value: unknown, targetType: string): [boolean, unknown] {
144
152
  return [true, value];
145
153
  }
146
154
 
155
+ // string target accepts scalars (number/boolean) by stringifying them —
156
+ // the model sometimes sends 5 where a string id is expected.
157
+ if (targetType === "string" && (typeof value === "number" || typeof value === "boolean")) {
158
+ return [true, String(value)];
159
+ }
160
+
147
161
  // Lenient coercion from string
148
162
  if (typeof value === "string") {
149
163
  const stripped = value.trim();
150
164
 
151
165
  if (targetType === "integer" || targetType === "number") {
152
- const num = parseInt(stripped, 10);
153
- if (!isNaN(num)) return [true, num];
154
- const float = parseFloat(stripped);
155
- if (!isNaN(float)) return [true, float];
166
+ // Number() handles ints and floats uniformly; parseInt truncated floats
167
+ // ("3.5" -> 3), silently corrupting numeric args.
168
+ if (stripped === "") return [false, value];
169
+ const num = Number(stripped);
170
+ if (!isNaN(num)) return [true, targetType === "integer" ? Math.trunc(num) : num];
156
171
  return [false, value];
157
172
  }
158
173
 
@@ -164,16 +179,35 @@ function coerceValue(value: unknown, targetType: string): [boolean, unknown] {
164
179
  }
165
180
 
166
181
  if (targetType === "array") {
182
+ // The model often sends a JSON-encoded array string for array params.
183
+ if (stripped.startsWith("[")) {
184
+ try { const parsed = JSON.parse(stripped); if (Array.isArray(parsed)) return [true, parsed]; } catch { /* fall through */ }
185
+ }
167
186
  if (stripped.includes(",")) {
168
187
  return [true, stripped.split(",").map((s) => s.trim())];
169
188
  }
170
189
  return [true, [value]];
171
190
  }
191
+
192
+ if (targetType === "object") {
193
+ try {
194
+ const parsed = JSON.parse(stripped);
195
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return [true, parsed];
196
+ } catch { /* not JSON */ }
197
+ return [false, value];
198
+ }
172
199
  }
173
200
 
174
201
  return [false, value];
175
202
  }
176
203
 
204
+ /** Describe a value's runtime type for an error message. */
205
+ function describeType(v: unknown): string {
206
+ if (v === null) return "null";
207
+ if (Array.isArray(v)) return "array";
208
+ return typeof v;
209
+ }
210
+
177
211
  /**
178
212
  * Tool registry and executor
179
213
  */
@@ -265,32 +299,59 @@ export class ToolRegistry extends EventEmitter {
265
299
  }
266
300
 
267
301
  /**
268
- * Validate tool parameters
302
+ * Validate tool inputs against the declared schema AND return coerced args:
303
+ * required-field presence, per-param type coercion (string<->number/bool,
304
+ * JSON-string -> array/object), and enum membership. Returning the coerced
305
+ * params means the handler receives clean, typed values (the model often
306
+ * sends "5" / "true" / a JSON string), and rejecting malformed input before
307
+ * execution gives the model a precise, actionable error to retry against.
269
308
  */
270
- validateParameters(toolName: string, params: Record<string, unknown>): [boolean, string] {
309
+ validateAndCoerce(
310
+ toolName: string,
311
+ params: Record<string, unknown>,
312
+ ): { ok: boolean; error?: string; params?: Record<string, unknown> } {
271
313
  const tool = this.tools.get(toolName);
272
- if (!tool) {
273
- return [false, `Tool ${toolName} not found`];
274
- }
275
-
276
- if (!tool.parameters) {
277
- return [true, ""];
278
- }
314
+ if (!tool) return { ok: false, error: `Tool ${toolName} not found` };
315
+ if (!tool.parameters || tool.parameters.length === 0) return { ok: true, params };
279
316
 
317
+ const out: Record<string, unknown> = { ...params };
280
318
  for (const param of tool.parameters) {
281
- if (param.required && !(param.name in params)) {
282
- return [false, `Missing required parameter: ${param.name}`];
319
+ const has = param.name in params && params[param.name] !== null && params[param.name] !== undefined;
320
+ if (!has) {
321
+ if (param.required) {
322
+ return { ok: false, error: `Missing required parameter '${param.name}' (expected ${param.type}).` };
323
+ }
324
+ continue;
283
325
  }
284
326
 
285
- if (param.name in params) {
286
- const [valid] = coerceValue(params[param.name], param.type);
287
- if (!valid) {
288
- return [false, `Invalid type for parameter ${param.name}: expected ${param.type}`];
289
- }
327
+ const [valid, coerced] = coerceValue(params[param.name], param.type);
328
+ if (!valid) {
329
+ return {
330
+ ok: false,
331
+ error: `Invalid type for parameter '${param.name}': expected ${param.type}, got ${describeType(params[param.name])}.`,
332
+ };
333
+ }
334
+
335
+ if (param.enum && param.enum.length > 0 && !param.enum.includes(String(coerced))) {
336
+ return {
337
+ ok: false,
338
+ error: `Invalid value for parameter '${param.name}': '${String(coerced)}' is not allowed. Valid values: ${param.enum.join(", ")}.`,
339
+ };
290
340
  }
341
+
342
+ out[param.name] = coerced;
291
343
  }
292
344
 
293
- return [true, ""];
345
+ return { ok: true, params: out };
346
+ }
347
+
348
+ /**
349
+ * Validate tool parameters (legacy boolean/message form; delegates to
350
+ * validateAndCoerce).
351
+ */
352
+ validateParameters(toolName: string, params: Record<string, unknown>): [boolean, string] {
353
+ const r = this.validateAndCoerce(toolName, params);
354
+ return [r.ok, r.error || ""];
294
355
  }
295
356
 
296
357
  /**
@@ -316,6 +377,18 @@ export class ToolRegistry extends EventEmitter {
316
377
  };
317
378
  }
318
379
 
380
+ // Validate + coerce inputs against the schema BEFORE cache/execute, so the
381
+ // handler and the cache key both use clean, typed values.
382
+ const validated = this.validateAndCoerce(toolName, params);
383
+ if (!validated.ok) {
384
+ return {
385
+ success: false,
386
+ result: "",
387
+ error: validated.error,
388
+ };
389
+ }
390
+ params = validated.params!;
391
+
319
392
  // Check cache
320
393
  if (tool.cacheable) {
321
394
  const cacheKey = stableStringify(params);
@@ -330,16 +403,6 @@ export class ToolRegistry extends EventEmitter {
330
403
  }
331
404
  }
332
405
 
333
- // Validate parameters
334
- const [valid, error] = this.validateParameters(toolName, params);
335
- if (!valid) {
336
- return {
337
- success: false,
338
- result: "",
339
- error,
340
- };
341
- }
342
-
343
406
  // Execute with retries
344
407
  const maxRetries = tool.maxRetries ?? DEFAULT_RETRIES;
345
408
  const retryDelay = (tool.retryDelay ?? DEFAULT_RETRY_DELAY) * 1000;
@@ -375,6 +438,13 @@ export class ToolRegistry extends EventEmitter {
375
438
  if (timer) clearTimeout(timer);
376
439
  }
377
440
 
441
+ // Output guard: a non-null message means the result is invalid. Throw so
442
+ // it flows through the same retry + breaker path as any other failure.
443
+ if (tool.validateOutput) {
444
+ const outErr = tool.validateOutput(result, params);
445
+ if (outErr) throw new Error(`invalid tool output: ${outErr}`);
446
+ }
447
+
378
448
  const duration = Date.now() - startTime;
379
449
 
380
450
  // Cache result
@@ -1,14 +1,52 @@
1
1
  /**
2
- * Plugin loader — loads external plugins that register additional tools.
2
+ * Plugin loader — discovers external plugins and runs them through an ordered
3
+ * hook lifecycle.
4
+ *
5
+ * A plugin is a directory with an `index.js` exporting either:
6
+ * - activate(ctx): the lifecycle form. `ctx` scopes every registration to the
7
+ * plugin (registerTool / on(hook, fn)), so unload(name) cleanly removes
8
+ * exactly what the plugin added.
9
+ * - register(registry): the legacy form. Still supported — tools it adds are
10
+ * diffed against the registry so they're tracked for unload too.
11
+ *
12
+ * Hooks fire in registration order. Core hooks: `init` (after all plugins
13
+ * load), `tool.register` (a tool was added), `provider.update` (model/provider
14
+ * config changed). Plugins may define and emit their own hook names.
3
15
  */
4
16
 
5
17
  import * as fs from 'fs';
6
18
  import * as path from 'path';
7
- import { ToolRegistry } from '../core/tool';
19
+ import { ToolRegistry, type ToolDefinition } from '../core/tool';
8
20
  import { getLogger } from '../core/logger';
9
21
 
10
22
  const log = getLogger('plugin-loader');
11
23
 
24
+ export type PluginHook = 'init' | 'tool.register' | 'provider.update' | string;
25
+ export type HookHandler = (payload?: any) => void | Promise<void>;
26
+
27
+ /** Scoped API handed to a plugin's activate(); every call is tracked for unload. */
28
+ export interface PluginContext {
29
+ readonly name: string;
30
+ readonly config: any;
31
+ readonly log: ReturnType<typeof getLogger>;
32
+ registerTool(def: ToolDefinition): void;
33
+ on(hook: PluginHook, handler: HookHandler): void;
34
+ }
35
+
36
+ export interface Plugin {
37
+ name?: string;
38
+ activate?(ctx: PluginContext): void | Promise<void>;
39
+ register?(registry: ToolRegistry): void; // legacy
40
+ deactivate?(): void | Promise<void>;
41
+ }
42
+
43
+ interface LoadedPlugin {
44
+ name: string;
45
+ module: Plugin;
46
+ tools: string[];
47
+ hooks: Array<{ hook: string; fn: HookHandler }>;
48
+ }
49
+
12
50
  /**
13
51
  * A plugin path is safe to `require` only if neither it nor (on POSIX) its
14
52
  * permissions allow group/world write — otherwise a less-privileged user could
@@ -28,14 +66,17 @@ export function isSafePluginPath(target: string): boolean {
28
66
 
29
67
  export class PluginLoader {
30
68
  private toolRegistry: ToolRegistry;
69
+ private config: any;
70
+ private plugins = new Map<string, LoadedPlugin>();
71
+ /** hook name -> handlers in registration order, each tagged with its plugin. */
72
+ private hookHandlers = new Map<string, Array<{ plugin: string; fn: HookHandler }>>();
31
73
 
32
- constructor(toolRegistry: ToolRegistry) {
74
+ constructor(toolRegistry: ToolRegistry, config?: any) {
33
75
  this.toolRegistry = toolRegistry;
76
+ this.config = config ?? {};
34
77
  }
35
78
 
36
- /**
37
- * Load plugins from specified directories.
38
- */
79
+ /** Load plugins from specified directories. Returns the number activated. */
39
80
  loadFromDirectories(directories: string[]): number {
40
81
  let total = 0;
41
82
  for (const dir of directories) {
@@ -44,9 +85,6 @@ export class PluginLoader {
44
85
  return total;
45
86
  }
46
87
 
47
- /**
48
- * Load a single plugin directory.
49
- */
50
88
  private loadDirectory(dir: string): number {
51
89
  if (!fs.existsSync(dir)) {
52
90
  log.debug('plugin_dir_not_found', { dir });
@@ -55,10 +93,9 @@ export class PluginLoader {
55
93
 
56
94
  let count = 0;
57
95
  try {
58
- const entries = fs.readdirSync(dir);
59
- for (const entry of entries) {
96
+ for (const entry of fs.readdirSync(dir)) {
60
97
  const pluginPath = path.join(dir, entry);
61
- if (!fs.statSync(pluginPath).isDirectory()) continue;
98
+ try { if (!fs.statSync(pluginPath).isDirectory()) continue; } catch { continue; }
62
99
 
63
100
  const pluginFile = path.join(pluginPath, 'index.js');
64
101
  if (!fs.existsSync(pluginFile)) continue;
@@ -72,12 +109,8 @@ export class PluginLoader {
72
109
  }
73
110
 
74
111
  try {
75
- const plugin = require(pluginFile);
76
- if (typeof plugin.register === 'function') {
77
- plugin.register(this.toolRegistry);
78
- count++;
79
- log.info('plugin_loaded', { name: entry });
80
- }
112
+ const mod = require(pluginFile) as Plugin;
113
+ if (this.activatePlugin(entry, mod)) count++;
81
114
  } catch (e) {
82
115
  log.warn('plugin_load_failed', { name: entry, error: String(e) });
83
116
  }
@@ -88,4 +121,98 @@ export class PluginLoader {
88
121
 
89
122
  return count;
90
123
  }
124
+
125
+ /**
126
+ * Activate a plugin module under a name. Reactivating an already-loaded name
127
+ * unloads the previous instance first. Returns true if anything registered.
128
+ */
129
+ activatePlugin(name: string, mod: Plugin): boolean {
130
+ const pluginName = mod.name || name;
131
+ if (this.plugins.has(pluginName)) this.unload(pluginName);
132
+
133
+ const record: LoadedPlugin = { name: pluginName, module: mod, tools: [], hooks: [] };
134
+ const self = this;
135
+
136
+ if (typeof mod.activate === 'function') {
137
+ const ctx: PluginContext = {
138
+ name: pluginName,
139
+ config: this.config,
140
+ log: getLogger(`plugin:${pluginName}`),
141
+ registerTool(def: ToolDefinition) {
142
+ self.toolRegistry.register(def);
143
+ record.tools.push(def.name);
144
+ void self.emit('tool.register', { plugin: pluginName, tool: def.name });
145
+ },
146
+ on(hook: PluginHook, handler: HookHandler) {
147
+ record.hooks.push({ hook, fn: handler });
148
+ const arr = self.hookHandlers.get(hook) || [];
149
+ arr.push({ plugin: pluginName, fn: handler });
150
+ self.hookHandlers.set(hook, arr);
151
+ },
152
+ };
153
+ try {
154
+ void mod.activate(ctx);
155
+ } catch (e) {
156
+ log.warn('plugin_activate_failed', { name: pluginName, error: String(e) });
157
+ this.unload(pluginName);
158
+ return false;
159
+ }
160
+ } else if (typeof mod.register === 'function') {
161
+ // Legacy: diff the registry to learn which tools the plugin added.
162
+ const before = new Set(this.toolRegistry.listNames());
163
+ try {
164
+ mod.register(this.toolRegistry);
165
+ } catch (e) {
166
+ log.warn('plugin_register_failed', { name: pluginName, error: String(e) });
167
+ return false;
168
+ }
169
+ for (const n of this.toolRegistry.listNames()) {
170
+ if (!before.has(n)) record.tools.push(n);
171
+ }
172
+ } else {
173
+ log.warn('plugin_no_entrypoint', { name: pluginName });
174
+ return false;
175
+ }
176
+
177
+ this.plugins.set(pluginName, record);
178
+ log.info('plugin_loaded', { name: pluginName, tools: record.tools.length, hooks: record.hooks.length });
179
+ return true;
180
+ }
181
+
182
+ /** Fire a hook; handlers run in registration order. Errors are isolated. */
183
+ async emit(hook: PluginHook, payload?: any): Promise<void> {
184
+ const handlers = this.hookHandlers.get(hook);
185
+ if (!handlers || handlers.length === 0) return;
186
+ for (const { plugin, fn } of [...handlers]) {
187
+ try {
188
+ await fn(payload);
189
+ } catch (e) {
190
+ log.warn('plugin_hook_failed', { hook, plugin, error: String(e) });
191
+ }
192
+ }
193
+ }
194
+
195
+ /** Unload a plugin: remove its tools and hook handlers, call deactivate. */
196
+ unload(name: string): boolean {
197
+ const record = this.plugins.get(name);
198
+ if (!record) return false;
199
+
200
+ for (const tool of record.tools) this.toolRegistry.unregister(tool);
201
+ for (const [hook, arr] of this.hookHandlers) {
202
+ const filtered = arr.filter((h) => h.plugin !== name);
203
+ if (filtered.length) this.hookHandlers.set(hook, filtered);
204
+ else this.hookHandlers.delete(hook);
205
+ }
206
+ try { record.module.deactivate?.(); } catch (e) { log.warn('plugin_deactivate_failed', { name, error: String(e) }); }
207
+
208
+ this.plugins.delete(name);
209
+ log.info('plugin_unloaded', { name });
210
+ return true;
211
+ }
212
+
213
+ /** Names of currently loaded plugins. */
214
+ list(): string[] { return [...this.plugins.keys()]; }
215
+
216
+ /** Number of handlers registered for a hook (for diagnostics/tests). */
217
+ hookCount(hook: PluginHook): number { return this.hookHandlers.get(hook)?.length ?? 0; }
91
218
  }
@@ -10,6 +10,10 @@ import { registerComputerTools } from './computer';
10
10
  import { registerExtraTools } from './extra';
11
11
  import { isPrivateIp, assertFetchAllowed, fenceRoot, fenceCheck } from './guards';
12
12
  import { webSearch, formatSearchResults, readPage } from './websearch';
13
+ import { countOccurrences, unifiedDiff } from '../core/diff';
14
+ import { getDiagnostics, formatDiagnostics } from '../core/diagnostics';
15
+ import { searchCode, formatSearchResult } from '../core/search';
16
+ import { applyPatch } from '../core/patch';
13
17
 
14
18
  // Re-exported so existing importers/tests keep resolving these from builtin.
15
19
  export { isPrivateIp, assertFetchAllowed, fenceRoot, fenceCheck };
@@ -77,32 +81,93 @@ export function registerBuiltinTools(registry: ToolRegistry): void {
77
81
 
78
82
  registry.register({
79
83
  name: 'edit_file',
80
- description: 'Edit a file by replacing old_text with new_text. Use this for targeted edits.',
84
+ description: 'Edit a file by replacing an exact occurrence of old_text with new_text. old_text must match the file exactly (including whitespace and indentation) and must be UNIQUE — include enough surrounding context to disambiguate, or set replace_all to change every occurrence. Returns a unified diff of the change.',
81
85
  parameters: [
82
86
  { name: 'path', type: 'string', description: 'Path to the file to edit', required: true },
83
- { name: 'old_text', type: 'string', description: 'Text to search for and replace', required: true },
84
- { name: 'new_text', type: 'string', description: 'Text to replace with', required: true },
87
+ { name: 'old_text', type: 'string', description: 'Exact text to replace (must match the file verbatim, and be unique unless replace_all is set)', required: true },
88
+ { name: 'new_text', type: 'string', description: 'Replacement text (must differ from old_text)', required: true },
89
+ { name: 'replace_all', type: 'boolean', description: 'Replace every occurrence instead of requiring a unique match (default false)', required: false },
85
90
  ],
86
91
  handler: async (params) => {
87
92
  const filePath = path.resolve(params.path as string);
88
93
  const fenced = fenceCheck(filePath); if (fenced) return fenced;
89
94
  if (!fs.existsSync(filePath)) return `Error: File not found: ${filePath}`;
95
+ const oldText = params.old_text as string;
96
+ const newText = params.new_text as string;
97
+ const replaceAll = params.replace_all === true || params.replace_all === 'true';
98
+ if (oldText === newText) return 'Error: old_text and new_text are identical — nothing to change.';
90
99
  try {
91
- let content = fs.readFileSync(filePath, 'utf-8');
92
- const oldText = params.old_text as string;
93
- const newText = params.new_text as string;
94
- if (!content.includes(oldText)) {
95
- return `Error: old_text not found in file. Searched for: ${oldText.slice(0, 50)}...`;
100
+ const orig = fs.readFileSync(filePath, 'utf-8');
101
+ const n = countOccurrences(orig, oldText);
102
+ if (n === 0) {
103
+ return `Error: old_text not found in ${filePath}. It must match exactly (including whitespace). Searched for: ${JSON.stringify(oldText.slice(0, 80))}`;
96
104
  }
97
- content = content.replace(oldText, newText);
98
- fs.writeFileSync(filePath, content, 'utf-8');
99
- return `Successfully edited ${filePath}`;
105
+ if (n > 1 && !replaceAll) {
106
+ return `Error: old_text appears ${n} times in ${filePath} — the edit is ambiguous. Add more surrounding context to make it unique, or set replace_all=true to change all ${n} occurrences.`;
107
+ }
108
+ // Literal replacement: split/join (replace_all) and a function replacer
109
+ // (single) both avoid String.replace interpreting `$&`/`$1` in new_text.
110
+ const updated = replaceAll
111
+ ? orig.split(oldText).join(newText)
112
+ : orig.replace(oldText, () => newText);
113
+ fs.writeFileSync(filePath, updated, 'utf-8');
114
+ const rel = path.relative(process.cwd(), filePath) || filePath;
115
+ const diff = unifiedDiff(orig, updated, { path: rel, context: 3 });
116
+ const occ = replaceAll ? ` (${n} occurrence${n > 1 ? 's' : ''})` : '';
117
+ const body = diff.text ? `\n${diff.text}` : '';
118
+ return `Successfully edited ${filePath}${occ} · +${diff.stat.added} -${diff.stat.removed}${body}`;
100
119
  } catch (e) {
101
120
  return `Error editing file: ${e}`;
102
121
  }
103
122
  },
104
123
  });
105
124
 
125
+ registry.register({
126
+ name: 'get_diagnostics',
127
+ idempotent: true,
128
+ description: 'Get LSP-style diagnostics (type errors, lint issues with line:col) for a source file. TS/JS work out of the box via the workspace TypeScript; other languages use a configured checker (config.yaml diagnostics map). Call this after editing code to confirm it is error-free, or to locate the root cause of a type error.',
129
+ parameters: [
130
+ { name: 'path', type: 'string', description: 'Path to the source file to check', required: true },
131
+ ],
132
+ handler: async (params) => {
133
+ const filePath = path.resolve(params.path as string);
134
+ const fenced = fenceCheck(filePath); if (fenced) return fenced;
135
+ let config: any = {};
136
+ try { const { loadConfig } = require('../core/config'); config = loadConfig(); } catch { /* defaults */ }
137
+ try {
138
+ const result = getDiagnostics(filePath, config);
139
+ if (!Array.isArray(result)) return `[diagnostics unavailable] ${result.unavailable}`;
140
+ return formatDiagnostics(path.relative(process.cwd(), filePath) || filePath, result);
141
+ } catch (e) {
142
+ return `Error getting diagnostics: ${e}`;
143
+ }
144
+ },
145
+ });
146
+
147
+ registry.register({
148
+ name: 'apply_patch',
149
+ description: 'Apply an atomic, multi-file edit in one call — ideal for larger refactors touching several places/files. The whole patch is validated before anything is written, so a bad block aborts cleanly with no half-applied changes. Each SEARCH must match the file exactly and uniquely. Format:\n*** Update File: path\n<<<<<<< SEARCH\nold exact text\n=======\nnew text\n>>>>>>> REPLACE\n(repeat blocks; also *** Add File: path / full content, and *** Delete File: path)',
150
+ parameters: [
151
+ { name: 'patch', type: 'string', description: 'The patch text in the *** Update/Add/Delete File + SEARCH/REPLACE format', required: true },
152
+ ],
153
+ handler: async (params) => {
154
+ let snapshot: ((abs: string) => void) | undefined;
155
+ try {
156
+ const { getFileCheckpoints } = require('../core/file_checkpoint');
157
+ const cp = getFileCheckpoints();
158
+ snapshot = (abs: string) => { try { cp.snapshot(abs); } catch { /* best-effort */ } };
159
+ } catch { /* checkpointing optional */ }
160
+ try {
161
+ return applyPatch(String(params.patch || ''), {
162
+ fenceCheck: (abs: string) => fenceCheck(abs),
163
+ snapshot,
164
+ });
165
+ } catch (e) {
166
+ return `Error applying patch: ${e}`;
167
+ }
168
+ },
169
+ });
170
+
106
171
  registry.register({
107
172
  name: 'delete_file',
108
173
  description: 'Delete a file at the given path.',
@@ -172,13 +237,23 @@ export function registerBuiltinTools(registry: ToolRegistry): void {
172
237
 
173
238
  registry.register({
174
239
  name: 'run_bash',
175
- description: 'Execute a shell command and return its output.',
240
+ description: 'Execute a shell command and return its output. Set background=true for long-running processes (dev servers, watchers, builds): it returns a job id immediately — read its output later with bash_output, stop it with kill_bash.',
176
241
  parameters: [
177
242
  { name: 'command', type: 'string', description: 'Command to execute', required: true },
178
- { name: 'timeout', type: 'number', description: 'Timeout in milliseconds (default: 30000)', required: false },
243
+ { name: 'timeout', type: 'number', description: 'Timeout in milliseconds (default: 30000). Ignored when background=true.', required: false },
244
+ { name: 'background', type: 'boolean', description: 'Run detached in the background and return a job id instead of blocking (default false)', required: false },
179
245
  ],
180
246
  handler: async (params) => {
181
247
  const cmd = params.command as string;
248
+ const background = params.background === true || params.background === 'true';
249
+ if (background) {
250
+ try {
251
+ const { getBackgroundManager } = require('../core/bgproc');
252
+ const { id, error } = getBackgroundManager().start(cmd);
253
+ if (error) return error;
254
+ return `[background job ${id} started] pid running. Use bash_output("${id}") to read output, kill_bash("${id}") to stop, list_bash to see all jobs.`;
255
+ } catch (e: any) { return `Error: ${e.message || e}`; }
256
+ }
182
257
  const timeout = (params.timeout as number) || 30000;
183
258
  try {
184
259
  const { runInSandbox, formatSandboxResult } = require('../core/sandbox');
@@ -189,6 +264,51 @@ export function registerBuiltinTools(registry: ToolRegistry): void {
189
264
  dangerous: true,
190
265
  });
191
266
 
267
+ registry.register({
268
+ name: 'bash_output',
269
+ description: 'Read new output from a background shell job (started by run_bash with background=true) since the last read, plus its current status.',
270
+ parameters: [
271
+ { name: 'id', type: 'string', description: 'The background job id', required: true },
272
+ ],
273
+ handler: async (params) => {
274
+ const { getBackgroundManager } = require('../core/bgproc');
275
+ const r = getBackgroundManager().read(String(params.id || ''));
276
+ if (!r.ok) return r.error || 'Error reading background job.';
277
+ const statusLine = `[job ${params.id} · ${r.status}${r.exitCode != null ? ` · exit ${r.exitCode}` : ''}]`;
278
+ const out = r.text && r.text.length ? r.text : '(no new output)';
279
+ return `${statusLine}\n${out}`;
280
+ },
281
+ });
282
+
283
+ registry.register({
284
+ name: 'list_bash',
285
+ description: 'List background shell jobs with their status, pid, and runtime.',
286
+ parameters: [],
287
+ handler: async () => {
288
+ const { getBackgroundManager } = require('../core/bgproc');
289
+ const jobs = getBackgroundManager().list();
290
+ if (!jobs.length) return 'No background jobs.';
291
+ return jobs.map((j: any) => {
292
+ const dur = ((j.endedAt || Date.now()) - j.startedAt) / 1000;
293
+ const ex = j.exitCode != null ? ` exit ${j.exitCode}` : '';
294
+ return `${j.id} · ${j.status}${ex} · pid ${j.pid ?? '?'} · ${dur.toFixed(1)}s · ${j.command.slice(0, 60)}`;
295
+ }).join('\n');
296
+ },
297
+ });
298
+
299
+ registry.register({
300
+ name: 'kill_bash',
301
+ description: 'Terminate a running background shell job.',
302
+ parameters: [
303
+ { name: 'id', type: 'string', description: 'The background job id to kill', required: true },
304
+ ],
305
+ handler: async (params) => {
306
+ const { getBackgroundManager } = require('../core/bgproc');
307
+ const r = getBackgroundManager().kill(String(params.id || ''));
308
+ return r.ok ? `[job ${params.id} killed]` : (r.error || 'Error killing background job.');
309
+ },
310
+ });
311
+
192
312
  // ── HTTP Tools ──
193
313
 
194
314
  registry.register({
@@ -376,10 +496,39 @@ export function registerBuiltinTools(registry: ToolRegistry): void {
376
496
 
377
497
  // ── Utility Tools ──
378
498
 
499
+ registry.register({
500
+ name: 'code_search',
501
+ idempotent: true,
502
+ description: 'Search source code for a regex pattern across files. Returns file:line matches with optional surrounding context. Use this to find where a symbol/string is defined or used. Restrict scope with glob (e.g. "**/*.ts") and add context lines to read around hits.',
503
+ parameters: [
504
+ { name: 'pattern', type: 'string', description: 'Regex (or literal if regex=false) to search for', required: true },
505
+ { name: 'path', type: 'string', description: 'Root directory to search (default: cwd)', required: false },
506
+ { name: 'glob', type: 'string', description: 'Restrict to files matching this glob, e.g. "**/*.ts"', required: false },
507
+ { name: 'context', type: 'number', description: 'Lines of context around each match (default 0)', required: false },
508
+ { name: 'ignore_case', type: 'boolean', description: 'Case-insensitive match (default false)', required: false },
509
+ { name: 'regex', type: 'boolean', description: 'Treat pattern as regex (default true; false = literal substring)', required: false },
510
+ { name: 'max_results', type: 'number', description: 'Max matches to return (default 200)', required: false },
511
+ ],
512
+ handler: async (params) => {
513
+ const root = params.path ? path.resolve(params.path as string) : process.cwd();
514
+ const fenced = fenceCheck(root); if (fenced) return fenced;
515
+ const res = searchCode({
516
+ pattern: String(params.pattern || ''),
517
+ root,
518
+ glob: params.glob ? String(params.glob) : undefined,
519
+ context: params.context != null ? Number(params.context) : 0,
520
+ ignoreCase: params.ignore_case === true,
521
+ regex: params.regex !== false,
522
+ maxResults: params.max_results != null ? Number(params.max_results) : 200,
523
+ });
524
+ return formatSearchResult(res);
525
+ },
526
+ });
527
+
379
528
  registry.register({
380
529
  name: 'grep',
381
530
  idempotent: true,
382
- description: 'Search for a pattern in files using ripgrep or grep.',
531
+ description: 'Search for a regex pattern in files using ripgrep/grep, with a built-in fallback when neither is installed. For richer control (glob, context, ignore-case) prefer code_search.',
383
532
  parameters: [
384
533
  { name: 'pattern', type: 'string', description: 'Regex pattern to search for', required: true },
385
534
  { name: 'path', type: 'string', description: 'Directory to search in', required: false },
@@ -402,12 +551,13 @@ export function registerBuiltinTools(registry: ToolRegistry): void {
402
551
  const out = execFileSync(bin, args, { encoding: 'utf-8', maxBuffer: 1024 * 1024 });
403
552
  return out || 'No matches found.';
404
553
  } catch (e: any) {
405
- // exit status 1 = ran successfully, zero matches; anything else
406
- // (e.g. binary not installed) falls through to the next variant.
554
+ // exit status 1 = ran successfully, zero matches. Any other failure
555
+ // (e.g. binary not installed) falls through to the next variant, then
556
+ // to the pure-JS engine so search works even with no rg/grep.
407
557
  if (e?.status === 1) return 'No matches found.';
408
558
  }
409
559
  }
410
- return 'No matches found.';
560
+ return formatSearchResult(searchCode({ pattern: pat, root: searchDir, maxResults: 200 }));
411
561
  },
412
562
  });
413
563