shortcutxl 0.3.89 → 0.3.91

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 (97) hide show
  1. package/BINARY-INVENTORY.json +13 -13
  2. package/CHANGELOG.md +7 -1
  3. package/dist/ai/types.d.ts +2 -0
  4. package/dist/app/connectors/secret-store.d.ts +8 -0
  5. package/dist/app/connectors/secret-store.js +73 -1
  6. package/dist/app/local/tools/execute-code/approval-payload.d.ts +5 -1
  7. package/dist/app/local/tools/execute-code/executor.d.ts +4 -15
  8. package/dist/app/local/tools/execute-code/executor.js +56 -146
  9. package/dist/app/local/tools/execute-code/observability.js +0 -20
  10. package/dist/app/local/tools/execute-code/tool.js +0 -17
  11. package/dist/app/local/tools/execute-code/write-analyzer.js +2 -0
  12. package/dist/app/local/tools/take-screenshot/com-screenshot.js +8 -1
  13. package/dist/app/local/tools/take-screenshot/mog-screenshot.d.ts +0 -1
  14. package/dist/app/local/tools/take-screenshot/mog-screenshot.js +10 -17
  15. package/dist/app/mcp/auth-status.d.ts +2 -0
  16. package/dist/app/mcp/auth-status.js +5 -1
  17. package/dist/app/mcp/connection.d.ts +32 -2
  18. package/dist/app/mcp/connection.js +179 -83
  19. package/dist/app/mcp/index.js +44 -9
  20. package/dist/app/mcp/types.d.ts +3 -1
  21. package/dist/app/modes/ask/prompt.js +1 -1
  22. package/dist/app/modes/manage/prompt.js +1 -1
  23. package/dist/app/providers/model-registry.d.ts +1 -0
  24. package/dist/app/providers/model-registry.js +1 -0
  25. package/dist/app/providers/model-resolver.js +1 -0
  26. package/dist/app/session/session-models.js +2 -1
  27. package/dist/app/tools/take-screenshot.d.ts +3 -0
  28. package/dist/app/tools/take-screenshot.js +5 -2
  29. package/dist/cli.js +965 -509
  30. package/dist/contracts/agent-api.d.ts +4 -4
  31. package/dist/contracts/agent-api.js +4 -4
  32. package/dist/contracts/agent-snapshot.d.ts +14 -0
  33. package/dist/contracts/agent-snapshot.js +6 -0
  34. package/dist/core/agent-snapshot-builder.js +6 -2
  35. package/dist/core/prompts/agent-guidelines.d.ts +1 -1
  36. package/dist/core/prompts/agent-guidelines.js +1 -1
  37. package/dist/core/prompts/take-screenshot.d.ts +1 -1
  38. package/dist/core/prompts/take-screenshot.js +1 -0
  39. package/dist/core/session/compaction/compaction.d.ts +1 -1
  40. package/dist/core/session/compaction/compaction.js +2 -2
  41. package/dist/core/session/compaction/constants.d.ts +4 -3
  42. package/dist/core/session/compaction/constants.js +5 -4
  43. package/dist/core/session/session-compaction.js +6 -4
  44. package/dist/embedded-agent/agent-event-funnel.d.ts +0 -2
  45. package/dist/embedded-agent/agent-event-funnel.js +0 -3
  46. package/dist/embedded-agent/compaction-wiring.d.ts +2 -2
  47. package/dist/embedded-agent/compaction-wiring.js +44 -22
  48. package/dist/embedded-agent/compose.js +6 -5
  49. package/dist/embedded-agent/host-tools/mcp/contract.d.ts +12 -0
  50. package/dist/embedded-agent/host-tools/mcp/contract.js +20 -1
  51. package/dist/embedded-agent/host-tools/mcp/index.d.ts +1 -1
  52. package/dist/embedded-agent/host-tools/mcp/index.js +1 -1
  53. package/dist/embedded-agent/host-tools/take-screenshot/contract.d.ts +1 -1
  54. package/dist/embedded-agent/model-registry.d.ts +2 -0
  55. package/dist/embedded-agent/model-registry.js +2 -0
  56. package/dist/embedded-agent/prompt/build.d.ts +0 -1
  57. package/dist/embedded-agent/prompt/build.js +0 -1
  58. package/dist/embedded-agent/provider-fast-mode.js +7 -4
  59. package/dist/embedded-agent/recovery-wiring.d.ts +2 -0
  60. package/dist/embedded-agent/recovery-wiring.js +1 -13
  61. package/dist/main.js +34 -3
  62. package/dist/model-display.js +1 -1
  63. package/dist/model-ids.d.ts +7 -1
  64. package/dist/model-ids.js +14 -2
  65. package/dist/rpc/rpc-host.d.ts +3 -1
  66. package/dist/rpc/rpc-host.js +4 -1
  67. package/dist/rpc/rpc-mode.d.ts +1 -1
  68. package/dist/rpc/rpc-types.d.ts +11 -0
  69. package/dist/shortcut-model-catalog.d.ts +2 -1
  70. package/dist/shortcut-model-catalog.js +49 -10
  71. package/dist/subagent-model-policy.js +1 -0
  72. package/dist/utils/api-reference.d.ts +0 -2
  73. package/dist/utils/api-reference.js +21 -5
  74. package/dist/utils/mog-headless-api-reference.js +0 -1
  75. package/native-app/main/index.js +2 -2
  76. package/native-app/package.json +1 -1
  77. package/native-app/renderer/assets/{index-SuRESuDS.js → index-B4oy3UFe.js} +90 -24
  78. package/native-app/renderer/index.html +1 -1
  79. package/package.json +6 -1
  80. package/skills/mcp/SKILL.md +2 -2
  81. package/user-docs/dist/index.html +14 -26
  82. package/user-docs/security.md +4 -9
  83. package/xll/modules/shortcut_xl/_exec_entry.py +19 -4
  84. package/xll/modules/shortcut_xl/_exec_sandbox.py +2 -2
  85. package/xll/modules/shortcut_xl/_log.py +9 -2
  86. package/xll/modules/shortcut_xl/_threading.py +1 -1
  87. package/xll/python/Lib/site-packages/httpx-0.28.1.dist-info/RECORD +1 -1
  88. package/xll/python/Lib/site-packages/idna-3.18.dist-info/RECORD +1 -1
  89. package/xll/python/Lib/site-packages/pip-26.2.dist-info/RECORD +3 -3
  90. package/xll/python/Lib/site-packages/pywin32-311.dist-info/RECORD +2 -2
  91. package/xll/python/Scripts/httpx.exe +0 -0
  92. package/xll/python/Scripts/idna.exe +0 -0
  93. package/xll/python/Scripts/pip.exe +0 -0
  94. package/xll/python/Scripts/pip3.13.exe +0 -0
  95. package/xll/python/Scripts/pip3.exe +0 -0
  96. package/xll/python/Scripts/pywin32_postinstall.exe +0 -0
  97. package/xll/python/Scripts/pywin32_testall.exe +0 -0
@@ -13,6 +13,7 @@ async function captureComScreenshot(host, range, signal) {
13
13
  headers: { 'Content-Type': 'application/json' },
14
14
  body: JSON.stringify({
15
15
  control: 'screenshot',
16
+ workbook: range.workbook,
16
17
  sheet: range.sheet,
17
18
  range: range.cell_range
18
19
  }),
@@ -43,6 +44,7 @@ export async function captureComScreenshots(ranges, signal, resolveTarget) {
43
44
  const msg = error instanceof Error ? error.message : String(error);
44
45
  console.error(`[take_screenshot] COM capture unavailable: ${msg}`);
45
46
  return ranges.map((range) => ({
47
+ workbook: range.workbook,
46
48
  sheet: range.sheet,
47
49
  cell_range: range.cell_range,
48
50
  data: null
@@ -58,7 +60,12 @@ export async function captureComScreenshots(ranges, signal, resolveTarget) {
58
60
  const msg = error instanceof Error ? error.message : String(error);
59
61
  console.error(`[take_screenshot] COM capture failed for ${range.sheet}!${range.cell_range}: ${msg}`);
60
62
  }
61
- screenshots.push({ sheet: range.sheet, cell_range: range.cell_range, data });
63
+ screenshots.push({
64
+ workbook: range.workbook,
65
+ sheet: range.sheet,
66
+ cell_range: range.cell_range,
67
+ data
68
+ });
62
69
  }
63
70
  return screenshots;
64
71
  }
@@ -1,6 +1,5 @@
1
1
  import type { Screenshot, ScreenshotRange } from '../../../tools/take-screenshot.js';
2
2
  interface MogSheetPoolWithScreenshots {
3
- activeWorkbookName: string | null | undefined;
4
3
  getWorkbook(name: string): unknown;
5
4
  }
6
5
  export declare function captureMogScreenshots(pool: MogSheetPoolWithScreenshots, ranges: ScreenshotRange[], _signal?: AbortSignal): Promise<Screenshot[]>;
@@ -4,26 +4,14 @@ function isMogWorkbookWithScreenshots(value) {
4
4
  typeof value.captureScreenshot === 'function');
5
5
  }
6
6
  export async function captureMogScreenshots(pool, ranges, _signal) {
7
- if (!pool.activeWorkbookName) {
8
- return ranges.map((range) => ({
9
- sheet: range.sheet,
10
- cell_range: range.cell_range,
11
- data: null
12
- }));
13
- }
14
- const workbook = pool.getWorkbook(pool.activeWorkbookName);
15
- if (!isMogWorkbookWithScreenshots(workbook)) {
16
- console.error('[take_screenshot] Active Mog workbook does not support screenshot capture');
17
- return ranges.map((range) => ({
18
- sheet: range.sheet,
19
- cell_range: range.cell_range,
20
- data: null
21
- }));
22
- }
23
7
  const screenshots = [];
24
8
  for (const range of ranges) {
25
9
  let data = null;
26
10
  try {
11
+ const workbook = pool.getWorkbook(range.workbook);
12
+ if (!isMogWorkbookWithScreenshots(workbook)) {
13
+ throw new Error(`Workbook does not support screenshot capture: ${range.workbook}`);
14
+ }
27
15
  const pngBuffer = await workbook.captureScreenshot(range.sheet, range.cell_range);
28
16
  data = Buffer.from(pngBuffer).toString('base64');
29
17
  }
@@ -31,7 +19,12 @@ export async function captureMogScreenshots(pool, ranges, _signal) {
31
19
  const msg = error instanceof Error ? error.message : String(error);
32
20
  console.error(`[take_screenshot] Mog capture failed for ${range.sheet}!${range.cell_range}: ${msg}`);
33
21
  }
34
- screenshots.push({ sheet: range.sheet, cell_range: range.cell_range, data });
22
+ screenshots.push({
23
+ workbook: range.workbook,
24
+ sheet: range.sheet,
25
+ cell_range: range.cell_range,
26
+ data
27
+ });
35
28
  }
36
29
  return screenshots;
37
30
  }
@@ -6,6 +6,8 @@ export interface ResolveMcpAuthStatusOptions {
6
6
  authStorage?: AuthStorage;
7
7
  mcpOAuthStore?: McpOAuthStore;
8
8
  discovery?: McpAuthDiscoveryMode;
9
+ /** Pre-fetched connection secrets by name; when absent the keychain is read directly. */
10
+ connectionSecrets?: ReadonlyMap<string, Record<string, string> | undefined>;
9
11
  }
10
12
  export declare function resolveMcpAuthStatus(serverName: string, config: McpServerConfig, options?: ResolveMcpAuthStatusOptions): Promise<McpAuthStatus>;
11
13
  export declare function getMcpAuthStatus(serverName: string, config: McpServerConfig, authStorage?: AuthStorage, mcpOAuthStore?: McpOAuthStore): Promise<McpAuthStatus>;
@@ -10,7 +10,11 @@ export async function resolveMcpAuthStatus(serverName, config, options = {}) {
10
10
  return 'unsupported';
11
11
  }
12
12
  const staticHeaders = { ...(config.headers ?? {}) };
13
- const secretHeaders = config.connection ? getSecret(config.connection) : undefined;
13
+ const secretHeaders = !config.connection
14
+ ? undefined
15
+ : options.connectionSecrets
16
+ ? options.connectionSecrets.get(config.connection)
17
+ : getSecret(config.connection);
14
18
  if (hasConfiguredHeaders(staticHeaders) || hasConfiguredHeaders(secretHeaders)) {
15
19
  return 'configured';
16
20
  }
@@ -4,17 +4,47 @@
4
4
  import type { AuthStorage } from '../auth/auth-storage.js';
5
5
  import type { McpOAuthStore } from './oauth-state-store.js';
6
6
  import { type McpConnection, type McpFailureKind, type McpRequestPolicy, type McpServerConfig } from './types.js';
7
+ /** Abort signal plus absolute deadline that bounds one server's whole startup. */
8
+ export interface McpStartupBudget {
9
+ signal: AbortSignal;
10
+ deadlineAt: number;
11
+ }
12
+ export interface McpConnectOptions {
13
+ /**
14
+ * Connection secrets already read from the keychain, keyed by connection
15
+ * name. When absent, the server reads its own secret synchronously.
16
+ */
17
+ connectionSecrets?: ReadonlyMap<string, Record<string, string> | undefined>;
18
+ /** Budget shared across the whole startup. Direct callers get one from the config's startup timeout. */
19
+ budget?: McpStartupBudget;
20
+ }
7
21
  export declare class McpConnectionError extends Error {
8
22
  readonly failureKind: McpFailureKind;
9
23
  constructor(failureKind: McpFailureKind, message: string);
10
24
  }
11
25
  export declare function resolveStartupTimeoutMs(config: McpServerConfig): number;
12
26
  export declare function buildRequestPolicy(config: McpServerConfig): McpRequestPolicy;
27
+ /**
28
+ * Run one server's startup under a single deadline. The budget's signal is
29
+ * threaded into every SDK request so a stalled initialize or tools/list is
30
+ * cancelled, and the race guarantees the deadline even for steps that cannot
31
+ * observe the signal (process spawn, keychain reads).
32
+ */
33
+ export declare function withStartupBudget<T>(name: string, timeoutMs: number, operation: (budget: McpStartupBudget) => Promise<T>): Promise<T>;
34
+ /** SDK request options for initialize and tools/list under a startup budget. */
35
+ export declare function requestOptions(budget: McpStartupBudget): {
36
+ signal: AbortSignal;
37
+ timeout: number;
38
+ };
39
+ /** Whether a failed hosted startup is safe to repeat with a fresh MCP session. */
40
+ export declare function isTransientHostedStartupError(error: unknown): boolean;
13
41
  export declare function createHostedMcpLoggingFetch(serverName: string, fetchImpl?: typeof fetch): typeof fetch;
14
42
  /**
15
- * Connect a server, list tools, and return a live MCP connection.
43
+ * Connect a server, list tools, and return a live MCP connection. The whole
44
+ * sequence runs under `options.budget`, or under a fresh budget from the
45
+ * config's startup timeout when the caller did not supply one.
16
46
  */
17
- export declare function connectServer(name: string, config: McpServerConfig, defaultCwd: string, authStorage?: AuthStorage, mcpOAuthStore?: McpOAuthStore): Promise<McpConnection>;
47
+ export declare function connectServer(name: string, config: McpServerConfig, defaultCwd: string, authStorage?: AuthStorage, mcpOAuthStore?: McpOAuthStore, options?: McpConnectOptions): Promise<McpConnection>;
18
48
  /**
19
49
  * Call a tool on a connected server. Returns the result text or throws.
20
50
  */
@@ -10,16 +10,40 @@ import { mkdir, writeFile } from 'fs/promises';
10
10
  import { basename, extname, join } from 'path';
11
11
  import { getAgentTempDir } from '../../config.js';
12
12
  import { log } from '../../shared/logging/agent-log.js';
13
+ import { sleep } from '../../utils/sleep.js';
13
14
  import { getSecret } from '../connectors/secret-store.js';
14
15
  import { getHostedMcpOAuthProviderId } from './hosted-oauth-id.js';
15
16
  import { createHostedMcpRuntimeAuthProvider } from './hosted-oauth-provider.js';
16
17
  import { resolveHostedMcpOAuthTimeoutMs, supportsHostedMcpOAuth } from './oauth-discovery.js';
17
18
  import { withHostedMcpProxyFetchOptions } from './proxy-fetch.js';
18
19
  import { isHostedServerConfig } from './types.js';
19
- const DEFAULT_STARTUP_TIMEOUT_MS = 30_000;
20
+ /**
21
+ * Default budget for one server's whole startup: credential lookup, connect and
22
+ * initialize, OAuth refresh, and tool discovery. Managed Windows fleets behind
23
+ * proxies and EDR routinely take 60-90s here, so the budget is generous while
24
+ * still sitting under the 180s RPC readiness budget in `rpc-client.ts`.
25
+ */
26
+ const DEFAULT_STARTUP_TIMEOUT_MS = 120_000;
20
27
  const DEFAULT_TOOL_CALL_TIMEOUT_MS = 600_000;
21
28
  const DEFAULT_RESET_TIMEOUT_ON_PROGRESS = true;
22
29
  const MCP_OUTPUT_DIR_NAME = 'mcp';
30
+ const HOSTED_STARTUP_RETRY_DELAY_MS = 250;
31
+ const TRANSIENT_NETWORK_ERROR_CODES = new Set([
32
+ 'EAI_AGAIN',
33
+ 'ECONNABORTED',
34
+ 'ECONNREFUSED',
35
+ 'ECONNRESET',
36
+ 'EHOSTUNREACH',
37
+ 'ENETUNREACH',
38
+ 'ENOTFOUND',
39
+ 'EPIPE',
40
+ 'ETIMEDOUT',
41
+ 'UND_ERR_BODY_TIMEOUT',
42
+ 'UND_ERR_CONNECT_TIMEOUT',
43
+ 'UND_ERR_HEADERS_TIMEOUT',
44
+ 'UND_ERR_SOCKET'
45
+ ]);
46
+ const TRANSIENT_HTTP_STATUS_CODES = new Set([408, 425, 429, 500, 502, 503, 504]);
23
47
  function isRecord(value) {
24
48
  return typeof value === 'object' && value !== null;
25
49
  }
@@ -231,14 +255,68 @@ export function buildRequestPolicy(config) {
231
255
  : {})
232
256
  };
233
257
  }
234
- async function buildHostedHeaders(name, config) {
258
+ /**
259
+ * Run one server's startup under a single deadline. The budget's signal is
260
+ * threaded into every SDK request so a stalled initialize or tools/list is
261
+ * cancelled, and the race guarantees the deadline even for steps that cannot
262
+ * observe the signal (process spawn, keychain reads).
263
+ */
264
+ export async function withStartupBudget(name, timeoutMs, operation) {
265
+ const controller = new AbortController();
266
+ const timeoutError = new McpConnectionError('startup_timeout', `MCP server '${name}' did not finish connection and tool discovery within ${timeoutMs / 1000}s.`);
267
+ const expired = new Promise((_, reject) => {
268
+ controller.signal.addEventListener('abort', () => reject(timeoutError), { once: true });
269
+ });
270
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
271
+ if (typeof timer === 'object' && 'unref' in timer)
272
+ timer.unref();
273
+ try {
274
+ return await Promise.race([
275
+ operation({ signal: controller.signal, deadlineAt: Date.now() + timeoutMs }),
276
+ expired
277
+ ]);
278
+ }
279
+ catch (error) {
280
+ // The SDK surfaces our abort as its own error; report the budget instead.
281
+ throw controller.signal.aborted ? timeoutError : error;
282
+ }
283
+ finally {
284
+ clearTimeout(timer);
285
+ }
286
+ }
287
+ /**
288
+ * The SDK arms its own per-request timer (60s when none is given), so it cannot
289
+ * be the budget's timekeeper: two timers set to the same instant race, and the
290
+ * SDK's raw "Request timed out" would sometimes win. The budget's abort signal
291
+ * is the single source of truth; the SDK timeout only sits above it so the
292
+ * default 60s never cuts a longer budget short.
293
+ */
294
+ const SDK_TIMEOUT_SLACK_MS = 1_000;
295
+ /** SDK request options for initialize and tools/list under a startup budget. */
296
+ export function requestOptions(budget) {
297
+ return {
298
+ signal: budget.signal,
299
+ timeout: Math.max(1, budget.deadlineAt - Date.now()) + SDK_TIMEOUT_SLACK_MS
300
+ };
301
+ }
302
+ /** Secret for `config.connection`, from the pre-fetched map when the manager supplied one. */
303
+ function resolveConnectionSecret(name, connection, options) {
304
+ const secret = options.connectionSecrets
305
+ ? options.connectionSecrets.get(connection)
306
+ : getSecret(connection);
307
+ if (!secret) {
308
+ throw new Error(`MCP server '${name}' references connection '${connection}' but no credentials found in keychain. Run /connect to set it up.`);
309
+ }
310
+ return secret;
311
+ }
312
+ /** Best-effort teardown after a failed startup; a stdio close waits on the child, so never block on it. */
313
+ function discardClient(client) {
314
+ void client.close().catch(() => { });
315
+ }
316
+ function buildHostedHeaders(name, config, options) {
235
317
  const headers = { ...(config.headers ?? {}) };
236
318
  if (config.connection) {
237
- const secrets = getSecret(config.connection);
238
- if (!secrets) {
239
- throw new Error(`MCP server '${name}' references connection '${config.connection}' but no credentials found in keychain. Run /connect to set it up.`);
240
- }
241
- Object.assign(headers, secrets);
319
+ Object.assign(headers, resolveConnectionSecret(name, config.connection, options));
242
320
  }
243
321
  return headers;
244
322
  }
@@ -354,6 +432,32 @@ function summarizeErrorForLog(error, depth = 0) {
354
432
  }
355
433
  return details;
356
434
  }
435
+ /** Whether a failed hosted startup is safe to repeat with a fresh MCP session. */
436
+ export function isTransientHostedStartupError(error) {
437
+ if (error instanceof UnauthorizedError || error instanceof McpConnectionError) {
438
+ return false;
439
+ }
440
+ if (error instanceof StreamableHTTPError &&
441
+ typeof error.code === 'number' &&
442
+ TRANSIENT_HTTP_STATUS_CODES.has(error.code)) {
443
+ return true;
444
+ }
445
+ let current = error;
446
+ for (let depth = 0; depth < 4; depth += 1) {
447
+ if (current === null || (typeof current !== 'object' && typeof current !== 'function')) {
448
+ return false;
449
+ }
450
+ const record = current;
451
+ if (record.name === 'AbortError')
452
+ return false;
453
+ if (typeof record.code === 'string' &&
454
+ TRANSIENT_NETWORK_ERROR_CODES.has(record.code.toUpperCase())) {
455
+ return true;
456
+ }
457
+ current = record.cause;
458
+ }
459
+ return false;
460
+ }
357
461
  async function readResponseSnippet(response) {
358
462
  try {
359
463
  const text = await response.clone().text();
@@ -383,10 +487,33 @@ export function createHostedMcpLoggingFetch(serverName, fetchImpl = fetch) {
383
487
  return response;
384
488
  };
385
489
  }
386
- async function connectHostedServer(name, config, authStorage, mcpOAuthStore, retriedAfterOAuthRefresh = false) {
387
- const startupTimeoutMs = resolveStartupTimeoutMs(config);
490
+ /** Discover the server's tools inside the budget, releasing the client if discovery fails. */
491
+ async function discoverTools(name, client, budget) {
492
+ let toolsResult;
493
+ try {
494
+ budget.signal.throwIfAborted();
495
+ toolsResult = await client.listTools(undefined, requestOptions(budget));
496
+ }
497
+ catch (error) {
498
+ discardClient(client);
499
+ throw error;
500
+ }
501
+ const tools = (toolsResult.tools ?? []).map((t) => ({
502
+ name: t.name,
503
+ description: t.description,
504
+ parameterNames: t.inputSchema?.properties
505
+ ? Object.keys(t.inputSchema.properties)
506
+ : [],
507
+ inputSchema: (t.inputSchema ?? {})
508
+ }));
509
+ if (tools.length === 0) {
510
+ log.info('mcp', `MCP server '${name}' connected but exposed 0 tools.`);
511
+ }
512
+ return tools;
513
+ }
514
+ async function connectHostedServerAttempt(name, config, authStorage, mcpOAuthStore, options, budget, retriedAfterOAuthRefresh = false) {
388
515
  const requestPolicy = buildRequestPolicy(config);
389
- const headers = await buildHostedHeaders(name, config);
516
+ const headers = buildHostedHeaders(name, config, options);
390
517
  const authProvider = authStorage &&
391
518
  mcpOAuthStore &&
392
519
  !hasAuthorizationHeader(headers) &&
@@ -399,25 +526,12 @@ async function connectHostedServer(name, config, authStorage, mcpOAuthStore, ret
399
526
  authProvider
400
527
  });
401
528
  const client = new Client({ name: 'shortcut-agent', version: '1.0.0' }, { capabilities: {} });
402
- let timer;
403
- const connectPromise = client.connect(transport);
404
- const timeoutPromise = new Promise((_, reject) => {
405
- timer = setTimeout(() => reject(new Error(`did not respond within ${startupTimeoutMs / 1000}s`)), startupTimeoutMs);
406
- if (typeof timer === 'object' && 'unref' in timer)
407
- timer.unref();
408
- });
409
529
  try {
410
- await Promise.race([connectPromise, timeoutPromise]);
411
- if (timer)
412
- clearTimeout(timer);
530
+ await client.connect(transport, requestOptions(budget));
413
531
  }
414
532
  catch (err) {
415
- try {
416
- await client.close();
417
- }
418
- catch {
419
- // Ignore close errors during cleanup.
420
- }
533
+ discardClient(client);
534
+ budget.signal.throwIfAborted();
421
535
  if (err instanceof UnauthorizedError ||
422
536
  (err instanceof StreamableHTTPError && err.code === 401)) {
423
537
  const providerId = getHostedMcpOAuthProviderId(name, config);
@@ -426,12 +540,13 @@ async function connectHostedServer(name, config, authStorage, mcpOAuthStore, ret
426
540
  !retriedAfterOAuthRefresh &&
427
541
  authStorage.get(providerId)?.type === 'oauth') {
428
542
  const refreshedToken = await authStorage.getApiKey(providerId, { forceRefresh: true });
543
+ budget.signal.throwIfAborted();
429
544
  if (refreshedToken) {
430
- return connectHostedServer(name, config, authStorage, mcpOAuthStore, true);
545
+ return connectHostedServerAttempt(name, config, authStorage, mcpOAuthStore, options, budget, true);
431
546
  }
432
547
  }
433
548
  const supportsOAuth = await supportsHostedMcpOAuth(name, config, mcpOAuthStore, {
434
- timeoutMs: resolveHostedMcpOAuthTimeoutMs(config)
549
+ timeoutMs: Math.min(resolveHostedMcpOAuthTimeoutMs(config), requestOptions(budget).timeout)
435
550
  }).catch(() => !!config.oauth);
436
551
  const providerName = config.oauth?.providerName ?? `MCP: ${name}`;
437
552
  throw new McpConnectionError('auth_required', supportsOAuth
@@ -440,31 +555,32 @@ async function connectHostedServer(name, config, authStorage, mcpOAuthStore, ret
440
555
  }
441
556
  throw err;
442
557
  }
443
- const toolsResult = await client.listTools();
444
- const tools = (toolsResult.tools ?? []).map((t) => ({
445
- name: t.name,
446
- description: t.description,
447
- parameterNames: t.inputSchema?.properties
448
- ? Object.keys(t.inputSchema.properties)
449
- : [],
450
- inputSchema: (t.inputSchema ?? {})
451
- }));
452
- if (tools.length === 0) {
453
- log.info('mcp', `Hosted MCP server '${name}' connected but exposed 0 tools.`);
454
- }
558
+ const tools = await discoverTools(name, client, budget);
455
559
  return { name, client, transport, tools, requestPolicy };
456
560
  }
457
- async function connectStdioServer(name, config, defaultCwd) {
458
- const startupTimeoutMs = resolveStartupTimeoutMs(config);
459
- const requestPolicy = buildRequestPolicy(config);
460
- let serverEnv = { ...process.env, ...(config.env ?? {}) };
461
- if (config.connection) {
462
- const secrets = getSecret(config.connection);
463
- if (!secrets) {
464
- throw new Error(`MCP server '${name}' references connection '${config.connection}' but no credentials found in keychain. Run /connect to set it up.`);
561
+ /** Retry transient hosted startup failures with a fresh client and transport each time. */
562
+ async function connectHostedServer(name, config, authStorage, mcpOAuthStore, options, budget) {
563
+ try {
564
+ return await connectHostedServerAttempt(name, config, authStorage, mcpOAuthStore, options, budget);
565
+ }
566
+ catch (error) {
567
+ budget.signal.throwIfAborted();
568
+ if (!isTransientHostedStartupError(error) ||
569
+ Date.now() + HOSTED_STARTUP_RETRY_DELAY_MS >= budget.deadlineAt) {
570
+ throw error;
465
571
  }
466
- serverEnv = { ...serverEnv, ...secrets };
572
+ log.warn('mcp', `MCP server '${name}' had a transient startup failure; retrying once with a fresh session. error=${JSON.stringify(summarizeErrorForLog(error))}`);
573
+ await sleep(HOSTED_STARTUP_RETRY_DELAY_MS, budget.signal);
574
+ return connectHostedServerAttempt(name, config, authStorage, mcpOAuthStore, options, budget);
467
575
  }
576
+ }
577
+ async function connectStdioServer(name, config, defaultCwd, options, budget) {
578
+ const requestPolicy = buildRequestPolicy(config);
579
+ const serverEnv = {
580
+ ...process.env,
581
+ ...(config.env ?? {}),
582
+ ...(config.connection ? resolveConnectionSecret(name, config.connection, options) : {})
583
+ };
468
584
  const transport = new StdioClientTransport({
469
585
  command: config.command,
470
586
  args: config.args,
@@ -480,52 +596,32 @@ async function connectStdioServer(name, config, defaultCwd) {
480
596
  }
481
597
  });
482
598
  const client = new Client({ name: 'shortcut-agent', version: '1.0.0' }, { capabilities: {} });
483
- const connectPromise = client.connect(transport);
484
- let timer;
485
- const timeoutPromise = new Promise((_, reject) => {
486
- timer = setTimeout(() => reject(new Error(`did not respond within ${startupTimeoutMs / 1000}s`)), startupTimeoutMs);
487
- if (typeof timer === 'object' && 'unref' in timer)
488
- timer.unref();
489
- });
490
599
  try {
491
- await Promise.race([connectPromise, timeoutPromise]);
492
- if (timer)
493
- clearTimeout(timer);
600
+ await client.connect(transport, requestOptions(budget));
494
601
  }
495
602
  catch (err) {
496
- try {
497
- await client.close();
498
- }
499
- catch {
500
- // Ignore close errors during cleanup.
501
- }
603
+ discardClient(client);
604
+ budget.signal.throwIfAborted();
502
605
  const stderr = stderrBuf.trim();
503
606
  const base = err instanceof Error ? err.message : String(err);
504
607
  const detail = stderr ? `${base}\nServer stderr: ${stderr}` : base;
505
608
  throw new McpConnectionError('connect_failed', detail);
506
609
  }
507
- const toolsResult = await client.listTools();
508
- const tools = (toolsResult.tools ?? []).map((t) => ({
509
- name: t.name,
510
- description: t.description,
511
- parameterNames: t.inputSchema?.properties
512
- ? Object.keys(t.inputSchema.properties)
513
- : [],
514
- inputSchema: (t.inputSchema ?? {})
515
- }));
516
- if (tools.length === 0) {
517
- log.info('mcp', `MCP server '${name}' connected but exposed 0 tools.`);
518
- }
610
+ const tools = await discoverTools(name, client, budget);
519
611
  return { name, client, transport, tools, requestPolicy };
520
612
  }
521
613
  /**
522
- * Connect a server, list tools, and return a live MCP connection.
614
+ * Connect a server, list tools, and return a live MCP connection. The whole
615
+ * sequence runs under `options.budget`, or under a fresh budget from the
616
+ * config's startup timeout when the caller did not supply one.
523
617
  */
524
- export async function connectServer(name, config, defaultCwd, authStorage, mcpOAuthStore) {
525
- if (isHostedServerConfig(config)) {
526
- return connectHostedServer(name, config, authStorage, mcpOAuthStore);
527
- }
528
- return connectStdioServer(name, config, defaultCwd);
618
+ export async function connectServer(name, config, defaultCwd, authStorage, mcpOAuthStore, options = {}) {
619
+ const connect = (budget) => isHostedServerConfig(config)
620
+ ? connectHostedServer(name, config, authStorage, mcpOAuthStore, options, budget)
621
+ : connectStdioServer(name, config, defaultCwd, options, budget);
622
+ return options.budget
623
+ ? connect(options.budget)
624
+ : withStartupBudget(name, resolveStartupTimeoutMs(config), connect);
529
625
  }
530
626
  /**
531
627
  * Call a tool on a connected server. Returns the result text or throws.
@@ -2,10 +2,11 @@
2
2
  * MCP client manager — composition root for the MCP subsystem.
3
3
  */
4
4
  import { log } from '../../shared/logging/agent-log.js';
5
+ import { getSecrets } from '../connectors/secret-store.js';
5
6
  import { resolveMcpAuthStatus } from './auth-status.js';
6
7
  import { buildCatalog, buildStatusLine } from './catalog.js';
7
8
  import { loadMcpConfig } from './config.js';
8
- import { closeConnection, connectServer, McpConnectionError } from './connection.js';
9
+ import { closeConnection, connectServer, McpConnectionError, resolveStartupTimeoutMs, withStartupBudget } from './connection.js';
9
10
  import { getHostedMcpOAuthProviderId } from './hosted-oauth-id.js';
10
11
  import { registerHostedMcpOAuthProviders, unregisterHostedMcpOAuthProviders } from './hosted-oauth-provider.js';
11
12
  import { createMcpDescribeTool } from './mcp-describe-tool.js';
@@ -20,6 +21,25 @@ function resolveDisplayedAuthStatus(connection, rawAuthStatus, failureKind) {
20
21
  }
21
22
  return rawAuthStatus;
22
23
  }
24
+ /**
25
+ * Read every referenced connection secret in one asynchronous batch so that a
26
+ * slow keychain (PowerShell on managed Windows) neither blocks the event loop
27
+ * nor runs once per server. A failed batch is reported per server as a
28
+ * connection failure instead of masquerading as "no credentials".
29
+ */
30
+ async function loadConnectionSecrets(servers, timeoutMs) {
31
+ const names = [
32
+ ...new Set(Object.values(servers)
33
+ .map((config) => config.connection)
34
+ .filter((name) => typeof name === 'string'))
35
+ ];
36
+ try {
37
+ return await getSecrets(names, timeoutMs);
38
+ }
39
+ catch (error) {
40
+ throw new McpConnectionError('connect_failed', `Could not read connection credentials from the keychain: ${error instanceof Error ? error.message : String(error)}`);
41
+ }
42
+ }
23
43
  export async function createMcpManager(options) {
24
44
  const servers = loadMcpConfig({
25
45
  cwd: options.cwd,
@@ -35,14 +55,28 @@ export async function createMcpManager(options) {
35
55
  const failureKinds = new Map();
36
56
  const authStatuses = new Map();
37
57
  let shutDown = false;
38
- const results = await Promise.allSettled(serverNames.map(async (name) => {
39
- authStatuses.set(name, await resolveMcpAuthStatus(name, servers[name], {
40
- authStorage: options.authStorage,
41
- mcpOAuthStore: options.mcpOAuthStore,
42
- discovery: 'cached'
43
- }));
44
- const conn = await connectServer(name, servers[name], options.cwd, options.authStorage, options.mcpOAuthStore);
45
- return { name, conn };
58
+ const startupTimeouts = serverNames.map((name) => resolveStartupTimeoutMs(servers[name]));
59
+ const connectionSecrets = loadConnectionSecrets(servers, Math.max(...startupTimeouts));
60
+ const startupMs = new Map();
61
+ const results = await Promise.allSettled(serverNames.map(async (name, index) => {
62
+ const config = servers[name];
63
+ const startedAt = Date.now();
64
+ try {
65
+ return await withStartupBudget(name, startupTimeouts[index], async (budget) => {
66
+ const secrets = config.connection ? await connectionSecrets : undefined;
67
+ authStatuses.set(name, await resolveMcpAuthStatus(name, config, {
68
+ authStorage: options.authStorage,
69
+ mcpOAuthStore: options.mcpOAuthStore,
70
+ discovery: 'cached',
71
+ connectionSecrets: secrets
72
+ }));
73
+ const conn = await connectServer(name, config, options.cwd, options.authStorage, options.mcpOAuthStore, { connectionSecrets: secrets, budget });
74
+ return { name, conn };
75
+ });
76
+ }
77
+ finally {
78
+ startupMs.set(name, Date.now() - startedAt);
79
+ }
46
80
  }));
47
81
  for (const [idx, result] of results.entries()) {
48
82
  if (result.status === 'fulfilled') {
@@ -113,6 +147,7 @@ export async function createMcpManager(options) {
113
147
  : {}),
114
148
  toolCount: connection?.tools.length ?? 0,
115
149
  toolNames: connection?.tools.map((t) => t.name) ?? [],
150
+ startupMs: startupMs.get(name) ?? 0,
116
151
  ...(failure ? { error: failure } : {})
117
152
  };
118
153
  });
@@ -67,7 +67,7 @@ export interface McpHostedServerConfig {
67
67
  }
68
68
  export type McpServerConfig = McpStdioServerConfig | McpHostedServerConfig;
69
69
  export type McpAuthStatus = 'unsupported' | 'not_logged_in' | 'configured' | 'oauth';
70
- export type McpFailureKind = 'auth_required' | 'connect_failed';
70
+ export type McpFailureKind = 'auth_required' | 'connect_failed' | 'startup_timeout';
71
71
  export declare function isHostedServerConfig(config: McpServerConfig): config is McpHostedServerConfig;
72
72
  export declare function isStdioServerConfig(config: McpServerConfig): config is McpStdioServerConfig;
73
73
  export type McpTransport = StdioClientTransport | StreamableHTTPClientTransport;
@@ -100,6 +100,8 @@ export interface McpServerStatus {
100
100
  oauthProviderName?: string;
101
101
  toolCount: number;
102
102
  toolNames: string[];
103
+ /** Wall-clock time the server's startup took, whether it connected or failed. */
104
+ startupMs: number;
103
105
  error?: string;
104
106
  }
105
107
  export interface McpManager {
@@ -65,7 +65,7 @@ Generally speaking, unless it is for very simple requests, you should spawn para
65
65
 
66
66
  ## Tone and Style
67
67
  - Be clear and direct. Give enough context that the user understands what's happening and why
68
- - Reference cells/ranges frequently using Sheet!Range or Sheet!Cell format (e.g., "Summary!A1:B10", "Data!C5")
68
+ - Reference cells/ranges frequently using Sheet!Range or Sheet!Cell format as plain text (e.g., Summary!A1:B10, Data!C5). Do not wrap them in markdown links.
69
69
  - No emojis unless requested. Avoid filler phrases
70
70
  `;
71
71
  // ---------------------------------------------------------------------------
@@ -65,7 +65,7 @@ Launch verification subagent(s) in the background to check results against requi
65
65
 
66
66
  - **NEVER** write "based on your findings, implement X" — this pushes synthesis onto the subagent. YOU must synthesize research results and write precise instructions.
67
67
  - **NEVER** delegate understanding. Read subagent outputs, extract what matters, then write specific implementation instructions.
68
- - **NEVER** launch a single subagent for a task that could be parallelized. If 5 sheets need independent work, launch 5 subagents.
68
+ - **NEVER** spawn a replacement until the original subagent has stopped, and never resume the original afterward.
69
69
  - **NEVER** give vague instructions like "fix the formatting" — specify which cells, which format, which style.
70
70
 
71
71
  ## Delegation Format
@@ -106,6 +106,7 @@ export interface ProviderConfigInput {
106
106
  cacheWrite: number;
107
107
  };
108
108
  contextWindow: number;
109
+ compactionThresholdTokens?: number;
109
110
  maxTokens: number;
110
111
  capabilities?: Model<Api>['capabilities'];
111
112
  headers?: Record<string, string>;
@@ -575,6 +575,7 @@ export class ModelRegistry {
575
575
  input: modelDef.input,
576
576
  cost: modelDef.cost,
577
577
  contextWindow: modelDef.contextWindow,
578
+ compactionThresholdTokens: modelDef.compactionThresholdTokens,
578
579
  maxTokens: modelDef.maxTokens,
579
580
  capabilities: modelDef.capabilities,
580
581
  headers,
@@ -59,6 +59,7 @@ function getDefaultModelCandidatesForProvider(provider) {
59
59
  if (provider === SHORTCUT_PROVIDER_ID) {
60
60
  return [
61
61
  SHORTCUT_OPENAI_MODEL_ID,
62
+ SHORTCUT_MODEL_ID.ClaudeFable51,
62
63
  SHORTCUT_MODEL_ID.ClaudeFable5,
63
64
  SHORTCUT_MODEL_ID.ClaudeOpus5
64
65
  ];