loom-agent 1.3.2 → 1.5.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.
@@ -89,7 +89,7 @@ const MODES = ['build', 'plan', 'chat'];
89
89
  // Tools that never mutate the filesystem/state — safe to expose in plan mode.
90
90
  // task delegates to a read-only-or-not subagent, so from the CALLER's side it
91
91
  // is safe in plan mode (the subagent's own tool list gates what it may do).
92
- const READ_ONLY_TOOLS = ['read', 'glob', 'grep', 'webfetch', 'websearch', 'todowrite', 'task'];
92
+ const READ_ONLY_TOOLS = ['read', 'glob', 'grep', 'webfetch', 'websearch', 'todowrite', 'todoread', 'task'];
93
93
 
94
94
  // Optional path sandbox: when config.sandbox.paths is set, filesystem tools
95
95
  // only operate inside those roots (defense in depth on top of permissions).
@@ -126,7 +126,30 @@ const TOOLS = {
126
126
  const filePath = path.resolve(params.filePath);
127
127
  if (!pathAllowed(filePath)) return { error: sandboxDenied('read', filePath) };
128
128
  if (!fsSync.existsSync(filePath)) return { error: `File not found: ${filePath}` };
129
- const content = await fs.readFile(filePath, 'utf8');
129
+ // Binary detection: a NUL byte in the first 8 KB means this isn't text —
130
+ // reading it as utf8 produces garbage that poisons the context.
131
+ const stat = fsSync.statSync(filePath);
132
+ if (stat.size > 0) {
133
+ const fd = fsSync.openSync(filePath, 'r');
134
+ try {
135
+ const head = Buffer.alloc(Math.min(8192, stat.size));
136
+ fsSync.readSync(fd, head, 0, head.length, 0);
137
+ if (head.includes(0)) {
138
+ return `[binary file: ${filePath} — ${stat.size} bytes, content omitted]`;
139
+ }
140
+ } catch {
141
+ // Unreadable head (device/fifo?) — fall through and try normally.
142
+ } finally {
143
+ fsSync.closeSync(fd);
144
+ }
145
+ }
146
+ // Size cap: huge files are read up to 2000 lines (or the requested
147
+ // window) instead of slurping megabytes into the context.
148
+ const MAX_FILE_BYTES = 512 * 1024;
149
+ const content = stat.size > MAX_FILE_BYTES && !params.offset && !params.limit
150
+ ? (await fs.readFile(filePath, 'utf8')).split('\n').slice(0, 2000).join('\n') +
151
+ `\n[file truncated at 2000 lines (${stat.size} bytes total) — pass offset/limit to read more]`
152
+ : await fs.readFile(filePath, 'utf8');
130
153
  const lines = content.split('\n');
131
154
  const start = (params.offset || 1) - 1;
132
155
  const end = params.limit ? start + params.limit : lines.length;
@@ -154,11 +177,12 @@ const TOOLS = {
154
177
  },
155
178
  edit: {
156
179
  name: 'edit',
157
- description: 'Performs exact string replacements in files.',
180
+ description: 'Performs exact string replacements in files. Pass either oldString/newString for one replacement, or an edits array of {oldString, newString, replaceAll?} entries applied in order (multi-edit — prefer this over several edit calls on the same file).',
158
181
  parameters: {
159
182
  filePath: { type: 'string', required: true, description: 'Absolute path to the file' },
160
- oldString: { type: 'string', required: true, description: 'Text to replace' },
161
- newString: { type: 'string', required: true, description: 'Replacement text' },
183
+ oldString: { type: 'string', required: false, description: 'Text to replace (single edit)' },
184
+ newString: { type: 'string', required: false, description: 'Replacement text (single edit)' },
185
+ edits: { type: 'array', required: false, description: 'Multiple edits applied in order: [{oldString, newString, replaceAll?}, ...]' },
162
186
  replaceAll: { type: 'boolean', required: false, description: 'Replace all occurrences' },
163
187
  dryRun: { type: 'boolean', required: false, description: 'Preview the edit without writing' },
164
188
  },
@@ -167,43 +191,71 @@ const TOOLS = {
167
191
  if (!pathAllowed(filePath)) return { error: sandboxDenied('edit', filePath) };
168
192
  if (!fsSync.existsSync(filePath)) return { error: `File not found: ${filePath}` };
169
193
  let content = await fs.readFile(filePath, 'utf8');
170
- const count = content.split(params.oldString).length - 1;
171
- if (count === 0) return { error: 'oldString not found in content' };
172
- if (params.replaceAll) {
173
- content = content.split(params.oldString).join(params.newString);
174
- } else {
175
- content = content.replace(params.oldString, params.newString.replace(/\$/g, '$$$$'));
194
+ // Multi-edit path: apply each {oldString, newString} in order against
195
+ // the in-memory copy, then write once. Sequential application means a
196
+ // later edit can target text an earlier edit inserted. Entries enforce
197
+ // uniqueness (a multi-edit that silently hits 2+ matches is how files
198
+ // get corrupted); the classic single oldString path keeps its
199
+ // replace-first-occurrence semantics for back-compat.
200
+ const multi = Array.isArray(params.edits) && params.edits.length > 0;
201
+ const edits = multi
202
+ ? params.edits
203
+ : (params.oldString != null || params.newString != null
204
+ ? [{ oldString: params.oldString, newString: params.newString, replaceAll: params.replaceAll }]
205
+ : []);
206
+ if (!edits.length) return { error: 'edit needs oldString/newString or an edits array' };
207
+ const applied = [];
208
+ for (let i = 0; i < edits.length; i++) {
209
+ const e = edits[i] || {};
210
+ const oldStr = e.oldString;
211
+ const newStr = e.newString;
212
+ if (typeof oldStr !== 'string' || typeof newStr !== 'string') {
213
+ return { error: `edits[${i}]: oldString and newString must be strings` };
214
+ }
215
+ const count = content.split(oldStr).length - 1;
216
+ if (count === 0) return { error: multi ? `edits[${i}]: oldString not found in content` : 'oldString not found in content' };
217
+ if (e.replaceAll) {
218
+ content = content.split(oldStr).join(newStr);
219
+ applied.push(`replaced ${count} occurrences`);
220
+ } else {
221
+ if (multi && count > 1) {
222
+ return { error: `edits[${i}]: found ${count} matches for oldString — provide more context to make it unique` };
223
+ }
224
+ content = content.replace(oldStr, newStr.replace(/\$/g, '$$$$'));
225
+ applied.push('replaced 1 occurrence');
226
+ }
176
227
  }
177
228
  if (params.dryRun) {
178
- const changed = params.replaceAll ? `replaced ${count} occurrences` : 'replaced 1 occurrence';
179
- return `Dry run — would edit ${filePath}: ${changed} (file not modified).\n${content.slice(0, 4000)}`;
229
+ return `Dry run — would edit ${filePath}: ${applied.join('; ')} (file not modified).\n${content.slice(0, 4000)}`;
180
230
  }
181
231
  await fs.writeFile(filePath, content, 'utf8');
182
232
  let extra = '';
183
233
  try { extra = await require('../core/format').formatAfterWrite(filePath); } catch {}
184
- return `Edited ${filePath}: ${params.replaceAll ? `replaced ${count} occurrences` : 'replaced 1 occurrence'}${extra}`;
234
+ return `Edited ${filePath}: ${applied.join('; ')}${extra}`;
185
235
  }
186
236
  },
187
237
  bash: {
188
238
  name: 'bash',
189
- description: 'Execute a shell command.',
239
+ description: 'Execute a shell command on the user\'s machine. Pass sandbox:true to run it INSIDE the Docker sandbox instead (docker exec into loom-sandbox, project at /workspace) — required for anything the user asks to run "in the sandbox".',
190
240
  parameters: {
191
241
  command: { type: 'string', required: true, description: 'The command to execute' },
192
242
  workdir: { type: 'string', required: false, description: 'Working directory' },
193
- background: { type: 'boolean', required: false, description: 'Run without waiting (dev servers, long builds). Returns a task id immediately; check with the /tasks panel.' },
243
+ timeoutMs: { type: 'number', required: false, description: 'Kill the command after this many ms (default 60000, max 600000)' },
244
+ background: { type: 'boolean', required: false, description: 'Run without waiting (dev servers, long builds). Returns a task id immediately; check with the /tasks panel or the tasks tool.' },
245
+ sandbox: { type: 'boolean', required: false, description: 'Run this command inside the Docker sandbox container (docker exec) instead of on the host. Use when the user asked for the sandbox or isolation is needed; defaults to the host.' },
194
246
  },
195
247
  async execute(params, ctx) {
196
- // Docker sandbox: when enabled (/sandbox setup), every command runs
197
- // inside the loom-sandbox container instead of the host shell.
198
- // Background tasks stay on the host (they outlive tool timeouts).
199
- const sandbox = require('../core/sandbox');
200
- if (!params.background && sandbox.shouldUseSandbox()) {
201
- if (!pathAllowed(params.workdir || cwd)) return { error: sandboxDenied('bash', params.workdir || cwd) };
248
+ // Sandbox opt-in: the SAME command goes through docker exec into the
249
+ // loom-sandbox container — never a silent host fallback if the
250
+ // container is unavailable (the error tells the model what to do).
251
+ if (params.sandbox && !params.background) {
252
+ const sandbox = require('../core/sandbox');
202
253
  const risk = params._approved ? null : commandRiskLabel(params.command);
203
254
  if (risk) return { error: `Blocked by safety filter: ${risk}. Approve it via the permission prompt to run it anyway.` };
204
255
  const stream = ctx && typeof ctx.stream === 'function' ? ctx.stream : null;
205
256
  const res = await sandbox.runInSandbox(params.command, {
206
257
  workdir: params.workdir || cwd,
258
+ timeoutMs: typeof params.timeoutMs === 'number' && params.timeoutMs > 0 ? Math.min(params.timeoutMs, 600000) : 120000,
207
259
  onChunk: stream ? (text, which) => stream(text, which) : undefined,
208
260
  });
209
261
  if ('error' in res) return { error: res.error };
@@ -236,13 +288,29 @@ const TOOLS = {
236
288
  const child = isWin
237
289
  ? spawn('powershell', ['-NoProfile', '-NonInteractive', '-Command', '[Console]::OutputEncoding=[Text.Encoding]::UTF8;' + params.command], opts)
238
290
  : spawn('/bin/sh', ['-c', params.command], opts);
239
- const killTimer = setTimeout(() => {
291
+ // Kill after timeoutMs (default 60s, cap 600s) so a hung command
292
+ // can never wedge the turn forever.
293
+ const timeoutMs = Math.min(Math.max(Number(params.timeoutMs) || 60000, 1000), 600000);
294
+ let timedOut = false;
295
+ let aborted = false;
296
+ const killChild = () => {
240
297
  if (isWin) {
241
298
  try { execSync('taskkill /PID ' + child.pid + ' /T /F', { stdio: 'ignore', windowsHide: true }); } catch {}
242
299
  }
243
300
  child.kill('SIGKILL');
244
- }, 60000);
301
+ };
302
+ const killTimer = setTimeout(() => { timedOut = true; killChild(); }, timeoutMs);
245
303
  child.on('close', () => clearTimeout(killTimer));
304
+ // ESC/interrupt: the session's abort signal kills the running
305
+ // command so the user isn't left waiting on a zombie process.
306
+ const sig = ctx && ctx.signal;
307
+ if (sig) {
308
+ if (sig.aborted) { aborted = true; clearTimeout(killTimer); killChild(); }
309
+ else {
310
+ const onAbort = () => { aborted = true; clearTimeout(killTimer); killChild(); };
311
+ try { sig.addEventListener('abort', onAbort, { once: true }); child.on('close', () => { try { sig.removeEventListener('abort', onAbort); } catch {} }); } catch {}
312
+ }
313
+ }
246
314
  let stdout = '';
247
315
  let stderr = '';
248
316
  let truncated = false;
@@ -272,7 +340,9 @@ const TOOLS = {
272
340
  let result = '';
273
341
  if (stdout) result += stdout;
274
342
  if (stderr) result += `\n[stderr]\n${stderr}`;
275
- if (code !== 0) result += `\n[exit code: ${code}]`;
343
+ if (timedOut) result += `\n[timed out after ${Math.round(timeoutMs / 1000)}s — killed]`;
344
+ else if (aborted) result += '\n[aborted by user]';
345
+ else if (code !== 0) result += `\n[exit code: ${code}]`;
276
346
  if (truncated) result += '\n[output truncated at 10 MB]';
277
347
  resolve(result || 'command completed');
278
348
  });
@@ -283,6 +353,32 @@ const TOOLS = {
283
353
  });
284
354
  }
285
355
  },
356
+ sandbox: {
357
+ name: 'sandbox',
358
+ description: 'Run a shell command INSIDE the Docker sandbox via `docker exec loom-sandbox sh -lc "<command>"` (isolated Linux container, project at /workspace). Use when the user asked for the sandbox, the command is risky (installs, deletions, untrusted scripts), or a Linux-only environment is needed. Equivalent to bash with sandbox:true. One command per call.',
359
+ parameters: {
360
+ command: { type: 'string', required: true, description: 'The Linux shell command to run inside the container' },
361
+ workdir: { type: 'string', required: false, description: 'Working directory inside the container (host paths under the project map to /workspace/...)' },
362
+ timeoutMs: { type: 'number', required: false, description: 'Kill the command after this many ms (default 120000)' },
363
+ },
364
+ async execute(params, ctx) {
365
+ // Opt-in isolation path: nothing is routed here automatically. The
366
+ // container is revived/re-mounted on demand, then torn back to idle.
367
+ const sandbox = require('../core/sandbox');
368
+ const risk = params._approved ? null : commandRiskLabel(params.command);
369
+ if (risk) return { error: `Blocked by safety filter: ${risk}. Approve it via the permission prompt to run it anyway.` };
370
+ const stream = ctx && typeof ctx.stream === 'function' ? ctx.stream : null;
371
+ const res = await sandbox.runInSandbox(params.command, {
372
+ workdir: params.workdir || cwd,
373
+ timeoutMs: typeof params.timeoutMs === 'number' && params.timeoutMs > 0 ? params.timeoutMs : undefined,
374
+ onChunk: stream ? (text, which) => stream(text, which) : undefined,
375
+ });
376
+ if ('error' in res) return { error: res.error };
377
+ let out = res.output;
378
+ if (res.code !== 0) out += `\n[exit code: ${res.code}]`;
379
+ return out || 'command completed';
380
+ }
381
+ },
286
382
  grep: {
287
383
  name: 'grep',
288
384
  description: 'Search file contents using regular expressions.',
@@ -337,7 +433,7 @@ const TOOLS = {
337
433
  parameters: {
338
434
  url: { type: 'string', required: true, description: 'URL to fetch' },
339
435
  },
340
- async execute(params) {
436
+ async execute(params, ctx) {
341
437
  let u;
342
438
  try {
343
439
  u = new URL(String(params.url || ''));
@@ -351,7 +447,10 @@ const TOOLS = {
351
447
  // Follow redirects manually so every hop passes the SSRF guard — a
352
448
  // public URL may legitimately bounce into an internal address.
353
449
  let current = u;
354
- const signal = AbortSignal.timeout(15000);
450
+ // ESC/interrupt kills the in-flight fetch (ctx.signal) alongside the
451
+ // 15s timeout so the turn never hangs on a stalled connection.
452
+ const timeoutSignal = AbortSignal.timeout(15000);
453
+ const signal = (ctx && ctx.signal) ? AbortSignal.any([ctx.signal, timeoutSignal]) : timeoutSignal;
355
454
  for (let hop = 0; hop < 5; hop++) {
356
455
  if (!(await urlIsPublic(current))) {
357
456
  return { error: `Blocked by webfetch policy: ${current.hostname} resolves to a private/reserved address (SSRF guard)` };
@@ -443,6 +542,59 @@ const TOOLS = {
443
542
  return lines.join('\n');
444
543
  }
445
544
  },
545
+ todoread: {
546
+ name: 'todoread',
547
+ description: 'Read the current session todo list (the same list todowrite maintains). Use to check task progress without overwriting it.',
548
+ parameters: {},
549
+ async execute(params, ctx) {
550
+ const todos = (ctx && ctx.parentSession && ctx.parentSession.todos) || [];
551
+ if (!todos.length) return '(no todos yet)';
552
+ const statuses = { pending: 0, in_progress: 0, completed: 0, cancelled: 0 };
553
+ const lines = ['## Todo List', ''];
554
+ for (const t of todos) {
555
+ const status = t.status || 'pending';
556
+ statuses[status] = (statuses[status] || 0) + 1;
557
+ const icon = status === 'completed' ? '[x]' : status === 'in_progress' ? '[>]' : status === 'cancelled' ? '[-]' : '[ ]';
558
+ const priority = t.priority ? ` [${t.priority}]` : '';
559
+ lines.push(`${icon}${priority} ${t.content || '(unnamed)'}`);
560
+ }
561
+ lines.push('');
562
+ lines.push(`Summary: ${statuses.completed} done, ${statuses.in_progress} in-progress, ${statuses.pending} pending, ${statuses.cancelled} cancelled`);
563
+ return lines.join('\n');
564
+ }
565
+ },
566
+ tasks: {
567
+ name: 'tasks',
568
+ description: 'Inspect and manage background tasks started with bash background:true. Actions: list (all tasks with status), output (recent output of one task by id), kill (stop a running task). Use when a background command may have finished, or the user asks about dev servers/builds running in the background.',
569
+ parameters: {
570
+ action: { type: 'string', required: true, description: '"list" | "output" | "kill"' },
571
+ id: { type: 'string', required: false, description: 'Task id (for output/kill), e.g. bt-1-abc' },
572
+ tailLines: { type: 'number', required: false, description: 'How many trailing lines of output to return (default 80)' },
573
+ },
574
+ async execute(params) {
575
+ const bt = require('../core/background-tasks');
576
+ const action = String(params.action || 'list').toLowerCase();
577
+ if (action === 'list') {
578
+ const rows = bt.listBackgroundTasks();
579
+ if (!rows.length) return 'No background tasks. Start one with bash {background: true}.';
580
+ return rows.map((t) => `${t.id} [${t.status}]${t.exitCode != null ? ' exit ' + t.exitCode : ''} ${String(t.command).slice(0, 120)}`).join('\n');
581
+ }
582
+ if (action === 'output') {
583
+ const t = bt.getBackgroundTask(String(params.id || ''));
584
+ if (!t) return { error: 'Unknown task id. Use action="list" to see ids.' };
585
+ const lines = String(t.output || '').split('\n');
586
+ const n = Math.min(Math.max(Number(params.tailLines) || 80, 1), 500);
587
+ const tail = lines.slice(-n).join('\n');
588
+ return `[${t.id} — ${t.status}${t.exitCode != null ? ', exit ' + t.exitCode : ''}]\n` + (tail || '(no output yet)');
589
+ }
590
+ if (action === 'kill') {
591
+ const ok = bt.killBackgroundTask(String(params.id || ''));
592
+ if (!ok) return { error: 'Task not found or not running: ' + (params.id || '(none)') };
593
+ return 'Killed ' + params.id + '.';
594
+ }
595
+ return { error: 'Unknown action "' + action + '". Use list, output, or kill.' };
596
+ }
597
+ },
446
598
  ask: {
447
599
  name: 'ask',
448
600
  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.',
@@ -458,7 +610,7 @@ const TOOLS = {
458
610
  },
459
611
  task: {
460
612
  name: 'task',
461
- 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. Agents: general (full toolset, default), explore (fast read-only code search), scout (quick docs lookups), research (deep multi-source investigation with a sourced report), coder (isolated implementation packages), reviewer (severity-ranked code review), tester (write/run tests until green), web (headless-browser work on live pages). Pick the smallest suitable agent. Pass 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.',
613
+ 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 (see the agent param). Pass 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.',
462
614
  parameters: {
463
615
  prompt: { type: 'string', required: true, description: 'Complete instructions for the subagent — goal, relevant file paths, and the exact output to return' },
464
616
  agent: { type: 'string', required: false, description: 'Subagent id: general (default), explore, scout, or a custom one from config.agents' },
@@ -498,7 +650,7 @@ const TOOLS = {
498
650
  },
499
651
  mcp: {
500
652
  name: 'mcp',
501
- 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.',
653
+ description: 'Manage MCP (tool) servers for this workspace: list, add, remove, enable, disable. Use when the user asks to add a connector or when a task needs tools the session lacks yet (a DB, a browser, a ticketing system). Adding a server needs a stdio command (e.g. "npx -y <pkg>"); it stays disabled until enabled. If unsure a package exists, verify it with webfetch on the registry before installing — never run an unverified npm package.',
502
654
  parameters: {
503
655
  action: { type: 'string', required: true, description: '"list" | "add" | "remove" | "enable" | "disable"' },
504
656
  name: { type: 'string', required: false, description: 'MCP server name, letters/digits/-/_ (e.g. supabase)' },
@@ -596,6 +748,22 @@ function getToolDefinitions(mode = 'build') {
596
748
  });
597
749
  }
598
750
 
751
+ // Session-level tool-result cap: a tool that returns megabytes (huge bash
752
+ // output, MCP payloads, giant file reads) poisons the context window and can
753
+ // even break the provider request. Cap at 100k chars, head+tail so both the
754
+ // start AND the error tail survive.
755
+ const TOOL_RESULT_CAP = 100 * 1000;
756
+
757
+ function truncateToolText(text) {
758
+ const s = String(text);
759
+ if (s.length <= TOOL_RESULT_CAP) return s;
760
+ const head = Math.floor(TOOL_RESULT_CAP * 0.6);
761
+ const tail = TOOL_RESULT_CAP - head - 100;
762
+ return s.slice(0, head) +
763
+ `\n\n[…truncated ${s.length - head - tail} chars — full output was ${s.length} chars; re-run with narrower scope]\n\n` +
764
+ s.slice(-tail);
765
+ }
766
+
599
767
  /** Execute one tool. `ctx` is passed through to tool executors that need
600
768
  * session context (parent session, abort signal, subagent progress relay).
601
769
  * @param {string} [toolName]
@@ -621,19 +789,22 @@ async function executeTool(toolName, params, mode = 'build', ctx = null) {
621
789
  if (idx < 0) return { error: 'Malformed MCP tool name: ' + toolName };
622
790
  const server = toolName.slice(5, idx);
623
791
  const tool = toolName.slice(idx + 2);
624
- return await mcp.callTool(server, tool, params || {});
792
+ const res = await mcp.callTool(server, tool, params || {});
793
+ if (res && typeof res === 'object' && res.error) return { error: truncateToolText(res.error) };
794
+ if (res && typeof res === 'object' && typeof res.result === 'string') return { result: truncateToolText(res.result) };
795
+ return res;
625
796
  } catch (e) {
626
- return { error: 'MCP error: ' + e.message };
797
+ return { error: truncateToolText('MCP error: ' + e.message) };
627
798
  }
628
799
  }
629
800
  const tool = TOOLS[toolName];
630
801
  if (!tool) return { error: `Unknown tool: ${toolName}` };
631
802
  try {
632
803
  const result = await tool.execute(params, ctx);
633
- if (result && typeof result === 'object' && result.error) return { error: result.error };
634
- return { result };
804
+ if (result && typeof result === 'object' && result.error) return { error: truncateToolText(result.error) };
805
+ return { result: typeof result === 'string' ? truncateToolText(result) : result };
635
806
  } catch (e) {
636
- return { error: e.message };
807
+ return { error: truncateToolText(e.message) };
637
808
  }
638
809
  }
639
810
 
@@ -669,4 +840,4 @@ async function getAllToolDefinitions(mode = 'build') {
669
840
  }
670
841
  }
671
842
 
672
- module.exports = { TOOLS, MODES, READ_ONLY_TOOLS, getToolDefinitions, getAllToolDefinitions, executeTool, isPrivateAddress };
843
+ module.exports = { TOOLS, MODES, READ_ONLY_TOOLS, getToolDefinitions, getAllToolDefinitions, executeTool, isPrivateAddress, truncateToolText };