loom-agent 1.2.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 (79) hide show
  1. package/.env.example +25 -0
  2. package/CHANGELOG.md +402 -0
  3. package/LICENSE +21 -0
  4. package/LOOM.md +235 -0
  5. package/README.md +433 -0
  6. package/bin/loom-tui.js +43 -0
  7. package/bin/loom.js +44 -0
  8. package/docs/acp.md +151 -0
  9. package/docs/web.md +205 -0
  10. package/package.json +97 -0
  11. package/scripts/acp-smoke.js +146 -0
  12. package/src/acp/acp-server.js +287 -0
  13. package/src/config/provider-cmd.js +37 -0
  14. package/src/config/settings.js +164 -0
  15. package/src/core/agents.js +361 -0
  16. package/src/core/background-tasks.js +103 -0
  17. package/src/core/cli.js +579 -0
  18. package/src/core/custom-commands.js +70 -0
  19. package/src/core/errors.js +29 -0
  20. package/src/core/events.js +24 -0
  21. package/src/core/file-diffs.js +282 -0
  22. package/src/core/format.js +206 -0
  23. package/src/core/graph.js +257 -0
  24. package/src/core/hooks.js +82 -0
  25. package/src/core/lsp.js +385 -0
  26. package/src/core/memory.js +87 -0
  27. package/src/core/model-router.js +87 -0
  28. package/src/core/permissions.js +327 -0
  29. package/src/core/platform.js +33 -0
  30. package/src/core/plugin-cmd.js +380 -0
  31. package/src/core/restore.js +207 -0
  32. package/src/core/session-store.js +167 -0
  33. package/src/core/session.js +910 -0
  34. package/src/core/subagent-log.js +134 -0
  35. package/src/core/tokens.js +31 -0
  36. package/src/core/update.js +6 -0
  37. package/src/core/usage.js +166 -0
  38. package/src/index.js +41 -0
  39. package/src/mcp/mcp-client.js +201 -0
  40. package/src/mcp/mcp-manager.js +193 -0
  41. package/src/providers/anthropic.js +243 -0
  42. package/src/providers/google.js +29 -0
  43. package/src/providers/index.js +175 -0
  44. package/src/providers/local.js +27 -0
  45. package/src/providers/nvidia.js +85 -0
  46. package/src/providers/openai-compat.js +269 -0
  47. package/src/providers/openai.js +35 -0
  48. package/src/providers/openrouter.js +43 -0
  49. package/src/providers/registry.js +196 -0
  50. package/src/providers/tokenrouter.js +19 -0
  51. package/src/skills/skill-matcher.js +133 -0
  52. package/src/skills/skills-manager.js +213 -0
  53. package/src/tools/index.js +543 -0
  54. package/src/tui/App.tsx +1578 -0
  55. package/src/tui/components/BreadcrumbBar.tsx +34 -0
  56. package/src/tui/components/ChatArea.tsx +518 -0
  57. package/src/tui/components/InputBar.tsx +354 -0
  58. package/src/tui/components/MdText.tsx +105 -0
  59. package/src/tui/components/Modals.tsx +851 -0
  60. package/src/tui/components/PermissionPopup.tsx +264 -0
  61. package/src/tui/components/Sidebar.tsx +182 -0
  62. package/src/tui/components/SplashScreen.tsx +51 -0
  63. package/src/tui/components/SubagentPanel.tsx +217 -0
  64. package/src/tui/components/ToastOverlay.tsx +34 -0
  65. package/src/tui/keybinds.ts +318 -0
  66. package/src/tui/mcp-presets.ts +189 -0
  67. package/src/tui/md-render.ts +228 -0
  68. package/src/tui/store.ts +714 -0
  69. package/src/tui/suite-home.ts +20 -0
  70. package/src/tui/theme.ts +313 -0
  71. package/src/tui/themes.generated.ts +968 -0
  72. package/src/tui/tool-display.ts +176 -0
  73. package/src/tui/toolname.ts +60 -0
  74. package/src/tui/tui-config.ts +28 -0
  75. package/src/tui-open.tsx +51 -0
  76. package/src/web/attach.js +242 -0
  77. package/src/web/graph-view.html +262 -0
  78. package/src/web/index.html +824 -0
  79. package/src/web/web-server.js +470 -0
@@ -0,0 +1,543 @@
1
+ const fs = require('fs').promises;
2
+ const fsSync = require('fs');
3
+ const path = require('path');
4
+ const { execSync, spawn } = require('child_process');
5
+ const { glob: globLib } = require('glob');
6
+ const { commandRiskLabel } = require('../core/permissions');
7
+ const { loadConfig } = require('../config/settings');
8
+
9
+ const cwd = process.cwd();
10
+
11
+ function globIgnore(full) {
12
+ const abs = full.startsWith('/') || /^[a-zA-Z]:/.test(full);
13
+ if (!abs) return ['**/node_modules/**', '**/.git/**'];
14
+ const base = path.posix.dirname(full);
15
+ return ['node_modules', '.git'].map((n) => path.posix.join(base, '**', n, '**'));
16
+ }
17
+
18
+ const MODES = ['build', 'plan', 'chat'];
19
+
20
+ // Tools that never mutate the filesystem/state — safe to expose in plan mode.
21
+ // task delegates to a read-only-or-not subagent, so from the CALLER's side it
22
+ // is safe in plan mode (the subagent's own tool list gates what it may do).
23
+ const READ_ONLY_TOOLS = ['read', 'glob', 'grep', 'webfetch', 'websearch', 'todowrite', 'task'];
24
+
25
+ // Optional path sandbox: when config.sandbox.paths is set, filesystem tools
26
+ // only operate inside those roots (defense in depth on top of permissions).
27
+ function sandboxRoots() {
28
+ const cfg = loadConfig();
29
+ const paths = cfg.sandbox && Array.isArray(cfg.sandbox.paths) ? cfg.sandbox.paths : null;
30
+ return paths && paths.length ? paths : null;
31
+ }
32
+
33
+ function pathAllowed(absPath) {
34
+ const roots = sandboxRoots();
35
+ if (!roots) return true;
36
+ const abs = path.resolve(absPath);
37
+ return roots.some((r) => {
38
+ const root = path.resolve(r);
39
+ return abs === root || abs.startsWith(root + path.sep);
40
+ });
41
+ }
42
+
43
+ function sandboxDenied(toolName, absPath) {
44
+ return `Blocked by sandbox: ${toolName} on ${absPath} is outside config sandbox.paths (${sandboxRoots().join(', ')})`;
45
+ }
46
+
47
+ const TOOLS = {
48
+ read: {
49
+ name: 'read',
50
+ description: 'Read a file from the local filesystem.',
51
+ parameters: {
52
+ filePath: { type: 'string', required: true, description: 'Absolute path to the file' },
53
+ offset: { type: 'number', required: false, description: 'Line number to start reading from' },
54
+ limit: { type: 'number', required: false, description: 'Max lines to read' },
55
+ },
56
+ async execute(params) {
57
+ const filePath = path.resolve(params.filePath);
58
+ if (!pathAllowed(filePath)) return { error: sandboxDenied('read', filePath) };
59
+ if (!fsSync.existsSync(filePath)) return { error: `File not found: ${filePath}` };
60
+ const content = await fs.readFile(filePath, 'utf8');
61
+ const lines = content.split('\n');
62
+ const start = (params.offset || 1) - 1;
63
+ const end = params.limit ? start + params.limit : lines.length;
64
+ const result = lines.slice(start, end).map((l,i) => `${start+i+1}: ${l}`).join('\n');
65
+ return result;
66
+ }
67
+ },
68
+ write: {
69
+ name: 'write',
70
+ description: 'Write a file to the local filesystem.',
71
+ parameters: {
72
+ filePath: { type: 'string', required: true, description: 'Absolute path to the file' },
73
+ content: { type: 'string', required: true, description: 'Content to write' },
74
+ },
75
+ async execute(params) {
76
+ const dest = path.resolve(params.filePath);
77
+ if (!pathAllowed(dest)) return { error: sandboxDenied('write', dest) };
78
+ const dir = path.dirname(dest);
79
+ if (!fsSync.existsSync(dir)) fsSync.mkdirSync(dir, { recursive: true });
80
+ await fs.writeFile(dest, params.content, 'utf8');
81
+ let extra = '';
82
+ try { extra = await require('../core/format').formatAfterWrite(dest); } catch {}
83
+ return `File written: ${dest}${extra}`;
84
+ }
85
+ },
86
+ edit: {
87
+ name: 'edit',
88
+ description: 'Performs exact string replacements in files.',
89
+ parameters: {
90
+ filePath: { type: 'string', required: true, description: 'Absolute path to the file' },
91
+ oldString: { type: 'string', required: true, description: 'Text to replace' },
92
+ newString: { type: 'string', required: true, description: 'Replacement text' },
93
+ replaceAll: { type: 'boolean', required: false, description: 'Replace all occurrences' },
94
+ dryRun: { type: 'boolean', required: false, description: 'Preview the edit without writing' },
95
+ },
96
+ async execute(params) {
97
+ const filePath = path.resolve(params.filePath);
98
+ if (!pathAllowed(filePath)) return { error: sandboxDenied('edit', filePath) };
99
+ if (!fsSync.existsSync(filePath)) return { error: `File not found: ${filePath}` };
100
+ let content = await fs.readFile(filePath, 'utf8');
101
+ const count = content.split(params.oldString).length - 1;
102
+ if (count === 0) return { error: 'oldString not found in content' };
103
+ if (params.replaceAll) {
104
+ content = content.split(params.oldString).join(params.newString);
105
+ } else {
106
+ content = content.replace(params.oldString, params.newString.replace(/\$/g, '$$$$'));
107
+ }
108
+ if (params.dryRun) {
109
+ const changed = params.replaceAll ? `replaced ${count} occurrences` : 'replaced 1 occurrence';
110
+ return `Dry run — would edit ${filePath}: ${changed} (file not modified).\n${content.slice(0, 4000)}`;
111
+ }
112
+ await fs.writeFile(filePath, content, 'utf8');
113
+ let extra = '';
114
+ try { extra = await require('../core/format').formatAfterWrite(filePath); } catch {}
115
+ return `Edited ${filePath}: ${params.replaceAll ? `replaced ${count} occurrences` : 'replaced 1 occurrence'}${extra}`;
116
+ }
117
+ },
118
+ bash: {
119
+ name: 'bash',
120
+ description: 'Execute a shell command.',
121
+ parameters: {
122
+ command: { type: 'string', required: true, description: 'The command to execute' },
123
+ workdir: { type: 'string', required: false, description: 'Working directory' },
124
+ background: { type: 'boolean', required: false, description: 'Run without waiting (dev servers, long builds). Returns a task id immediately; check with the /tasks panel.' },
125
+ },
126
+ async execute(params, ctx) {
127
+ // Background mode: fire-and-forget via the task registry.
128
+ if (params.background) {
129
+ const bt = require('../core/background-tasks');
130
+ if (!pathAllowed(params.workdir || cwd)) return { error: sandboxDenied('bash', params.workdir || cwd) };
131
+ const risk = params._approved ? null : commandRiskLabel(params.command);
132
+ if (risk) return { error: `Blocked by safety filter: ${risk}. Approve it via the permission prompt to run it anyway.` };
133
+ const res = /** @type {{id:string}|{error:string}} */ (bt.startBackgroundTask(params.command));
134
+ if ('error' in res) return { error: res.error };
135
+ return `Started background task ${res.id}. Keep working; check output with /tasks (or bash background:false re-run).`;
136
+ }
137
+ return new Promise((resolve) => {
138
+ const dir = params.workdir || cwd;
139
+ if (!pathAllowed(dir)) { resolve({ error: sandboxDenied('bash', dir) }); return; }
140
+ const risk = params._approved ? null : commandRiskLabel(params.command);
141
+ if (risk) {
142
+ resolve({ error: `Blocked by safety filter: ${risk}. Approve it via the permission prompt to run it anyway.` });
143
+ return;
144
+ }
145
+ try {
146
+ if (!params.command.trim()) { resolve('command completed (empty)'); return; }
147
+ const opts = { cwd: dir, windowsHide: true };
148
+ const isWin = process.platform === 'win32';
149
+ const child = isWin
150
+ ? spawn('powershell', ['-NoProfile', '-NonInteractive', '-Command', '[Console]::OutputEncoding=[Text.Encoding]::UTF8;' + params.command], opts)
151
+ : spawn('/bin/sh', ['-c', params.command], opts);
152
+ const killTimer = setTimeout(() => {
153
+ if (isWin) {
154
+ try { execSync('taskkill /PID ' + child.pid + ' /T /F', { stdio: 'ignore', windowsHide: true }); } catch {}
155
+ }
156
+ child.kill('SIGKILL');
157
+ }, 60000);
158
+ child.on('close', () => clearTimeout(killTimer));
159
+ let stdout = '';
160
+ let stderr = '';
161
+ let truncated = false;
162
+ const MAX_OUT = 10 * 1024 * 1024;
163
+ // Live terminal output: every chunk is relayed to the UI (the TUI
164
+ // streams it in a collapsible block) while the command runs, and the
165
+ // final accumulated output still lands in the tool result. The
166
+ // accumulated copy is capped at 10 MB; the live stream keeps going.
167
+ const stream = ctx && typeof ctx.stream === 'function' ? ctx.stream : null;
168
+ child.stdout.on('data', d => {
169
+ const t = d.toString();
170
+ if (!truncated) {
171
+ if (stdout.length + t.length > MAX_OUT) { stdout = stdout.slice(0, MAX_OUT); truncated = true; }
172
+ else stdout += t;
173
+ }
174
+ if (stream) { try { stream(t, 'out'); } catch {} }
175
+ });
176
+ child.stderr.on('data', d => {
177
+ const t = d.toString();
178
+ if (!truncated) {
179
+ if (stderr.length + t.length > MAX_OUT) { stderr = stderr.slice(0, MAX_OUT); truncated = true; }
180
+ else stderr += t;
181
+ }
182
+ if (stream) { try { stream(t, 'err'); } catch {} }
183
+ });
184
+ child.on('close', (code) => {
185
+ let result = '';
186
+ if (stdout) result += stdout;
187
+ if (stderr) result += `\n[stderr]\n${stderr}`;
188
+ if (code !== 0) result += `\n[exit code: ${code}]`;
189
+ if (truncated) result += '\n[output truncated at 10 MB]';
190
+ resolve(result || 'command completed');
191
+ });
192
+ child.on('error', (err) => resolve({ error: `Error executing command: ${err.message}` }));
193
+ } catch (e) {
194
+ resolve({ error: `Error executing command: ${e.message}` });
195
+ }
196
+ });
197
+ }
198
+ },
199
+ grep: {
200
+ name: 'grep',
201
+ description: 'Search file contents using regular expressions.',
202
+ parameters: {
203
+ pattern: { type: 'string', required: true, description: 'Regex pattern to search for' },
204
+ path: { type: 'string', required: false, description: 'Directory to search in' },
205
+ include: { type: 'string', required: false, description: 'File pattern to include' },
206
+ },
207
+ async execute(params) {
208
+ const dir = (params.path || cwd).replace(/\\/g, '/');
209
+ const include = (params.include || '**/*').replace(/\\/g, '/');
210
+ const regex = new RegExp(params.pattern);
211
+ const full = include.startsWith('/') || /^[a-zA-Z]:/.test(include)
212
+ ? include
213
+ : path.posix.join(dir, include);
214
+ if (!pathAllowed(dir) || !pathAllowed(full)) return { error: sandboxDenied('grep', full) };
215
+ const files = globLib.sync(full, { nodir: true, ignore: globIgnore(full) });
216
+ const results = [];
217
+ for (const file of files.slice(0, 50)) {
218
+ try {
219
+ const content = await fs.readFile(file, 'utf8');
220
+ const lines = content.split('\n');
221
+ lines.forEach((line, i) => {
222
+ if (regex.test(line)) results.push(`${file}:${i+1}: ${line.trim().slice(0, 200)}`);
223
+ });
224
+ } catch {}
225
+ }
226
+ return results.join('\n') || 'No matches found';
227
+ }
228
+ },
229
+ glob: {
230
+ name: 'glob',
231
+ description: 'Find files matching pattern.',
232
+ parameters: {
233
+ pattern: { type: 'string', required: true, description: 'Glob pattern' },
234
+ path: { type: 'string', required: false, description: 'Directory to search in' },
235
+ },
236
+ async execute(params) {
237
+ const dir = (params.path || cwd).replace(/\\/g, '/');
238
+ const pattern = params.pattern.replace(/\\/g, '/');
239
+ const full = pattern.startsWith('/') || /^[a-zA-Z]:/.test(pattern)
240
+ ? pattern
241
+ : path.posix.join(dir, pattern);
242
+ if (!pathAllowed(dir) || !pathAllowed(full)) return { error: sandboxDenied('glob', full) };
243
+ const files = globLib.sync(full, { nodir: true, ignore: globIgnore(full) });
244
+ return files.slice(0, 100).join('\n') || 'No files found';
245
+ }
246
+ },
247
+ webfetch: {
248
+ name: 'webfetch',
249
+ description: 'Fetch content from a URL.',
250
+ parameters: {
251
+ url: { type: 'string', required: true, description: 'URL to fetch' },
252
+ },
253
+ async execute(params) {
254
+ try {
255
+ const resp = await fetch(params.url, { signal: AbortSignal.timeout(15000) });
256
+ const text = await resp.text();
257
+ return text.slice(0, 10000);
258
+ } catch (e) {
259
+ return { error: `Fetch failed: ${e.message}` };
260
+ }
261
+ }
262
+ },
263
+ websearch: {
264
+ name: 'websearch',
265
+ description: 'Web search. Uses Brave (BRAVE_API_KEY) or Tavily (TAVILY_API_KEY) when configured, else the DuckDuckGo HTML endpoint (no key). Returns top results with titles, URLs and snippets.',
266
+ parameters: {
267
+ query: { type: 'string', required: true, description: 'Search query' },
268
+ count: { type: 'number', required: false, description: 'Max results 1-10 (default 6)' },
269
+ },
270
+ async execute(params) {
271
+ const q = String(params.query || '').trim();
272
+ if (!q) return { error: 'websearch needs a non-empty query.' };
273
+ const n = Math.max(1, Math.min(10, Number(params.count) || 6));
274
+ const decode = (s) => String(s || '').replace(/<[^>]+>/g, '').replace(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&#x27;|&#39;/g, "'").replace(/&lt;/g, '<').replace(/&gt;/g, '>').trim();
275
+ try {
276
+ // 1) Brave
277
+ if (process.env.BRAVE_API_KEY) {
278
+ const r = await fetch('https://api.search.brave.com/res/v1/web/search?q=' + encodeURIComponent(q) + '&count=' + n, { headers: { 'X-Subscription-Token': process.env.BRAVE_API_KEY, Accept: 'application/json' }, signal: AbortSignal.timeout(15000) });
279
+ const j = /** @type {{web?: {results?: any[]}}} */ (await r.json());
280
+ const rows = (j.web && j.web.results || []).slice(0, n).map((x, i) => `${i + 1}. ${decode(x.title)}\n ${x.url}\n ${decode(x.description).slice(0, 220)}`);
281
+ return rows.length ? rows.join('\n') : 'No results for "' + q + '".';
282
+ }
283
+ // 2) Tavily
284
+ if (process.env.TAVILY_API_KEY) {
285
+ const r = await fetch('https://api.tavily.com/search', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ api_key: process.env.TAVILY_API_KEY, query: q, max_results: n }), signal: AbortSignal.timeout(15000) });
286
+ const j = /** @type {{results?: any[]}} */ (await r.json());
287
+ const rows = (j.results || []).slice(0, n).map((x, i) => `${i + 1}. ${decode(x.title)}\n ${x.url}\n ${decode(x.content).slice(0, 220)}`);
288
+ return rows.length ? rows.join('\n') : 'No results for "' + q + '".';
289
+ }
290
+ // 3) DuckDuckGo HTML fallback (no key)
291
+ const resp = await fetch('https://html.duckduckgo.com/html/?q=' + encodeURIComponent(q), { signal: AbortSignal.timeout(15000), headers: { 'User-Agent': 'Mozilla/5.0' } });
292
+ const html = await resp.text();
293
+ const rows = [];
294
+ const re = /<a[^>]+class="result__a"[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g;
295
+ let m;
296
+ while ((m = re.exec(html)) && rows.length < n) {
297
+ let url = m[1];
298
+ const u = url.match(/[?&]uddg=([^&]+)/);
299
+ if (u) url = decodeURIComponent(u[1]);
300
+ rows.push(`${rows.length + 1}. ${decode(m[2])}\n ${url}`);
301
+ }
302
+ const snips = [...html.matchAll(/class="result__snippet"[^>]*>([\s\S]*?)<\/a>/g)].map(x => decode(x[1]));
303
+ const out = rows.map((r2, i) => snips[i] ? r2 + '\n ' + snips[i].slice(0, 220) : r2);
304
+ return out.length ? out.join('\n') : 'No results for "' + q + '" (DDG returned nothing — set BRAVE_API_KEY or TAVILY_API_KEY for reliable search).';
305
+ } catch (e) {
306
+ return { error: 'websearch failed: ' + e.message };
307
+ }
308
+ }
309
+ },
310
+ todowrite: {
311
+ name: 'todowrite',
312
+ description: 'Create and maintain a structured task list for the current session. Tracks progress for multi-step work. Use when the task requires 3+ distinct steps or the user requests a todo list.',
313
+ parameters: {
314
+ todos: { type: 'array', required: true, description: 'Array of todo items with content, status (pending/in_progress/completed/cancelled), and priority (high/medium/low)' },
315
+ },
316
+ async execute(params) {
317
+ const todos = Array.isArray(params.todos) ? params.todos : [];
318
+ if (!todos.length) return '(no todos provided)';
319
+ const statuses = { pending: 0, in_progress: 0, completed: 0, cancelled: 0 };
320
+ const lines = ['## Todo List', ''];
321
+ for (const t of todos) {
322
+ const status = t.status || 'pending';
323
+ statuses[status] = (statuses[status] || 0) + 1;
324
+ const icon = status === 'completed' ? '[x]' : status === 'in_progress' ? '[>]' : status === 'cancelled' ? '[-]' : '[ ]';
325
+ const priority = t.priority ? ` [${t.priority}]` : '';
326
+ lines.push(`${icon}${priority} ${t.content || '(unnamed)'}`);
327
+ }
328
+ lines.push('');
329
+ lines.push(`Summary: ${statuses.completed} done, ${statuses.in_progress} in-progress, ${statuses.pending} pending, ${statuses.cancelled} cancelled`);
330
+ return lines.join('\n');
331
+ }
332
+ },
333
+ ask: {
334
+ name: 'ask',
335
+ description: 'Ask the user a question when you genuinely need their input (missing info, a decision between approaches, or confirmation of intent). Provide up to 3 concise options when the answer is likely one of a few choices — the user can pick one OR type their own answer. Use sparingly: prefer deciding and acting yourself; never ask what you can determine from the codebase.',
336
+ parameters: {
337
+ question: { type: 'string', required: true, description: 'The question for the user, one or two sentences' },
338
+ options: { type: 'array', required: false, description: 'Up to 3 short answer options the user can click', items: { type: 'string' } },
339
+ },
340
+ async execute(params) {
341
+ // Handled entirely by the session's permission gate (the popup answer
342
+ // becomes the tool result); this executor is a safety fallback.
343
+ return String(params.question || '');
344
+ }
345
+ },
346
+ task: {
347
+ name: 'task',
348
+ description: 'Delegate a self-contained subtask to a subagent that runs fully autonomously and returns its final answer. Use when a piece of work can run independently (parallelizable), needs a focused toolset, or benefits from a different model. Pick the smallest suitable agent: explore (fast read-only code search), scout (web/docs research), general (full toolset except delegation). Pass the agent a COMPLETE, self-contained instruction: the goal, relevant file paths, and exactly what to return. The subagent may run tools autonomously; fold its findings into your own answer afterwards.',
349
+ parameters: {
350
+ prompt: { type: 'string', required: true, description: 'Complete instructions for the subagent — goal, relevant file paths, and the exact output to return' },
351
+ agent: { type: 'string', required: false, description: 'Subagent id: general (default), explore, scout, or a custom one from config.agents' },
352
+ model: { type: 'string', required: false, description: 'Optional model override "provider/model-id" for this delegation' },
353
+ },
354
+ async execute(params, ctx) {
355
+ const { runSubagent } = require('../core/agents');
356
+ const res = await runSubagent({
357
+ agentId: params.agent || 'general',
358
+ prompt: params.prompt,
359
+ model: params.model,
360
+ parentSession: ctx && ctx.parentSession,
361
+ onProgress: ctx && ctx.progress,
362
+ signal: ctx && ctx.signal,
363
+ });
364
+ if ('error' in res) return { error: res.error };
365
+ const ms = res.durationMs;
366
+ const usage = `[${res.agent} finished in ${(ms / 1000).toFixed(1)}s \u00B7 ${res.tokensIn + res.tokensOut} tokens \u00B7 ${res.costUsd > 0.001 ? '$' + res.costUsd.toFixed(4) : 'free'}]`;
367
+ return usage + '\n\n' + res.content;
368
+ }
369
+ },
370
+ mcp: {
371
+ name: 'mcp',
372
+ description: 'Manage MCP (tool) servers for this workspace. Use when the user asks to add/remove/enable/disable an MCP server or connector (e.g. github, filesystem, gmail) or when a task requires tools the session does not have yet. In the TUI, hosting/cloud services (supabase, vercel, nextjs, railway, netlify, cloudflare) live under /connectors; dev-tool MCP servers (playwright, github, sentry, figma, postgres, mongodb, linear, slack, brave-search, exa, context7, filesystem, sqlite, puppeteer) live under /mcp. Common packages: Playwright=@playwright/mcp, Sentry=@sentry/mcp-server (needs --access-token TOKEN), Figma=figma-developer-mcp (env FIGMA_API_KEY), Postgres=@modelcontextprotocol/server-postgres (env DATABASE_URL), MongoDB=mongodb-mcp-server (env MONGODB_CONNECTION_STRING), Linear=linear-mcp-server (env LINEAR_API_KEY), Slack=@modelcontextprotocol/server-slack (env SLACK_BOT_TOKEN + SLACK_TEAM_ID), Brave=@modelcontextprotocol/server-brave-search (env BRAVE_API_KEY), Exa=exa-mcp-server (env EXA_API_KEY), Context7=@upstash/context7-mcp, Vercel=vercel-mcp-server (env VERCEL_TOKEN), Railway=@railway/mcp-server (env RAILWAY_API_TOKEN), Netlify=netlify-mcp-server (env NETLIFY_AUTH_TOKEN), Cloudflare=@cloudflare/mcp-server-cloudflare (env CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID), Supabase=@supabase/mcp-server-supabase (needs --access-token TOKEN), GitHub=ghcr.io/github/github-mcp-server (docker). If unsure about an MCP package, verify it exists first with webfetch (e.g. https://registry.npmjs.org/<pkg>) or web search (fetch or bash curl) — never trust an unverified npm package.',
373
+ parameters: {
374
+ action: { type: 'string', required: true, description: '"list" | "add" | "remove" | "enable" | "disable"' },
375
+ name: { type: 'string', required: false, description: 'MCP server name, letters/digits/-/_ (e.g. supabase)' },
376
+ command: { type: 'string', required: false, description: 'Command to start the stdio server (add only), e.g. "npx" or "cmd" on Windows' },
377
+ args: { type: 'string', required: false, description: 'Space-separated args (add only), e.g. "-y @supabase/mcp-server-supabase --access-token TOKEN". Never put real secrets in chat text; prefer env vars.' },
378
+ env: { type: 'string', required: false, description: 'Optional env vars as space-separated KEY=VAL pairs (add only), e.g. "RAILWAY_API_TOKEN=token123"' },
379
+ },
380
+ async execute(params) {
381
+ const mgr = require('../mcp/mcp-manager');
382
+ const action = String(params.action || '').toLowerCase();
383
+ if (action === 'list') {
384
+ const rows = mgr.listServers();
385
+ if (!rows.length) return 'No MCP servers configured.';
386
+ return rows.map((s) => {
387
+ const cmd = s.command + ' ' + (s.args || []).join(' ');
388
+ return (s.enabled ? '[on] ' : '[off] ') + s.name + ' \u2014 ' + cmd.trim();
389
+ }).join('\n');
390
+ }
391
+ if (action === 'add') {
392
+ if (!params.name || !params.command) return { error: 'add needs name + command' };
393
+ // Adding a server spawns arbitrary processes with env secrets — same
394
+ // safety filter as bash; the session gate prompts interactively and
395
+ // marks the call approved before it reaches this layer.
396
+ if (!params._approved) {
397
+ const risk = commandRiskLabel(params.command + ' ' + (params.args || ''));
398
+ if (risk) {
399
+ return { error: `Blocked by safety filter: ${risk}. Approve it via the permission prompt to run it anyway.` };
400
+ }
401
+ }
402
+ const args = params.args ? params.args.split(/\s+/) : [];
403
+ const env = {};
404
+ if (params.env) {
405
+ for (const kv of params.env.trim().split(/\s+/)) {
406
+ const eq = kv.indexOf('=');
407
+ if (eq > 0) env[kv.slice(0, eq)] = kv.slice(eq + 1);
408
+ }
409
+ }
410
+ const res = mgr.addServer(params.name, params.command, args, Object.keys(env).length ? { env } : undefined);
411
+ if (res && res.error) return { error: res.error };
412
+ return 'Added MCP server "' + params.name + '". It is enabled by default; the new tools are available on the next turn (run /mcp to browse).';
413
+ }
414
+ if (action === 'remove') {
415
+ if (!params.name) return { error: 'remove needs name' };
416
+ const res = mgr.removeServer(params.name);
417
+ if (res && res.error) return { error: res.error };
418
+ return 'Removed MCP server "' + params.name + '".';
419
+ }
420
+ if (action === 'enable' || action === 'disable') {
421
+ if (!params.name) return { error: action + ' needs name' };
422
+ const servers = mgr.listServers();
423
+ const cur = servers.find((s) => s.name === params.name);
424
+ if (!cur) return { error: 'MCP server not found: ' + params.name + ' (use action="list" to see what is configured).' };
425
+ const wantOn = action === 'enable';
426
+ if (cur.enabled === wantOn) return 'MCP "' + params.name + '" already ' + (wantOn ? 'enabled' : 'disabled') + '.';
427
+ const res = mgr.toggleServer(params.name);
428
+ if (res && res.error) return { error: res.error };
429
+ return 'MCP "' + params.name + '" is now ' + (res.enabled ? 'enabled' : 'disabled') + '.';
430
+ }
431
+ return { error: 'Unknown action "' + action + '". Use list, add, remove, enable, or disable.' };
432
+ },
433
+ },
434
+ lsp: {
435
+ name: 'lsp',
436
+ description: 'Run a Language Server Protocol diagnostic check on a file and return any errors/warnings. Use this to get language-server feedback (syntax, types, lint) after writing or editing code. Configure which servers run via config.json "lsp": true.',
437
+ parameters: {
438
+ filePath: { type: 'string', required: true, description: 'Absolute path to the file to check' },
439
+ },
440
+ async execute(params) {
441
+ const lsp = require('../core/lsp');
442
+ const res = await lsp.checkFile(path.resolve(params.filePath));
443
+ if (!res.ok) return { error: res.error };
444
+ if (!res.diagnostics.length) return `LSP (${res.id}): no diagnostics for ${params.filePath}`;
445
+ const errors = res.diagnostics.filter((d) => d.severity === 'error');
446
+ const warnings = res.diagnostics.filter((d) => d.severity === 'warning');
447
+ const lines = res.diagnostics.map((d) =>
448
+ `${d.severity === 'error' ? 'E' : d.severity === 'warning' ? 'W' : 'I'} ${d.line + 1}:${d.character + 1} [${d.source || res.id}] ${d.message}`);
449
+ return `LSP (${res.id}) — ${errors.length} error(s), ${warnings.length} warning(s):\n` + lines.join('\n');
450
+ }
451
+ },
452
+ };
453
+
454
+ function getToolDefinitions(mode = 'build') {
455
+ const names = mode === 'chat' ? [] : mode === 'plan' ? READ_ONLY_TOOLS : Object.keys(TOOLS);
456
+ return names.map(t => {
457
+ const tool = TOOLS[t];
458
+ return {
459
+ name: tool.name,
460
+ description: tool.description,
461
+ input_schema: {
462
+ type: 'object',
463
+ properties: tool.parameters,
464
+ required: Object.keys(tool.parameters).filter(k => tool.parameters[k].required),
465
+ },
466
+ };
467
+ });
468
+ }
469
+
470
+ /** Execute one tool. `ctx` is passed through to tool executors that need
471
+ * session context (parent session, abort signal, subagent progress relay).
472
+ * @param {string} [toolName]
473
+ * @param {object} [params]
474
+ * @param {string} [mode]
475
+ * @param {object} [ctx] */
476
+ async function executeTool(toolName, params, mode = 'build', ctx = null) {
477
+ if (mode && mode !== 'build') {
478
+ if (mode === 'chat') {
479
+ return { error: 'Blocked in chat mode: no tools are available. Switch to Build mode to use tools.' };
480
+ }
481
+ if (toolName && toolName.indexOf('mcp__') === 0) {
482
+ return { error: `Blocked in ${mode} mode: MCP tools are not available outside Build mode.` };
483
+ }
484
+ if (toolName && !READ_ONLY_TOOLS.includes(toolName)) {
485
+ return { error: `Blocked in ${mode} mode: ${toolName} is not read-only. Switch to Build mode to use it.` };
486
+ }
487
+ }
488
+ if (toolName && toolName.indexOf('mcp__') === 0) {
489
+ try {
490
+ const mcp = require('../mcp/mcp-client');
491
+ const idx = toolName.indexOf('__', 5);
492
+ if (idx < 0) return { error: 'Malformed MCP tool name: ' + toolName };
493
+ const server = toolName.slice(5, idx);
494
+ const tool = toolName.slice(idx + 2);
495
+ return await mcp.callTool(server, tool, params || {});
496
+ } catch (e) {
497
+ return { error: 'MCP error: ' + e.message };
498
+ }
499
+ }
500
+ const tool = TOOLS[toolName];
501
+ if (!tool) return { error: `Unknown tool: ${toolName}` };
502
+ try {
503
+ const result = await tool.execute(params, ctx);
504
+ if (result && typeof result === 'object' && result.error) return { error: result.error };
505
+ return { result };
506
+ } catch (e) {
507
+ return { error: e.message };
508
+ }
509
+ }
510
+
511
+ const baseDefsCache = { build: null, plan: null };
512
+ async function getAllToolDefinitions(mode = 'build') {
513
+ if (mode === 'chat') return [];
514
+ // Tool schemas are static — build the base list once per mode.
515
+ const base = baseDefsCache[mode] || (baseDefsCache[mode] = getToolDefinitions(mode));
516
+ if (mode === 'plan') return base; // no MCP tools in plan mode (unknown side effects)
517
+ try {
518
+ const mcp = require('../mcp/mcp-client');
519
+ // Warm discovery started at session boot; wait at most 2s so the first
520
+ // turn never blocks on npx downloads, then proceed without MCP tools.
521
+ const servers = await Promise.race([
522
+ mcp.getCachedTools().catch(() => []),
523
+ new Promise(res => setTimeout(() => res(null), 2000)),
524
+ ]);
525
+ if (!servers || !servers.length) return base;
526
+ const mcpDefs = [];
527
+ for (const s of servers) {
528
+ if (s.error) continue;
529
+ for (const t of s.tools || []) {
530
+ mcpDefs.push({
531
+ name: mcp.buildToolName(s.server, t.name),
532
+ description: (t.description || ('MCP tool ' + t.name + ' from ' + s.server)).slice(0, 500),
533
+ input_schema: t.inputSchema || { type: 'object', properties: {} },
534
+ });
535
+ }
536
+ }
537
+ return base.concat(mcpDefs);
538
+ } catch (e) {
539
+ return base;
540
+ }
541
+ }
542
+
543
+ module.exports = { TOOLS, MODES, READ_ONLY_TOOLS, getToolDefinitions, getAllToolDefinitions, executeTool };