@link-assistant/agent 0.26.15 → 0.27.1

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 (65) hide show
  1. package/README.md +49 -7
  2. package/package.json +51 -42
  3. package/src/agent/agent.ts +12 -7
  4. package/src/auth/azure-login.ts +241 -0
  5. package/src/auth/plugins.ts +9 -0
  6. package/src/auth/provider-logins.ts +804 -0
  7. package/src/cli/argv.ts +1 -1
  8. package/src/cli/cmd/auth.ts +4 -0
  9. package/src/cli/cmd/models.ts +68 -13
  10. package/src/cli/cmd/run.ts +7 -1
  11. package/src/cli/continuous-mode.js +38 -12
  12. package/src/cli/event-handler.js +3 -1
  13. package/src/cli/model-config.js +27 -8
  14. package/src/cli/run-options.js +144 -78
  15. package/src/config/config.ts +306 -105
  16. package/src/config/defaults.ts +13 -5
  17. package/src/config/file-config.ts +16 -0
  18. package/src/file/ripgrep.ts +38 -5
  19. package/src/file/time.ts +86 -9
  20. package/src/index.js +42 -33
  21. package/src/mcp/index.ts +370 -106
  22. package/src/permission/index.ts +22 -0
  23. package/src/project/instance.ts +5 -1
  24. package/src/project/project.ts +35 -2
  25. package/src/provider/env-template.ts +115 -0
  26. package/src/provider/extra-providers.ts +347 -0
  27. package/src/provider/free-tier-error.ts +1 -1
  28. package/src/provider/kilo-deprecation.ts +30 -0
  29. package/src/provider/kilo.ts +44 -7
  30. package/src/provider/model-discovery.ts +690 -0
  31. package/src/provider/models.ts +11 -1
  32. package/src/provider/parity-loaders.ts +595 -0
  33. package/src/provider/provider.ts +309 -203
  34. package/src/provider/retry-fetch.ts +5 -0
  35. package/src/provider/transform.ts +168 -17
  36. package/src/session/compaction.ts +18 -2
  37. package/src/session/index.ts +30 -3
  38. package/src/session/message-v2.ts +318 -32
  39. package/src/session/processor.ts +274 -140
  40. package/src/session/prompt.ts +154 -128
  41. package/src/session/retry.ts +26 -13
  42. package/src/session/summary.ts +4 -1
  43. package/src/tool/bash.ts +232 -45
  44. package/src/tool/bash.txt +1 -1
  45. package/src/tool/codesearch.ts +15 -78
  46. package/src/tool/edit.ts +111 -30
  47. package/src/tool/glob.ts +2 -1
  48. package/src/tool/grep.ts +175 -57
  49. package/src/tool/ls.ts +2 -1
  50. package/src/tool/multiedit.ts +3 -1
  51. package/src/tool/read.ts +169 -41
  52. package/src/tool/task.ts +26 -18
  53. package/src/tool/tool.ts +4 -1
  54. package/src/tool/webfetch.ts +68 -56
  55. package/src/tool/webfetch.txt +4 -4
  56. package/src/tool/websearch.ts +20 -75
  57. package/src/tool/websearch.txt +2 -0
  58. package/src/tool/write.ts +23 -6
  59. package/src/util/bom.ts +32 -0
  60. package/src/util/exa-mcp.ts +82 -0
  61. package/src/util/legacy-error-names.ts +39 -0
  62. package/src/util/read-bounded.ts +32 -0
  63. package/src/util/sse-usage-extractor.ts +67 -13
  64. package/src/util/timeout.ts +3 -4
  65. package/src/util/verbose-fetch.ts +40 -1
package/src/mcp/index.ts CHANGED
@@ -10,6 +10,8 @@ import { NamedError } from '../util/error';
10
10
  import z from 'zod/v4';
11
11
  import { Instance } from '../project/instance';
12
12
  import { withTimeout } from '../util/timeout';
13
+ import { sanitizeUrl } from '../util/verbose-fetch';
14
+ import { spawnSync } from 'node:child_process';
13
15
 
14
16
  export namespace MCP {
15
17
  const log = Log.create({ service: 'mcp' });
@@ -35,6 +37,10 @@ export namespace MCP {
35
37
 
36
38
  type Client = Awaited<ReturnType<typeof experimental_createMCPClient>>;
37
39
 
40
+ /** Documented default of the per-server `timeout` for listing tools. */
41
+ const DEFAULT_LIST_TIMEOUT = 5000;
42
+ const MAX_LIST_PAGES = 1000;
43
+
38
44
  /** Global timeout defaults from configuration */
39
45
  export interface GlobalTimeoutDefaults {
40
46
  /** Global default timeout for MCP tool calls */
@@ -85,10 +91,14 @@ export namespace MCP {
85
91
  const state = Instance.state(
86
92
  async () => {
87
93
  const cfg = await Config.get();
88
- const config = cfg.mcp ?? {};
94
+ // Named `servers`, not `config`: shadowing the imported AgentConfig made
95
+ // both global defaults `undefined`, so every tool timeout became NaN.
96
+ const servers = cfg.mcp ?? {};
89
97
  const clients: Record<string, Client> = {};
90
98
  const status: Record<string, Status> = {};
91
99
  const timeoutConfigs: Record<string, TimeoutConfig> = {};
100
+ const listTimeouts: Record<string, number> = {};
101
+ const processes: Record<string, StdioClientTransport> = {};
92
102
 
93
103
  // Determine global timeout defaults from config and environment variables.
94
104
  // Uses config.mcp*() which reads from centralized AgentConfig (lino-arguments).
@@ -102,11 +112,22 @@ export namespace MCP {
102
112
  };
103
113
 
104
114
  await Promise.all(
105
- Object.entries(config).map(async ([key, mcp]) => {
106
- const result = await create(key, mcp).catch(() => undefined);
115
+ Object.entries(servers).map(async ([key, mcp]) => {
116
+ // A config that throws while connecting (e.g. a bad URL) must still
117
+ // show up as failed instead of silently disappearing.
118
+ // See: https://github.com/anomalyco/opencode/pull/25019
119
+ const result = await create(key, mcp).catch((error) => ({
120
+ mcpClient: undefined,
121
+ transport: undefined,
122
+ status: {
123
+ status: 'failed' as const,
124
+ error: error instanceof Error ? error.message : String(error),
125
+ },
126
+ }));
107
127
  if (!result) return;
108
128
 
109
129
  status[key] = result.status;
130
+ listTimeouts[key] = mcp.timeout ?? DEFAULT_LIST_TIMEOUT;
110
131
 
111
132
  // Store timeout configuration for this MCP server
112
133
  // Per-server timeout overrides global default, but is capped at global max
@@ -121,6 +142,7 @@ export namespace MCP {
121
142
 
122
143
  if (result.mcpClient) {
123
144
  clients[key] = result.mcpClient;
145
+ if (result.transport) processes[key] = result.transport;
124
146
  }
125
147
  })
126
148
  );
@@ -128,26 +150,118 @@ export namespace MCP {
128
150
  status,
129
151
  clients,
130
152
  timeoutConfigs,
153
+ listTimeouts,
154
+ processes,
131
155
  globalDefaults,
132
156
  };
133
157
  },
134
158
  async (state) => {
135
159
  await Promise.all(
136
- Object.values(state.clients).map((client) =>
137
- client.close().catch((error) => {
138
- log.error(() => ({
139
- message: 'Failed to close MCP client',
140
- error,
141
- }));
142
- })
160
+ Object.entries(state.clients).map(([key, client]) =>
161
+ closeClient(client, state.processes[key])
143
162
  )
144
163
  );
145
164
  }
146
165
  );
147
166
 
167
+ /** Every process below `pid`, collected before the tree is torn down. */
168
+ function descendants(pid: number | null | undefined): number[] {
169
+ if (!pid || process.platform === 'win32') return [];
170
+ const result: number[] = [];
171
+ const queue = [pid];
172
+ while (queue.length > 0) {
173
+ const parent = queue.shift()!;
174
+ const out = spawnSync('pgrep', ['-P', String(parent)], {
175
+ encoding: 'utf8',
176
+ });
177
+ for (const line of (out.stdout ?? '').split('\n')) {
178
+ const child = Number(line.trim());
179
+ if (!child || result.includes(child)) continue;
180
+ result.push(child);
181
+ queue.push(child);
182
+ }
183
+ }
184
+ return result;
185
+ }
186
+
187
+ /**
188
+ * Closing the client only stops the direct child; a server started through
189
+ * a wrapper (`sh -c`, `npx`, `uvx`) leaves its real process running.
190
+ * See: https://github.com/anomalyco/opencode/pull/15516
191
+ */
192
+ async function closeClient(client: Client, transport?: StdioClientTransport) {
193
+ const pids = descendants(transport?.pid);
194
+ await client.close().catch((error) => {
195
+ log.error(() => ({
196
+ message: 'Failed to close MCP client',
197
+ error,
198
+ }));
199
+ });
200
+ for (const pid of pids) {
201
+ try {
202
+ process.kill(pid, 'SIGTERM');
203
+ } catch {
204
+ // already gone
205
+ }
206
+ }
207
+ }
208
+
209
+ function sanitize(name: string) {
210
+ return name.replace(/[^a-zA-Z0-9_-]/g, '_');
211
+ }
212
+
213
+ /**
214
+ * Lists every tool page with a per-request timeout, bounded against servers
215
+ * that repeat a cursor or never stop paginating. A server without the
216
+ * `tools` capability simply has no tools.
217
+ * See: https://github.com/anomalyco/opencode/pull/31442
218
+ * See: https://github.com/anomalyco/opencode/pull/31271
219
+ */
220
+ async function listTools(
221
+ client: Client,
222
+ timeout: number
223
+ ): Promise<Record<string, Tool>> {
224
+ const definitions: Awaited<ReturnType<Client['listTools']>>['tools'] = [];
225
+ const seen = new Set<string>();
226
+ let cursor: string | undefined;
227
+ for (let page = 0; page < MAX_LIST_PAGES; page++) {
228
+ const result = await client
229
+ .listTools({
230
+ params: cursor ? { cursor } : undefined,
231
+ options: { timeout },
232
+ })
233
+ .catch((error) => {
234
+ if (
235
+ error instanceof Error &&
236
+ error.message.includes('does not support tools')
237
+ )
238
+ return undefined;
239
+ throw error;
240
+ });
241
+ if (!result) return {};
242
+ definitions.push(...result.tools);
243
+ if (result.nextCursor == null)
244
+ return client.toolsFromDefinitions({ tools: definitions });
245
+ if (seen.has(result.nextCursor))
246
+ throw new Error(
247
+ `MCP server repeated tools/list cursor "${result.nextCursor}"`
248
+ );
249
+ seen.add(result.nextCursor);
250
+ cursor = result.nextCursor;
251
+ }
252
+ throw new Error(`MCP tools/list exceeded ${MAX_LIST_PAGES} pages`);
253
+ }
254
+
148
255
  export async function add(name: string, mcp: Config.Mcp) {
149
256
  const s = await state();
150
- const result = await create(name, mcp);
257
+ const result = await create(name, mcp).catch((error) => ({
258
+ mcpClient: undefined,
259
+ transport: undefined,
260
+ status: {
261
+ status: 'failed' as const,
262
+ error: error instanceof Error ? error.message : String(error),
263
+ },
264
+ }));
151
265
  if (!result) {
152
266
  const status = {
153
267
  status: 'failed' as const,
@@ -158,6 +272,7 @@ export namespace MCP {
158
272
  status,
159
273
  };
160
274
  }
275
+ s.listTimeouts[name] = mcp.timeout ?? DEFAULT_LIST_TIMEOUT;
161
276
  if (!result.mcpClient) {
162
277
  s.status[name] = result.status;
163
278
  return {
@@ -165,6 +280,7 @@ export namespace MCP {
165
280
  };
166
281
  }
167
282
  s.clients[name] = result.mcpClient;
283
+ if (result.transport) s.processes[name] = result.transport;
168
284
  s.status[name] = result.status;
169
285
 
170
286
  return {
@@ -172,6 +288,28 @@ export namespace MCP {
172
288
  };
173
289
  }
174
290
 
291
+ /**
292
+ * `initialize` gets a deadline and a transport that fails to come up is
293
+ * closed, so a hung server cannot stall startup or leak its process.
294
+ * See: https://github.com/anomalyco/opencode/pull/6760
295
+ */
296
+ async function connect(
297
+ transport: Parameters<typeof experimental_createMCPClient>[0]['transport'],
298
+ timeout: number
299
+ ) {
300
+ return experimental_createMCPClient({
301
+ name: 'opencode',
302
+ transport,
303
+ // 0 means "no bound" (the AI SDK would otherwise abort immediately).
304
+ initializationOptions: timeout > 0 ? { timeout } : undefined,
305
+ }).catch(async (error) => {
306
+ await (transport as { close?: () => Promise<void> })
307
+ .close?.()
308
+ .catch(() => {});
309
+ throw error;
310
+ });
311
+ }
312
+
175
313
  async function create(key: string, mcp: Config.Mcp) {
176
314
  if (mcp.enabled === false) {
177
315
  log.info(() => ({ message: 'mcp server disabled', key }));
@@ -179,33 +317,52 @@ export namespace MCP {
179
317
  }
180
318
  log.info(() => ({ message: 'found', key, type: mcp.type }));
181
319
  let mcpClient: MCPClient | undefined;
320
+ let stdio: StdioClientTransport | undefined;
182
321
  let status: Status | undefined = undefined;
322
+ // Bound on starting the server and `initialize` (main had none, upstream
323
+ // reuses `timeout`). `timeout` keeps its documented meaning (listing
324
+ // tools), so a short listing timeout does not start failing slow-starting
325
+ // servers, while a raised one also raises the default connect bound.
326
+ const connectTimeout =
327
+ mcp.connect_timeout ??
328
+ (config.mcpConnectTimeout > 0
329
+ ? Math.max(config.mcpConnectTimeout, mcp.timeout ?? 0)
330
+ : 0);
183
331
 
184
332
  if (mcp.type === 'remote') {
333
+ if (!URL.canParse(mcp.url)) {
334
+ return {
335
+ mcpClient: undefined,
336
+ transport: undefined,
337
+ status: {
338
+ status: 'failed' as const,
339
+ error: `Invalid MCP URL: ${mcp.url}`,
340
+ },
341
+ };
342
+ }
185
343
  const transports = [
186
344
  {
187
345
  name: 'StreamableHTTP',
188
- transport: new StreamableHTTPClientTransport(new URL(mcp.url), {
189
- requestInit: {
190
- headers: mcp.headers,
191
- },
192
- }),
346
+ transport: () =>
347
+ new StreamableHTTPClientTransport(new URL(mcp.url), {
348
+ requestInit: {
349
+ headers: mcp.headers,
350
+ },
351
+ }),
193
352
  },
194
353
  {
195
354
  name: 'SSE',
196
- transport: new SSEClientTransport(new URL(mcp.url), {
197
- requestInit: {
198
- headers: mcp.headers,
199
- },
200
- }),
355
+ transport: () =>
356
+ new SSEClientTransport(new URL(mcp.url), {
357
+ requestInit: {
358
+ headers: mcp.headers,
359
+ },
360
+ }),
201
361
  },
202
362
  ];
203
363
  let lastError: Error | undefined;
204
364
  for (const { name, transport } of transports) {
205
- const result = await experimental_createMCPClient({
206
- name: 'opencode',
207
- transport,
208
- })
365
+ const result = await connect(transport(), connectTimeout)
209
366
  .then((client) => {
210
367
  log.info(() => ({
211
368
  message: 'connected',
@@ -223,7 +380,7 @@ export namespace MCP {
223
380
  message: 'transport connection failed',
224
381
  key,
225
382
  transport: name,
226
- url: mcp.url,
383
+ url: sanitizeUrl(mcp.url),
227
384
  error: lastError.message,
228
385
  }));
229
386
  status = {
@@ -238,21 +395,20 @@ export namespace MCP {
238
395
 
239
396
  if (mcp.type === 'local') {
240
397
  const [cmd, ...args] = mcp.command;
241
- await experimental_createMCPClient({
242
- name: 'opencode',
243
- transport: new StdioClientTransport({
244
- stderr: 'ignore',
245
- command: cmd,
246
- args,
247
- env: {
248
- ...process.env,
249
- ...(cmd === 'opencode' ? { BUN_BE_BUN: '1' } : {}),
250
- ...mcp.environment,
251
- },
252
- }),
253
- })
398
+ const transport = new StdioClientTransport({
399
+ stderr: 'ignore',
400
+ command: cmd,
401
+ args,
402
+ env: {
403
+ ...process.env,
404
+ ...(cmd === 'opencode' ? { BUN_BE_BUN: '1' } : {}),
405
+ ...mcp.environment,
406
+ },
407
+ });
408
+ await connect(transport, connectTimeout)
254
409
  .then((client) => {
255
410
  mcpClient = client;
411
+ stdio = transport;
256
412
  status = {
257
413
  status: 'connected',
258
414
  };
@@ -281,37 +437,31 @@ export namespace MCP {
281
437
  if (!mcpClient) {
282
438
  return {
283
439
  mcpClient: undefined,
440
+ transport: undefined,
284
441
  status,
285
442
  };
286
443
  }
287
444
 
288
- const result = await withTimeout(
289
- mcpClient.tools(),
290
- mcp.timeout ?? 5000
445
+ const client: MCPClient = mcpClient;
446
+ const result = await listTools(
447
+ client,
448
+ mcp.timeout ?? DEFAULT_LIST_TIMEOUT
291
449
  ).catch((err) => {
292
450
  log.error(() => ({
293
451
  message: 'failed to get tools from client',
294
452
  key,
295
- error: err,
453
+ error: err instanceof Error ? err.message : String(err),
296
454
  }));
297
- return undefined;
455
+ return err instanceof Error ? err : new Error(String(err));
298
456
  });
299
- if (!result) {
300
- await mcpClient.close().catch((error) => {
301
- log.error(() => ({
302
- message: 'Failed to close MCP client',
303
- error,
304
- }));
305
- });
306
- status = {
307
- status: 'failed',
308
- error: 'Failed to get tools',
309
- };
457
+ if (result instanceof Error) {
458
+ await closeClient(client, stdio);
310
459
  return {
311
460
  mcpClient: undefined,
461
+ transport: undefined,
312
462
  status: {
313
463
  status: 'failed' as const,
314
- error: 'Failed to get tools',
464
+ error: `Failed to get tools: ${result.message}`,
315
465
  },
316
466
  };
317
467
  }
@@ -322,7 +472,8 @@ export namespace MCP {
322
472
  toolCount: Object.keys(result).length,
323
473
  }));
324
474
  return {
325
- mcpClient,
475
+ mcpClient: client,
476
+ transport: stdio,
326
477
  status,
327
478
  };
328
479
  }
@@ -338,33 +489,158 @@ export namespace MCP {
338
489
  export async function tools() {
339
490
  const result: Record<string, Tool> = {};
340
491
  const s = await state();
341
- const clientsSnapshot = await clients();
342
- for (const [clientName, client] of Object.entries(clientsSnapshot)) {
343
- const tools = await client.tools().catch((e) => {
344
- log.error(() => ({
345
- message: 'failed to get tools',
346
- clientName,
347
- error: e.message,
348
- }));
349
- const failedStatus = {
350
- status: 'failed' as const,
351
- error: e instanceof Error ? e.message : String(e),
352
- };
353
- s.status[clientName] = failedStatus;
354
- delete s.clients[clientName];
355
- });
356
- if (!tools) {
357
- continue;
358
- }
492
+ // Servers are listed in parallel, each under its own timeout, so one
493
+ // stalled server cannot hold up the others. A server that fails is closed
494
+ // (not just forgotten) and reported as failed.
495
+ // See: https://github.com/anomalyco/opencode/pull/13229
496
+ const listed = await Promise.all(
497
+ Object.entries(s.clients).map(async ([clientName, client]) => {
498
+ const tools = await listTools(
499
+ client,
500
+ s.listTimeouts[clientName] ?? DEFAULT_LIST_TIMEOUT
501
+ ).catch(async (e) => {
502
+ const error = e instanceof Error ? e.message : String(e);
503
+ log.error(() => ({
504
+ message: 'failed to get tools',
505
+ clientName,
506
+ error,
507
+ }));
508
+ s.status[clientName] = { status: 'failed' as const, error };
509
+ if (s.clients[clientName] === client) {
510
+ delete s.clients[clientName];
511
+ const transport = s.processes[clientName];
512
+ delete s.processes[clientName];
513
+ await closeClient(client, transport);
514
+ }
515
+ return undefined;
516
+ });
517
+ return [clientName, tools] as const;
518
+ })
519
+ );
520
+ for (const [clientName, tools] of listed) {
521
+ if (!tools) continue;
359
522
  for (const [toolName, tool] of Object.entries(tools)) {
360
- const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, '_');
361
- const sanitizedToolName = toolName.replace(/[^a-zA-Z0-9_-]/g, '_');
362
- result[sanitizedClientName + '_' + sanitizedToolName] = tool;
523
+ result[sanitize(clientName) + '_' + sanitize(toolName)] = tool;
363
524
  }
364
525
  }
365
526
  return result;
366
527
  }
367
528
 
529
+ export interface NormalizedResult {
530
+ output: string;
531
+ attachments: { mime: string; url: string; filename?: string }[];
532
+ content: unknown[];
533
+ isError: boolean;
534
+ metadata: Record<string, any>;
535
+ }
536
+
537
+ const ATTACHMENT_MIMES = new Set([
538
+ 'application/pdf',
539
+ 'image/gif',
540
+ 'image/jpeg',
541
+ 'image/png',
542
+ 'image/webp',
543
+ ]);
544
+ const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
545
+
546
+ /**
547
+ * Turns an MCP `tools/call` result into text and attachments. Results with
548
+ * no `content` (structured or legacy `toolResult`) and embedded resources
549
+ * used to reach the model as an empty string or crash the loop.
550
+ * See: https://github.com/anomalyco/opencode/pull/7879
551
+ * See: https://github.com/anomalyco/opencode/pull/32244
552
+ */
553
+ export function normalizeResult(result: any): NormalizedResult {
554
+ const content: any[] = Array.isArray(result?.content) ? result.content : [];
555
+ const texts: string[] = [];
556
+ const attachments: NormalizedResult['attachments'] = [];
557
+ if (content.length === 0) {
558
+ if (result?.structuredContent !== undefined)
559
+ texts.push(JSON.stringify(result.structuredContent));
560
+ else if (result && 'toolResult' in result)
561
+ texts.push(JSON.stringify(result.toolResult));
562
+ }
563
+ for (const item of content) {
564
+ if (item?.type === 'text') {
565
+ texts.push(item.text ?? '');
566
+ } else if (item?.type === 'image') {
567
+ attachments.push({
568
+ mime: item.mimeType,
569
+ url: `data:${item.mimeType};base64,${item.data}`,
570
+ });
571
+ } else if (item?.type === 'resource' && item.resource) {
572
+ const resource = item.resource;
573
+ if (typeof resource.text === 'string') {
574
+ texts.push(resource.text);
575
+ continue;
576
+ }
577
+ if (typeof resource.blob !== 'string') continue;
578
+ const mime = resource.mimeType ?? 'application/octet-stream';
579
+ const bytes = Math.floor((resource.blob.length * 3) / 4);
580
+ if (!ATTACHMENT_MIMES.has(mime)) {
581
+ texts.push(
582
+ `[Binary MCP resource omitted: ${resource.uri} (${mime}) is not a supported attachment type]`
583
+ );
584
+ } else if (bytes > MAX_ATTACHMENT_BYTES) {
585
+ texts.push(
586
+ `[Binary MCP resource omitted: ${resource.uri} (${mime}) is larger than 10 MB]`
587
+ );
588
+ } else {
589
+ attachments.push({
590
+ mime,
591
+ url: `data:${mime};base64,${resource.blob}`,
592
+ filename: resource.uri,
593
+ });
594
+ }
595
+ } else if (item?.type === 'resource_link') {
596
+ texts.push(
597
+ `Resource: ${[item.name, item.uri].filter(Boolean).join(' ')}`
598
+ );
599
+ }
600
+ }
601
+ const isError = result?.isError === true;
602
+ const output = texts.join('\n\n');
603
+ return {
604
+ output:
605
+ isError && output.trim() === '' ? 'MCP tool returned an error' : output,
606
+ attachments,
607
+ content,
608
+ isError,
609
+ metadata: result?.metadata ?? {},
610
+ };
611
+ }
612
+
613
+ /**
614
+ * Runs an MCP tool call under `timeout`. The request is given its own abort
615
+ * signal (linked to the caller's), which is aborted when the timeout fires,
616
+ * so a timed-out call no longer keeps running in the background.
617
+ */
618
+ export async function callWithTimeout<T>(
619
+ key: string,
620
+ run: (abortSignal: AbortSignal) => PromiseLike<T>,
621
+ timeout: number,
622
+ parent?: AbortSignal
623
+ ): Promise<T> {
624
+ const controller = new AbortController();
625
+ const signal = parent
626
+ ? AbortSignal.any([parent, controller.signal])
627
+ : controller.signal;
628
+ try {
629
+ return await withTimeout(Promise.resolve(run(signal)), timeout);
630
+ } catch (error) {
631
+ controller.abort();
632
+ if (error instanceof Error && error.message.includes('timed out after')) {
633
+ const timeoutSec = Math.round(timeout / 1000);
634
+ throw new Error(
635
+ `MCP tool "${key}" timed out after ${timeoutSec} seconds. ` +
636
+ `The operation did not complete within the configured timeout. ` +
637
+ `You can increase the timeout in the MCP server configuration using tool_call_timeout or tool_timeouts.`
638
+ );
639
+ }
640
+ throw error;
641
+ }
642
+ }
643
+
368
644
  /**
369
645
  * Get the timeout configuration for all MCP servers
370
646
  */
@@ -392,40 +668,28 @@ export namespace MCP {
392
668
  const s = await state();
393
669
  const maxTimeout = s.globalDefaults.maxTimeout;
394
670
 
395
- // Parse the full tool name to extract server name and tool name
396
- // Format: serverName_toolName (where both are sanitized)
397
- const underscoreIndex = fullToolName.indexOf('_');
398
- if (underscoreIndex === -1) {
399
- return s.globalDefaults.defaultTimeout;
400
- }
401
-
402
- const serverName = fullToolName.substring(0, underscoreIndex);
403
- const toolName = fullToolName.substring(underscoreIndex + 1);
404
-
405
- // Find the server config (need to handle sanitization)
406
- const config = s.timeoutConfigs[serverName];
407
- if (!config) {
408
- // Try to find by iterating (in case of sanitization differences)
409
- for (const [key, cfg] of Object.entries(s.timeoutConfigs)) {
410
- const sanitizedKey = key.replace(/[^a-zA-Z0-9_-]/g, '_');
411
- if (sanitizedKey === serverName) {
412
- // Check for per-tool timeout override
413
- const perToolTimeout = cfg.toolTimeouts[toolName];
414
- if (perToolTimeout !== undefined) {
415
- return Math.min(perToolTimeout, maxTimeout);
416
- }
417
- return cfg.defaultTimeout;
418
- }
419
- }
420
- return s.globalDefaults.defaultTimeout;
671
+ // Format: serverName_toolName (both sanitized). Server names may contain
672
+ // `_` themselves, so the longest matching server prefix wins.
673
+ let match: { prefix: string; config: TimeoutConfig } | undefined;
674
+ for (const [key, cfg] of Object.entries(s.timeoutConfigs)) {
675
+ const prefix = sanitize(key) + '_';
676
+ if (!fullToolName.startsWith(prefix)) continue;
677
+ if (match && match.prefix.length >= prefix.length) continue;
678
+ match = { prefix, config: cfg };
421
679
  }
680
+ if (!match) return s.globalDefaults.defaultTimeout;
422
681
 
423
682
  // Check for per-tool timeout override
424
- const perToolTimeout = config.toolTimeouts[toolName];
683
+ const toolName = fullToolName.slice(match.prefix.length);
684
+ const perToolTimeout =
685
+ match.config.toolTimeouts[toolName] ??
686
+ Object.entries(match.config.toolTimeouts).find(
687
+ ([name]) => sanitize(name) === toolName
688
+ )?.[1];
425
689
  if (perToolTimeout !== undefined) {
426
690
  return Math.min(perToolTimeout, maxTimeout);
427
691
  }
428
692
 
429
- return config.defaultTimeout;
693
+ return match.config.defaultTimeout;
430
694
  }
431
695
  }
@@ -391,6 +391,28 @@ export namespace Permission {
391
391
  });
392
392
  }
393
393
 
394
+ /**
395
+ * Rejects and drops the pending asks of a session. Called when its turn is
396
+ * cancelled, so an aborted turn does not leave a pending promise (and its
397
+ * tool call) behind until the instance is disposed (issue #335).
398
+ */
399
+ export function clearSession(sessionID: Info['sessionID']) {
400
+ const { pending } = state();
401
+ const items = pending[sessionID];
402
+ if (!items) return;
403
+ delete pending[sessionID];
404
+ for (const item of Object.values(items)) {
405
+ item.reject(
406
+ new RejectedError(
407
+ item.info.sessionID,
408
+ item.info.id,
409
+ item.info.callID,
410
+ item.info.metadata
411
+ )
412
+ );
413
+ }
414
+ }
415
+
394
416
  export function respond(input: {
395
417
  sessionID: Info['sessionID'];
396
418
  permissionID: Info['id'];
@@ -57,7 +57,11 @@ export const Instance = {
57
57
  },
58
58
  async dispose() {
59
59
  Log.Default.info('disposing instance', { directory: Instance.directory });
60
- await State.dispose(Instance.directory);
60
+ const directory = Instance.directory;
61
+ await State.dispose(directory);
62
+ // Drop the resolved context too; it used to stay cached for every
63
+ // directory ever served (issue #335).
64
+ cache.delete(directory);
61
65
  await Project.prune();
62
66
  },
63
67
  async disposeAll() {