skyloom 1.16.2 → 1.17.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 (91) 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 +13 -0
  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/sandbox.d.ts +1 -0
  35. package/dist/core/sandbox.d.ts.map +1 -1
  36. package/dist/core/sandbox.js +1 -0
  37. package/dist/core/sandbox.js.map +1 -1
  38. package/dist/core/security.d.ts +22 -2
  39. package/dist/core/security.d.ts.map +1 -1
  40. package/dist/core/security.js +54 -24
  41. package/dist/core/security.js.map +1 -1
  42. package/dist/core/skill.d.ts +4 -0
  43. package/dist/core/skill.d.ts.map +1 -1
  44. package/dist/core/skill.js +1 -0
  45. package/dist/core/skill.js.map +1 -1
  46. package/dist/core/subagent.d.ts +75 -0
  47. package/dist/core/subagent.d.ts.map +1 -0
  48. package/dist/core/subagent.js +287 -0
  49. package/dist/core/subagent.js.map +1 -0
  50. package/dist/core/tool.d.ts +15 -1
  51. package/dist/core/tool.d.ts.map +1 -1
  52. package/dist/core/tool.js +88 -30
  53. package/dist/core/tool.js.map +1 -1
  54. package/dist/plugins/loader.d.ts +49 -8
  55. package/dist/plugins/loader.d.ts.map +1 -1
  56. package/dist/plugins/loader.js +129 -16
  57. package/dist/plugins/loader.js.map +1 -1
  58. package/dist/tools/builtin.d.ts.map +1 -1
  59. package/dist/tools/builtin.js +118 -13
  60. package/dist/tools/builtin.js.map +1 -1
  61. package/dist/tools/spawn.d.ts +23 -0
  62. package/dist/tools/spawn.d.ts.map +1 -0
  63. package/dist/tools/spawn.js +77 -0
  64. package/dist/tools/spawn.js.map +1 -0
  65. package/docs/OPTIMIZATION_PLAN.md +21 -4
  66. package/package.json +1 -1
  67. package/src/cli/loom_chat.ts +11 -0
  68. package/src/cli/main.ts +31 -1
  69. package/src/core/agent.ts +13 -0
  70. package/src/core/bgproc.ts +153 -0
  71. package/src/core/commands.ts +20 -0
  72. package/src/core/diagnostics.ts +178 -0
  73. package/src/core/diff.ts +98 -0
  74. package/src/core/envcontext.ts +79 -0
  75. package/src/core/factory.ts +31 -2
  76. package/src/core/sandbox.ts +1 -1
  77. package/src/core/security.ts +62 -21
  78. package/src/core/skill.ts +1 -1
  79. package/src/core/subagent.ts +272 -0
  80. package/src/core/tool.ts +86 -31
  81. package/src/plugins/loader.ts +145 -18
  82. package/src/tools/builtin.ts +107 -13
  83. package/src/tools/spawn.ts +92 -0
  84. package/tests/bgproc.test.ts +65 -0
  85. package/tests/diagnostics.test.ts +86 -0
  86. package/tests/edit_diff.test.ts +102 -0
  87. package/tests/envcontext.test.ts +67 -0
  88. package/tests/plugins.test.ts +84 -0
  89. package/tests/security.test.ts +87 -0
  90. package/tests/subagent.test.ts +211 -0
  91. package/tests/tool.test.ts +76 -0
package/src/core/tool.ts CHANGED
@@ -144,15 +144,22 @@ function coerceValue(value: unknown, targetType: string): [boolean, unknown] {
144
144
  return [true, value];
145
145
  }
146
146
 
147
+ // string target accepts scalars (number/boolean) by stringifying them —
148
+ // the model sometimes sends 5 where a string id is expected.
149
+ if (targetType === "string" && (typeof value === "number" || typeof value === "boolean")) {
150
+ return [true, String(value)];
151
+ }
152
+
147
153
  // Lenient coercion from string
148
154
  if (typeof value === "string") {
149
155
  const stripped = value.trim();
150
156
 
151
157
  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];
158
+ // Number() handles ints and floats uniformly; parseInt truncated floats
159
+ // ("3.5" -> 3), silently corrupting numeric args.
160
+ if (stripped === "") return [false, value];
161
+ const num = Number(stripped);
162
+ if (!isNaN(num)) return [true, targetType === "integer" ? Math.trunc(num) : num];
156
163
  return [false, value];
157
164
  }
158
165
 
@@ -164,16 +171,35 @@ function coerceValue(value: unknown, targetType: string): [boolean, unknown] {
164
171
  }
165
172
 
166
173
  if (targetType === "array") {
174
+ // The model often sends a JSON-encoded array string for array params.
175
+ if (stripped.startsWith("[")) {
176
+ try { const parsed = JSON.parse(stripped); if (Array.isArray(parsed)) return [true, parsed]; } catch { /* fall through */ }
177
+ }
167
178
  if (stripped.includes(",")) {
168
179
  return [true, stripped.split(",").map((s) => s.trim())];
169
180
  }
170
181
  return [true, [value]];
171
182
  }
183
+
184
+ if (targetType === "object") {
185
+ try {
186
+ const parsed = JSON.parse(stripped);
187
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return [true, parsed];
188
+ } catch { /* not JSON */ }
189
+ return [false, value];
190
+ }
172
191
  }
173
192
 
174
193
  return [false, value];
175
194
  }
176
195
 
196
+ /** Describe a value's runtime type for an error message. */
197
+ function describeType(v: unknown): string {
198
+ if (v === null) return "null";
199
+ if (Array.isArray(v)) return "array";
200
+ return typeof v;
201
+ }
202
+
177
203
  /**
178
204
  * Tool registry and executor
179
205
  */
@@ -265,32 +291,59 @@ export class ToolRegistry extends EventEmitter {
265
291
  }
266
292
 
267
293
  /**
268
- * Validate tool parameters
294
+ * Validate tool inputs against the declared schema AND return coerced args:
295
+ * required-field presence, per-param type coercion (string<->number/bool,
296
+ * JSON-string -> array/object), and enum membership. Returning the coerced
297
+ * params means the handler receives clean, typed values (the model often
298
+ * sends "5" / "true" / a JSON string), and rejecting malformed input before
299
+ * execution gives the model a precise, actionable error to retry against.
269
300
  */
270
- validateParameters(toolName: string, params: Record<string, unknown>): [boolean, string] {
301
+ validateAndCoerce(
302
+ toolName: string,
303
+ params: Record<string, unknown>,
304
+ ): { ok: boolean; error?: string; params?: Record<string, unknown> } {
271
305
  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
- }
306
+ if (!tool) return { ok: false, error: `Tool ${toolName} not found` };
307
+ if (!tool.parameters || tool.parameters.length === 0) return { ok: true, params };
279
308
 
309
+ const out: Record<string, unknown> = { ...params };
280
310
  for (const param of tool.parameters) {
281
- if (param.required && !(param.name in params)) {
282
- return [false, `Missing required parameter: ${param.name}`];
311
+ const has = param.name in params && params[param.name] !== null && params[param.name] !== undefined;
312
+ if (!has) {
313
+ if (param.required) {
314
+ return { ok: false, error: `Missing required parameter '${param.name}' (expected ${param.type}).` };
315
+ }
316
+ continue;
283
317
  }
284
318
 
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
- }
319
+ const [valid, coerced] = coerceValue(params[param.name], param.type);
320
+ if (!valid) {
321
+ return {
322
+ ok: false,
323
+ error: `Invalid type for parameter '${param.name}': expected ${param.type}, got ${describeType(params[param.name])}.`,
324
+ };
325
+ }
326
+
327
+ if (param.enum && param.enum.length > 0 && !param.enum.includes(String(coerced))) {
328
+ return {
329
+ ok: false,
330
+ error: `Invalid value for parameter '${param.name}': '${String(coerced)}' is not allowed. Valid values: ${param.enum.join(", ")}.`,
331
+ };
290
332
  }
333
+
334
+ out[param.name] = coerced;
291
335
  }
292
336
 
293
- return [true, ""];
337
+ return { ok: true, params: out };
338
+ }
339
+
340
+ /**
341
+ * Validate tool parameters (legacy boolean/message form; delegates to
342
+ * validateAndCoerce).
343
+ */
344
+ validateParameters(toolName: string, params: Record<string, unknown>): [boolean, string] {
345
+ const r = this.validateAndCoerce(toolName, params);
346
+ return [r.ok, r.error || ""];
294
347
  }
295
348
 
296
349
  /**
@@ -316,6 +369,18 @@ export class ToolRegistry extends EventEmitter {
316
369
  };
317
370
  }
318
371
 
372
+ // Validate + coerce inputs against the schema BEFORE cache/execute, so the
373
+ // handler and the cache key both use clean, typed values.
374
+ const validated = this.validateAndCoerce(toolName, params);
375
+ if (!validated.ok) {
376
+ return {
377
+ success: false,
378
+ result: "",
379
+ error: validated.error,
380
+ };
381
+ }
382
+ params = validated.params!;
383
+
319
384
  // Check cache
320
385
  if (tool.cacheable) {
321
386
  const cacheKey = stableStringify(params);
@@ -330,16 +395,6 @@ export class ToolRegistry extends EventEmitter {
330
395
  }
331
396
  }
332
397
 
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
398
  // Execute with retries
344
399
  const maxRetries = tool.maxRetries ?? DEFAULT_RETRIES;
345
400
  const retryDelay = (tool.retryDelay ?? DEFAULT_RETRY_DELAY) * 1000;
@@ -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,8 @@ 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';
13
15
 
14
16
  // Re-exported so existing importers/tests keep resolving these from builtin.
15
17
  export { isPrivateIp, assertFetchAllowed, fenceRoot, fenceCheck };
@@ -77,32 +79,69 @@ export function registerBuiltinTools(registry: ToolRegistry): void {
77
79
 
78
80
  registry.register({
79
81
  name: 'edit_file',
80
- description: 'Edit a file by replacing old_text with new_text. Use this for targeted edits.',
82
+ 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
83
  parameters: [
82
84
  { 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 },
85
+ { 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 },
86
+ { name: 'new_text', type: 'string', description: 'Replacement text (must differ from old_text)', required: true },
87
+ { name: 'replace_all', type: 'boolean', description: 'Replace every occurrence instead of requiring a unique match (default false)', required: false },
85
88
  ],
86
89
  handler: async (params) => {
87
90
  const filePath = path.resolve(params.path as string);
88
91
  const fenced = fenceCheck(filePath); if (fenced) return fenced;
89
92
  if (!fs.existsSync(filePath)) return `Error: File not found: ${filePath}`;
93
+ const oldText = params.old_text as string;
94
+ const newText = params.new_text as string;
95
+ const replaceAll = params.replace_all === true || params.replace_all === 'true';
96
+ if (oldText === newText) return 'Error: old_text and new_text are identical — nothing to change.';
90
97
  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)}...`;
98
+ const orig = fs.readFileSync(filePath, 'utf-8');
99
+ const n = countOccurrences(orig, oldText);
100
+ if (n === 0) {
101
+ return `Error: old_text not found in ${filePath}. It must match exactly (including whitespace). Searched for: ${JSON.stringify(oldText.slice(0, 80))}`;
96
102
  }
97
- content = content.replace(oldText, newText);
98
- fs.writeFileSync(filePath, content, 'utf-8');
99
- return `Successfully edited ${filePath}`;
103
+ if (n > 1 && !replaceAll) {
104
+ 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.`;
105
+ }
106
+ // Literal replacement: split/join (replace_all) and a function replacer
107
+ // (single) both avoid String.replace interpreting `$&`/`$1` in new_text.
108
+ const updated = replaceAll
109
+ ? orig.split(oldText).join(newText)
110
+ : orig.replace(oldText, () => newText);
111
+ fs.writeFileSync(filePath, updated, 'utf-8');
112
+ const rel = path.relative(process.cwd(), filePath) || filePath;
113
+ const diff = unifiedDiff(orig, updated, { path: rel, context: 3 });
114
+ const occ = replaceAll ? ` (${n} occurrence${n > 1 ? 's' : ''})` : '';
115
+ const body = diff.text ? `\n${diff.text}` : '';
116
+ return `Successfully edited ${filePath}${occ} · +${diff.stat.added} -${diff.stat.removed}${body}`;
100
117
  } catch (e) {
101
118
  return `Error editing file: ${e}`;
102
119
  }
103
120
  },
104
121
  });
105
122
 
123
+ registry.register({
124
+ name: 'get_diagnostics',
125
+ idempotent: true,
126
+ 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.',
127
+ parameters: [
128
+ { name: 'path', type: 'string', description: 'Path to the source file to check', required: true },
129
+ ],
130
+ handler: async (params) => {
131
+ const filePath = path.resolve(params.path as string);
132
+ const fenced = fenceCheck(filePath); if (fenced) return fenced;
133
+ let config: any = {};
134
+ try { const { loadConfig } = require('../core/config'); config = loadConfig(); } catch { /* defaults */ }
135
+ try {
136
+ const result = getDiagnostics(filePath, config);
137
+ if (!Array.isArray(result)) return `[diagnostics unavailable] ${result.unavailable}`;
138
+ return formatDiagnostics(path.relative(process.cwd(), filePath) || filePath, result);
139
+ } catch (e) {
140
+ return `Error getting diagnostics: ${e}`;
141
+ }
142
+ },
143
+ });
144
+
106
145
  registry.register({
107
146
  name: 'delete_file',
108
147
  description: 'Delete a file at the given path.',
@@ -172,13 +211,23 @@ export function registerBuiltinTools(registry: ToolRegistry): void {
172
211
 
173
212
  registry.register({
174
213
  name: 'run_bash',
175
- description: 'Execute a shell command and return its output.',
214
+ 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
215
  parameters: [
177
216
  { name: 'command', type: 'string', description: 'Command to execute', required: true },
178
- { name: 'timeout', type: 'number', description: 'Timeout in milliseconds (default: 30000)', required: false },
217
+ { name: 'timeout', type: 'number', description: 'Timeout in milliseconds (default: 30000). Ignored when background=true.', required: false },
218
+ { name: 'background', type: 'boolean', description: 'Run detached in the background and return a job id instead of blocking (default false)', required: false },
179
219
  ],
180
220
  handler: async (params) => {
181
221
  const cmd = params.command as string;
222
+ const background = params.background === true || params.background === 'true';
223
+ if (background) {
224
+ try {
225
+ const { getBackgroundManager } = require('../core/bgproc');
226
+ const { id, error } = getBackgroundManager().start(cmd);
227
+ if (error) return error;
228
+ 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.`;
229
+ } catch (e: any) { return `Error: ${e.message || e}`; }
230
+ }
182
231
  const timeout = (params.timeout as number) || 30000;
183
232
  try {
184
233
  const { runInSandbox, formatSandboxResult } = require('../core/sandbox');
@@ -189,6 +238,51 @@ export function registerBuiltinTools(registry: ToolRegistry): void {
189
238
  dangerous: true,
190
239
  });
191
240
 
241
+ registry.register({
242
+ name: 'bash_output',
243
+ description: 'Read new output from a background shell job (started by run_bash with background=true) since the last read, plus its current status.',
244
+ parameters: [
245
+ { name: 'id', type: 'string', description: 'The background job id', required: true },
246
+ ],
247
+ handler: async (params) => {
248
+ const { getBackgroundManager } = require('../core/bgproc');
249
+ const r = getBackgroundManager().read(String(params.id || ''));
250
+ if (!r.ok) return r.error || 'Error reading background job.';
251
+ const statusLine = `[job ${params.id} · ${r.status}${r.exitCode != null ? ` · exit ${r.exitCode}` : ''}]`;
252
+ const out = r.text && r.text.length ? r.text : '(no new output)';
253
+ return `${statusLine}\n${out}`;
254
+ },
255
+ });
256
+
257
+ registry.register({
258
+ name: 'list_bash',
259
+ description: 'List background shell jobs with their status, pid, and runtime.',
260
+ parameters: [],
261
+ handler: async () => {
262
+ const { getBackgroundManager } = require('../core/bgproc');
263
+ const jobs = getBackgroundManager().list();
264
+ if (!jobs.length) return 'No background jobs.';
265
+ return jobs.map((j: any) => {
266
+ const dur = ((j.endedAt || Date.now()) - j.startedAt) / 1000;
267
+ const ex = j.exitCode != null ? ` exit ${j.exitCode}` : '';
268
+ return `${j.id} · ${j.status}${ex} · pid ${j.pid ?? '?'} · ${dur.toFixed(1)}s · ${j.command.slice(0, 60)}`;
269
+ }).join('\n');
270
+ },
271
+ });
272
+
273
+ registry.register({
274
+ name: 'kill_bash',
275
+ description: 'Terminate a running background shell job.',
276
+ parameters: [
277
+ { name: 'id', type: 'string', description: 'The background job id to kill', required: true },
278
+ ],
279
+ handler: async (params) => {
280
+ const { getBackgroundManager } = require('../core/bgproc');
281
+ const r = getBackgroundManager().kill(String(params.id || ''));
282
+ return r.ok ? `[job ${params.id} killed]` : (r.error || 'Error killing background job.');
283
+ },
284
+ });
285
+
192
286
  // ── HTTP Tools ──
193
287
 
194
288
  registry.register({
@@ -0,0 +1,92 @@
1
+ /**
2
+ * spawn_agent tool — launch a general-purpose, isolated-context subagent to
3
+ * handle one focused, self-contained task and return only its final report.
4
+ *
5
+ * This is sky's analogue of Claude Code's Task tool. Subagent types are
6
+ * discovered from built-ins plus `.claude/agents` / `.sky/agents` definition
7
+ * files (see core/subagent). Subagents never receive spawn_agent themselves,
8
+ * so there is no recursive fan-out.
9
+ */
10
+
11
+ import type { ToolDefinition } from '../core/tool';
12
+ import type { ToolRegistry } from '../core/tool';
13
+ import type { SkillRegistry } from '../core/skill';
14
+ import type { LLMClient } from '../core/llm';
15
+ import type { MessageBus } from '../core/bus';
16
+ import { loadSubagentDefinitions, runSubagent } from '../core/subagent';
17
+
18
+ export function createSpawnAgentTool(opts: {
19
+ config: any;
20
+ llm: LLMClient;
21
+ bus: MessageBus;
22
+ baseToolRegistry: ToolRegistry;
23
+ baseSkillRegistry: SkillRegistry;
24
+ cwd?: string;
25
+ }): ToolDefinition {
26
+ const cwd = opts.cwd || process.cwd();
27
+
28
+ const buildDescription = (): string => {
29
+ const defs = loadSubagentDefinitions(cwd);
30
+ const lines = [...defs.values()].map((d) => ` - ${d.name}: ${d.description}`);
31
+ return (
32
+ '派生一个隔离上下文的子智能体来独立完成一个聚焦、自洽的任务,只返回它的最终报告。' +
33
+ '当任务需要大量搜索/调研、或你想把一段独立工作从主上下文里隔离出去时使用。' +
34
+ '子智能体看不到你的对话历史,所以 task 必须自带全部所需上下文(目标、相关文件、约束)。' +
35
+ '它无法反问你,会一次性完成并汇报。可并行派生多个互不依赖的子智能体。\n\n可用子智能体类型:\n' +
36
+ lines.join('\n')
37
+ );
38
+ };
39
+
40
+ return {
41
+ name: 'spawn_agent',
42
+ description: buildDescription(),
43
+ parameters: [
44
+ {
45
+ name: 'agent_type',
46
+ type: 'string',
47
+ description: '子智能体类型(见工具描述中的可用类型,如 general-purpose / explore 或自定义)',
48
+ required: true,
49
+ },
50
+ {
51
+ name: 'task',
52
+ type: 'string',
53
+ description: '完整、自洽的任务描述。子智能体看不到对话历史,必须在此写清目标、相关文件路径与约束。',
54
+ required: true,
55
+ },
56
+ {
57
+ name: 'description',
58
+ type: 'string',
59
+ description: '可选:对该任务的简短(3-5 词)标签,用于展示。',
60
+ required: false,
61
+ },
62
+ ],
63
+ // Long-running by nature (full nested agent loop); give it generous headroom.
64
+ timeout: 600000,
65
+ handler: async (params) => {
66
+ const agentType = String(params.agent_type || '').trim();
67
+ const task = String(params.task || '').trim();
68
+ if (!agentType) return '[spawn_agent error] agent_type is required.';
69
+ if (!task) return '[spawn_agent error] task is required.';
70
+
71
+ const defs = loadSubagentDefinitions(cwd);
72
+ const def = defs.get(agentType);
73
+ if (!def) {
74
+ const available = [...defs.keys()].join(', ');
75
+ return `[spawn_agent error] unknown agent_type '${agentType}'. Available: ${available}`;
76
+ }
77
+
78
+ const report = await runSubagent({
79
+ def,
80
+ task,
81
+ config: opts.config,
82
+ llm: opts.llm,
83
+ bus: opts.bus,
84
+ baseToolRegistry: opts.baseToolRegistry,
85
+ baseSkillRegistry: opts.baseSkillRegistry,
86
+ });
87
+
88
+ const header = `[subagent ${def.name} 完成]`;
89
+ return `${header}\n${report}`;
90
+ },
91
+ };
92
+ }