@gajae-code/coding-agent 0.16.1 → 0.16.4

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 (146) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/dist/types/cli/args.d.ts +2 -2
  3. package/dist/types/cli/root-flags.d.ts +4 -2
  4. package/dist/types/cli.d.ts +1 -1
  5. package/dist/types/commands/launch.d.ts +1 -1
  6. package/dist/types/config/autorouting-tier-map.d.ts +3 -0
  7. package/dist/types/config/model-registry.d.ts +4 -0
  8. package/dist/types/config/model-resolver.d.ts +9 -0
  9. package/dist/types/config/settings-schema.d.ts +29 -1
  10. package/dist/types/discovery/claude-plugins.d.ts +3 -1
  11. package/dist/types/discovery/helpers.d.ts +2 -0
  12. package/dist/types/extensibility/gjc-plugins/mcp-policy.d.ts +16 -0
  13. package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -0
  14. package/dist/types/extensibility/runtime-skill-discovery.d.ts +1 -1
  15. package/dist/types/extensibility/shared-events.d.ts +1 -1
  16. package/dist/types/gjc-runtime/session-state-lock.d.ts +2 -0
  17. package/dist/types/modes/components/assistant-message.d.ts +1 -2
  18. package/dist/types/modes/components/settings-selector.d.ts +6 -1
  19. package/dist/types/modes/components/status-line/command.d.ts +35 -0
  20. package/dist/types/modes/components/status-line/index.d.ts +1 -0
  21. package/dist/types/modes/components/status-line/types.d.ts +5 -0
  22. package/dist/types/modes/components/tool-status-header.d.ts +14 -0
  23. package/dist/types/modes/controllers/event-controller.d.ts +6 -0
  24. package/dist/types/modes/interactive-mode.d.ts +1 -0
  25. package/dist/types/modes/types.d.ts +1 -0
  26. package/dist/types/runtime-mcp/client.d.ts +9 -1
  27. package/dist/types/runtime-mcp/manager.d.ts +7 -1
  28. package/dist/types/runtime-mcp/pool.d.ts +22 -3
  29. package/dist/types/runtime-mcp/transports/stdio.d.ts +0 -4
  30. package/dist/types/runtime-mcp/types.d.ts +22 -0
  31. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +12 -0
  32. package/dist/types/sdk/bus/slack-live-provider.d.ts +6 -0
  33. package/dist/types/sdk/bus/slack-provider.d.ts +12 -0
  34. package/dist/types/sdk/cli/rows.d.ts +7 -0
  35. package/dist/types/sdk/host/events.d.ts +1 -0
  36. package/dist/types/sdk/host/host.d.ts +2 -0
  37. package/dist/types/sdk/host/session-runtime.d.ts +23 -0
  38. package/dist/types/sdk/session-api-key.d.ts +26 -0
  39. package/dist/types/sdk/transport/serve-cli.d.ts +1 -1
  40. package/dist/types/session/agent-session.d.ts +9 -0
  41. package/dist/types/setup/paseo/announce.d.ts +111 -0
  42. package/dist/types/setup/paseo/setup-deps.d.ts +2 -1
  43. package/dist/types/system-prompt.d.ts +2 -0
  44. package/dist/types/task/executor.d.ts +2 -1
  45. package/dist/types/tools/fetch.d.ts +7 -0
  46. package/dist/types/tools/index.d.ts +8 -2
  47. package/dist/types/tools/path-utils.d.ts +2 -0
  48. package/dist/types/tools/search.d.ts +6 -0
  49. package/dist/types/utils/external-editor.d.ts +5 -0
  50. package/package.json +7 -7
  51. package/scripts/capture-status-line-custom-editor-showcase.ts +151 -0
  52. package/scripts/generate-sdk-operation-inventory.ts +9 -0
  53. package/scripts/verify-status-line-custom-editor-showcase.ts +309 -0
  54. package/src/cli/args.ts +20 -9
  55. package/src/cli/root-flags.ts +5 -3
  56. package/src/config/autorouting-tier-map.ts +1 -0
  57. package/src/config/file-lock.ts +181 -17
  58. package/src/config/model-preset-registry.ts +5 -3
  59. package/src/config/model-profiles.ts +40 -0
  60. package/src/config/model-registry.ts +57 -11
  61. package/src/config/model-resolver.ts +48 -0
  62. package/src/config/settings-schema.ts +33 -1
  63. package/src/config/settings.ts +1 -0
  64. package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +11 -11
  65. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +13 -2
  66. package/src/discovery/builtin.ts +13 -4
  67. package/src/discovery/claude-plugins.ts +12 -4
  68. package/src/discovery/helpers.ts +10 -2
  69. package/src/extensibility/gjc-plugins/compiler.ts +208 -58
  70. package/src/extensibility/gjc-plugins/installer.ts +13 -12
  71. package/src/extensibility/gjc-plugins/mcp-policy.ts +81 -52
  72. package/src/extensibility/gjc-plugins/paths.ts +13 -0
  73. package/src/extensibility/gjc-plugins/runtime-adapters.ts +758 -16
  74. package/src/extensibility/runtime-skill-discovery.ts +77 -151
  75. package/src/extensibility/shared-events.ts +1 -1
  76. package/src/extensibility/skills.ts +44 -16
  77. package/src/gjc-runtime/repository-binding.ts +1 -1
  78. package/src/gjc-runtime/session-state-lock.ts +94 -6
  79. package/src/internal-urls/docs-index.generated.ts +10 -10
  80. package/src/main.ts +72 -34
  81. package/src/modes/DESIGN.md +54 -0
  82. package/src/modes/acp/acp-agent.ts +29 -14
  83. package/src/modes/components/assistant-message.ts +22 -9
  84. package/src/modes/components/model-selector.ts +166 -33
  85. package/src/modes/components/settings-selector.ts +1075 -235
  86. package/src/modes/components/status-line/command.ts +190 -0
  87. package/src/modes/components/status-line/index.ts +1 -0
  88. package/src/modes/components/status-line/segments.ts +32 -5
  89. package/src/modes/components/status-line/types.ts +5 -0
  90. package/src/modes/components/tool-status-header.ts +393 -21
  91. package/src/modes/controllers/command-controller.ts +2 -0
  92. package/src/modes/controllers/event-controller.ts +148 -5
  93. package/src/modes/controllers/selector-controller.ts +21 -0
  94. package/src/modes/interactive-mode.ts +47 -1
  95. package/src/modes/types.ts +1 -0
  96. package/src/modes/utils/ui-helpers.ts +1 -0
  97. package/src/prompts/system/system-prompt.md +8 -0
  98. package/src/prompts/tools/bash.md +1 -1
  99. package/src/prompts/tools/job.md +1 -1
  100. package/src/prompts/tools/search.md +2 -0
  101. package/src/runtime-mcp/client.ts +60 -17
  102. package/src/runtime-mcp/manager.ts +171 -14
  103. package/src/runtime-mcp/pool.test.ts +209 -0
  104. package/src/runtime-mcp/pool.ts +224 -23
  105. package/src/runtime-mcp/transports/stdio.ts +158 -24
  106. package/src/runtime-mcp/types.ts +24 -0
  107. package/src/sdk/acp/adapter.ts +1 -1
  108. package/src/sdk/broker/broker.ts +5 -4
  109. package/src/sdk/bus/chat-daemon-control.ts +1 -1
  110. package/src/sdk/bus/chat-daemon-runtime.ts +44 -1
  111. package/src/sdk/bus/slack-daemon.ts +92 -2
  112. package/src/sdk/bus/slack-live-provider.ts +12 -0
  113. package/src/sdk/bus/slack-provider.ts +5 -0
  114. package/src/sdk/cli/rows.ts +47 -2
  115. package/src/sdk/cli/session-cli.ts +47 -23
  116. package/src/sdk/host/events.ts +25 -2
  117. package/src/sdk/host/host.ts +4 -1
  118. package/src/sdk/host/session-runtime.test.ts +36 -25
  119. package/src/sdk/host/session-runtime.ts +279 -43
  120. package/src/sdk/protocol/operation-inventory.generated.json +66 -0
  121. package/src/sdk/router/session-router.ts +60 -37
  122. package/src/sdk/session-api-key.ts +94 -0
  123. package/src/sdk/session.ts +73 -30
  124. package/src/sdk/transport/serve-cli.ts +3 -3
  125. package/src/session/agent-session.ts +123 -38
  126. package/src/session/contribution-prep.ts +214 -21
  127. package/src/setup/paseo/announce.ts +450 -0
  128. package/src/setup/paseo/setup-deps.ts +15 -3
  129. package/src/slash-commands/builtin-registry.ts +1 -0
  130. package/src/system-prompt.ts +4 -0
  131. package/src/task/executor.ts +2 -1
  132. package/src/task/index.ts +17 -4
  133. package/src/tools/bash.ts +50 -21
  134. package/src/tools/cron.ts +1 -1
  135. package/src/tools/fetch.ts +29 -1
  136. package/src/tools/index.ts +8 -2
  137. package/src/tools/job.ts +8 -2
  138. package/src/tools/monitor.ts +8 -2
  139. package/src/tools/path-utils.ts +7 -0
  140. package/src/tools/search.ts +116 -58
  141. package/src/tools/skill.ts +22 -2
  142. package/src/tools/subagent.ts +3 -1
  143. package/src/tools/tool-catalog.generated.ts +9 -2
  144. package/src/utils/external-editor.ts +16 -2
  145. package/src/web/insane/bridge.ts +113 -23
  146. package/src/web/search/providers/perplexity.ts +102 -41
@@ -20,6 +20,7 @@ import {
20
20
  listResources,
21
21
  listResourceTemplates,
22
22
  listTools,
23
+ MCPConnectionCleanupFailure,
23
24
  readResource,
24
25
  serverSupportsPrompts,
25
26
  serverSupportsResources,
@@ -344,6 +345,8 @@ export class MCPManager {
344
345
  #pendingConnections = new Map<string, Promise<MCPServerConnection>>();
345
346
  #pendingConnectionControllers = new Map<string, AbortController>();
346
347
  #pendingToolLoads = new Map<string, Promise<ToolLoadResult>>();
348
+ #pendingConnectionCleanups = new Map<string, MCPConnectionCleanupFailure>();
349
+ #pendingAcquireCleanups = new Map<string, Set<MCPPoolAcquireAbortError>>();
347
350
  #sources = new Map<string, SourceMeta>();
348
351
  #authStorage: AuthStorage | null = null;
349
352
  #inputRequestHandler: MCPInputRequestHandler | null = null;
@@ -373,6 +376,79 @@ export class MCPManager {
373
376
  #serverError(message: string): string {
374
377
  return this.#toolsOnly ? "MCP server unavailable" : message;
375
378
  }
379
+
380
+ #removePendingAcquireCleanup(name: string, error: MCPPoolAcquireAbortError): void {
381
+ const pending = this.#pendingAcquireCleanups.get(name);
382
+ pending?.delete(error);
383
+ if (pending?.size === 0) this.#pendingAcquireCleanups.delete(name);
384
+ }
385
+
386
+ #retainPendingAcquireCleanup(name: string, error: MCPPoolAcquireAbortError): void {
387
+ if (error.cleanupGeneration === undefined || !error.cleanup || !this.#pool.hasPendingAcquireCleanup(error))
388
+ return;
389
+ let pending = this.#pendingAcquireCleanups.get(name);
390
+ if (!pending) {
391
+ pending = new Set();
392
+ this.#pendingAcquireCleanups.set(name, pending);
393
+ }
394
+ if (pending.has(error)) return;
395
+ pending.add(error);
396
+ void error.cleanup.catch(() => {});
397
+ }
398
+
399
+ #retainConnectionCleanupFailure(name: string, error: unknown): MCPConnectionCleanupFailure | undefined {
400
+ if (error instanceof MCPPoolAcquireAbortError) {
401
+ this.#retainPendingAcquireCleanup(name, error);
402
+ return undefined;
403
+ }
404
+ if (!(error instanceof MCPConnectionCleanupFailure)) return undefined;
405
+ if (!this.#pool.ownsPendingAcquireCleanupFailure(error)) this.#pendingConnectionCleanups.set(name, error);
406
+ return error;
407
+ }
408
+ async #closePendingConnectionCleanup(name: string, waitForAcquireFlight = true): Promise<void> {
409
+ const failures: unknown[] = [];
410
+ const pendingAcquires = [...(this.#pendingAcquireCleanups.get(name) ?? [])];
411
+ const acquireResults = await Promise.allSettled(
412
+ pendingAcquires.map(error => {
413
+ if (!error.cleanup) return Promise.resolve();
414
+ if (!waitForAcquireFlight && !this.#pool.getPendingAcquireCleanupFailure(error)) return Promise.resolve();
415
+ return error.cleanup.then(
416
+ () => undefined,
417
+ () =>
418
+ this.#pool.closePendingAcquireCleanup(error.poolKey, error.cleanupGeneration!).then(() => undefined),
419
+ );
420
+ }),
421
+ );
422
+ for (const [index, result] of acquireResults.entries()) {
423
+ const pendingAcquire = pendingAcquires[index];
424
+ if (!pendingAcquire) continue;
425
+ if (result.status === "rejected") failures.push(result.reason);
426
+ if (!this.#pool.hasPendingAcquireCleanup(pendingAcquire)) {
427
+ this.#removePendingAcquireCleanup(name, pendingAcquire);
428
+ }
429
+ }
430
+
431
+ const cleanup = this.#pendingConnectionCleanups.get(name);
432
+ if (cleanup) {
433
+ let cleanupFailed = false;
434
+ try {
435
+ await cleanup.close();
436
+ } catch (error) {
437
+ cleanupFailed = true;
438
+ const retained = error instanceof MCPConnectionCleanupFailure ? error : cleanup;
439
+ if (this.#pendingConnectionCleanups.get(name) === cleanup) {
440
+ this.#pendingConnectionCleanups.set(name, retained);
441
+ }
442
+ failures.push(error);
443
+ }
444
+ if (this.#pendingConnectionCleanups.get(name) === cleanup && !cleanupFailed) {
445
+ this.#pendingConnectionCleanups.delete(name);
446
+ }
447
+ }
448
+ if (failures.length === 1) throw failures[0];
449
+ if (failures.length > 1) throw new AggregateError(failures, `MCP server cleanup failed: ${name}`);
450
+ }
451
+
376
452
  #assertRawMCPAccessAllowed(): void {
377
453
  if (this.#toolsOnly) throw new Error("Tools-only MCP manager does not allow raw MCP access");
378
454
  }
@@ -413,6 +489,11 @@ export class MCPManager {
413
489
  connectionAbort: AbortController,
414
490
  trackManagerLease = true,
415
491
  ): Promise<MCPPoolLease> {
492
+ const pendingCleanup = this.#pendingConnectionCleanups.get(name);
493
+ if (pendingCleanup) throw pendingCleanup;
494
+ if (connectionAbort.signal.aborted) {
495
+ throw connectionAbort.signal.reason ?? new Error(`MCP connection acquisition aborted: ${name}`);
496
+ }
416
497
  const sharedConfig = originalConfig.sharing === "shared";
417
498
  const sharedEligible =
418
499
  sharedConfig &&
@@ -435,6 +516,7 @@ export class MCPManager {
435
516
  effectiveHeaders:
436
517
  resolvedConfig.type === "http" || resolvedConfig.type === "sse" ? resolvedConfig.headers : undefined,
437
518
  onRequest: (method, params) => this.#handleServerRequest(method, params),
519
+ onCleanupPending: error => this.#retainPendingAcquireCleanup(name, error),
438
520
  });
439
521
  if (trackManagerLease) this.#leaseByConnection.set(lease.connection, lease);
440
522
  if (!this.#toolsOnly) lease.updateRoots(this.#getRoots().roots);
@@ -958,6 +1040,21 @@ export class MCPManager {
958
1040
  const connectionTasks: ConnectionTask[] = [];
959
1041
 
960
1042
  for (const [name, config] of Object.entries(configs)) {
1043
+ const pendingAcquire = this.#pendingAcquireCleanups.get(name);
1044
+ const pendingCleanup = this.#pendingConnectionCleanups.get(name);
1045
+ if (pendingCleanup || pendingAcquire) {
1046
+ const message =
1047
+ pendingCleanup?.message ??
1048
+ (pendingAcquire
1049
+ ? [...pendingAcquire]
1050
+ .map(error => this.#pool.getPendingAcquireCleanupFailure(error)?.message ?? error.message)
1051
+ .at(0)
1052
+ : undefined) ??
1053
+ "MCP connection cleanup pending";
1054
+ errors.set(name, this.#serverError(message));
1055
+ reportedErrors.add(name);
1056
+ continue;
1057
+ }
961
1058
  if (sources[name]) {
962
1059
  this.#sources.set(name, sources[name]);
963
1060
  const existing = this.#connections.get(name);
@@ -1136,6 +1233,7 @@ export class MCPManager {
1136
1233
  if (!this.#toolsOnly) await this.#loadServerResourcesAndPrompts(name, connection);
1137
1234
  })
1138
1235
  .catch(error => {
1236
+ this.#retainConnectionCleanupFailure(name, error);
1139
1237
  if (this.#pendingToolLoads.get(name) !== toolsPromise) return;
1140
1238
  this.#pendingToolLoads.delete(name);
1141
1239
  if (!allowBackgroundLogging || reportedErrors.has(name) || this.#toolsOnly) return;
@@ -1270,6 +1368,7 @@ export class MCPManager {
1270
1368
  }
1271
1369
  } else if (task.tracked.status === "rejected") {
1272
1370
  const reason = task.tracked.reason;
1371
+ this.#retainConnectionCleanupFailure(name, reason);
1273
1372
  const message = reason instanceof Error ? reason.message : String(reason);
1274
1373
  errors.set(name, this.#serverError(message));
1275
1374
  reportedErrors.add(name);
@@ -1582,6 +1681,7 @@ export class MCPManager {
1582
1681
  });
1583
1682
  result = await Promise.race([callbackPromise, abortPromise]);
1584
1683
  } catch (error) {
1684
+ this.#retainConnectionCleanupFailure(operation.name, error);
1585
1685
  failed = true;
1586
1686
  primaryError = error;
1587
1687
  } finally {
@@ -1609,6 +1709,28 @@ export class MCPManager {
1609
1709
  get retiredLeaseReleaseCountForTests(): number {
1610
1710
  return this.#retiredLeaseReleases.size;
1611
1711
  }
1712
+
1713
+ /** Read-only test seam for retained failed-connection cleanup owners. */
1714
+ get pendingConnectionCleanupCountForTests(): number {
1715
+ return (
1716
+ this.#pendingConnectionCleanups.size +
1717
+ [...this.#pendingAcquireCleanups.values()].reduce((count, pending) => count + pending.size, 0)
1718
+ );
1719
+ }
1720
+
1721
+ /** Read-only test seam for retained failed-connection cleanup causes. */
1722
+ getPendingConnectionCleanupFailureForTests(name: string): MCPConnectionCleanupFailure | undefined {
1723
+ const pendingAcquire = this.#pendingAcquireCleanups.get(name);
1724
+ return (
1725
+ this.#pendingConnectionCleanups.get(name) ??
1726
+ (pendingAcquire
1727
+ ? [...pendingAcquire]
1728
+ .map(error => this.#pool.getPendingAcquireCleanupFailure(error))
1729
+ .find((error): error is MCPConnectionCleanupFailure => error !== undefined)
1730
+ : undefined)
1731
+ );
1732
+ }
1733
+
1612
1734
  /**
1613
1735
  * Get all connected server names.
1614
1736
  */
@@ -1621,7 +1743,13 @@ export class MCPManager {
1621
1743
  */
1622
1744
  getAllServerNames(): string[] {
1623
1745
  return Array.from(
1624
- new Set([...this.#sources.keys(), ...this.#connections.keys(), ...this.#pendingConnections.keys()]),
1746
+ new Set([
1747
+ ...this.#sources.keys(),
1748
+ ...this.#connections.keys(),
1749
+ ...this.#pendingConnections.keys(),
1750
+ ...this.#pendingConnectionCleanups.keys(),
1751
+ ...this.#pendingAcquireCleanups.keys(),
1752
+ ]),
1625
1753
  );
1626
1754
  }
1627
1755
 
@@ -1652,11 +1780,20 @@ export class MCPManager {
1652
1780
  this.#subscribedResources.delete(name);
1653
1781
 
1654
1782
  let closeError: unknown;
1783
+ try {
1784
+ await this.#closePendingConnectionCleanup(name);
1785
+ } catch (error) {
1786
+ closeError = error;
1787
+ }
1655
1788
  if (connection) {
1656
1789
  try {
1657
1790
  await this.#releaseLease(name);
1658
1791
  } catch (error) {
1659
- closeError = this.#logLeaseReleaseFailure(name, connection, error);
1792
+ const leaseError = this.#logLeaseReleaseFailure(name, connection, error);
1793
+ closeError =
1794
+ closeError !== undefined
1795
+ ? new AggregateError([closeError, leaseError], `MCP server cleanup failed: ${name}`)
1796
+ : leaseError;
1660
1797
  }
1661
1798
  if (this.#connections.get(name) === connection) this.#connections.delete(name);
1662
1799
  }
@@ -1672,7 +1809,7 @@ export class MCPManager {
1672
1809
 
1673
1810
  // Notify prompt consumers so stale commands are cleared
1674
1811
  if (connection?.prompts?.length) this.#onPromptsChanged?.(name);
1675
- if (closeError) throw closeError;
1812
+ if (closeError !== undefined) throw closeError;
1676
1813
  }
1677
1814
 
1678
1815
  #abortConnectionTask(task: ConnectionTask): void {
@@ -1692,12 +1829,8 @@ export class MCPManager {
1692
1829
  }
1693
1830
 
1694
1831
  async #terminateConnectionTask(task: ConnectionTask): Promise<void> {
1695
- const connection = await task.connectionPromise.catch(async error => {
1696
- if (error instanceof MCPPoolAcquireAbortError && error.cleanup) {
1697
- await error.cleanup.catch(cleanupError => {
1698
- logger.error("MCP aborted acquire cleanup failed", { path: `mcp:${task.name}`, error: cleanupError });
1699
- });
1700
- }
1832
+ const connection = await task.connectionPromise.catch(error => {
1833
+ this.#retainConnectionCleanupFailure(task.name, error);
1701
1834
  logger.debug("MCP connection task did not publish before cleanup", { path: `mcp:${task.name}`, error });
1702
1835
  return undefined;
1703
1836
  });
@@ -1731,6 +1864,14 @@ export class MCPManager {
1731
1864
  const lifecycleEpoch = this.#beginScopedLifecycle("disconnecting");
1732
1865
  this.#deferredSharedRebinds.clear();
1733
1866
  try {
1867
+ const cleanupNames = new Set([
1868
+ ...this.#pendingConnectionCleanups.keys(),
1869
+ ...this.#pendingAcquireCleanups.keys(),
1870
+ ...this.#pendingConnectionControllers.keys(),
1871
+ ...this.#pendingConnections.keys(),
1872
+ ...this.#serverConfigs.keys(),
1873
+ ...[...this.#scopedOperations.values()].map(operation => operation.name),
1874
+ ]);
1734
1875
  // Invalidate any in-flight reconnection attempts that outlive this call.
1735
1876
  // They captured the old epoch; after increment they'll detect staleness.
1736
1877
  this.#epoch++;
@@ -1757,6 +1898,11 @@ export class MCPManager {
1757
1898
  }
1758
1899
  this.#reconnectBackoffs.clear();
1759
1900
  this.#pendingReconnections.clear();
1901
+ for (const name of this.#pendingConnectionCleanups.keys()) cleanupNames.add(name);
1902
+ for (const name of this.#pendingAcquireCleanups.keys()) cleanupNames.add(name);
1903
+ const pendingCleanupResults = await Promise.allSettled(
1904
+ [...cleanupNames].map(name => this.#closePendingConnectionCleanup(name, false)),
1905
+ );
1760
1906
  const retiredReleaseFailures = await this.#drainRetiredLeaseReleases();
1761
1907
  this.#deferredSharedRebinds.clear();
1762
1908
  this.#pendingResourceRefresh.clear();
@@ -1770,6 +1916,9 @@ export class MCPManager {
1770
1916
  ...releaseResults
1771
1917
  .filter((result): result is PromiseRejectedResult => result.status === "rejected")
1772
1918
  .map(result => result.reason),
1919
+ ...pendingCleanupResults
1920
+ .filter((result): result is PromiseRejectedResult => result.status === "rejected")
1921
+ .map(result => result.reason),
1773
1922
  ...retiredReleaseFailures,
1774
1923
  ];
1775
1924
  if (releaseFailures.length > 0) {
@@ -1793,7 +1942,8 @@ export class MCPManager {
1793
1942
  * Tears down the stale connection, re-resolves auth, establishes a new
1794
1943
  * connection, reloads tools, and notifies consumers.
1795
1944
  * Concurrent calls for the same server share one reconnection attempt.
1796
- * Returns the new connection, or null if reconnection failed.
1945
+ * Returns the new connection, or null if reconnection fails after cleanup.
1946
+ * Throws a retryable cleanup failure while the previous transport owner remains incomplete.
1797
1947
  */
1798
1948
  async reconnectServer(name: string): Promise<MCPServerConnection | null> {
1799
1949
  if (this.#connectionSetSealed) return null;
@@ -1801,6 +1951,14 @@ export class MCPManager {
1801
1951
  const pending = this.#pendingReconnections.get(name);
1802
1952
  if (pending) return pending;
1803
1953
 
1954
+ const attempt = this.#closePendingConnectionCleanup(name).then(() => this.#startReconnect(name));
1955
+ this.#pendingReconnections.set(name, attempt);
1956
+ return attempt.finally(() => {
1957
+ if (this.#pendingReconnections.get(name) === attempt) this.#pendingReconnections.delete(name);
1958
+ });
1959
+ }
1960
+
1961
+ #startReconnect(name: string): Promise<MCPServerConnection | null> {
1804
1962
  const lease = this.#leases.get(name);
1805
1963
  const sharedKey = lease?.sharingMode === "shared" ? lease.key : undefined;
1806
1964
  let attempt: Promise<MCPServerConnection | null>;
@@ -1831,10 +1989,7 @@ export class MCPManager {
1831
1989
  .finally(() => this.#pool.releaseRestart(sharedKey));
1832
1990
  }
1833
1991
  }
1834
- this.#pendingReconnections.set(name, attempt);
1835
- return attempt.finally(() => {
1836
- if (this.#pendingReconnections.get(name) === attempt) this.#pendingReconnections.delete(name);
1837
- });
1992
+ return attempt;
1838
1993
  }
1839
1994
 
1840
1995
  async #doReconnect(name: string): Promise<MCPServerConnection | null> {
@@ -1905,6 +2060,7 @@ export class MCPManager {
1905
2060
  logger.debug("MCP reconnected", { path: `mcp:${name}`, tools: connection.tools?.length ?? 0 });
1906
2061
  return connection;
1907
2062
  } catch (error) {
2063
+ const cleanupFailure = this.#retainConnectionCleanupFailure(name, error);
1908
2064
  if ((this.#disconnectEpochs.get(name) ?? 0) !== reconnectEpoch || backoffAbort.signal.aborted) {
1909
2065
  logger.debug("MCP reconnect aborted after server disconnected", {
1910
2066
  path: `mcp:${name}`,
@@ -1913,6 +2069,7 @@ export class MCPManager {
1913
2069
  });
1914
2070
  return null;
1915
2071
  }
2072
+ if (cleanupFailure) throw cleanupFailure;
1916
2073
 
1917
2074
  const msg = error instanceof Error ? error.message : String(error);
1918
2075
  if (attempt < delays.length) {
@@ -1,4 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { MCPConnectionCleanupFailure } from "./client";
3
+ import { MCPManager } from "./manager";
2
4
  import { MCPConnectionPool, MCPPoolAcquireAbortError, MCPPoolLeaseInvalidatedError } from "./pool";
3
5
  import { MCPPoolConfigError } from "./pool-key";
4
6
  import { legacyEraObservation } from "./protocol";
@@ -48,6 +50,17 @@ class FakeTransport implements MCPTransport {
48
50
  }
49
51
  }
50
52
 
53
+ class ToolListTransport extends FakeTransport {
54
+ override async request<T = unknown>(
55
+ method: string,
56
+ params?: Record<string, unknown>,
57
+ options?: MCPRequestOptions,
58
+ ): Promise<T> {
59
+ if (method === "tools/list") return { tools: [] } as T;
60
+ return super.request<T>(method, params, options);
61
+ }
62
+ }
63
+
51
64
  class DelayedSubscriptionTransport extends FakeTransport {
52
65
  readonly subscribeStarted = Promise.withResolvers<void>();
53
66
  readonly allowSubscribe = Promise.withResolvers<void>();
@@ -373,6 +386,202 @@ describe("MCPConnectionPool", () => {
373
386
  await pool.shutdown();
374
387
  });
375
388
 
389
+ test("retains cancelled cleanup before late failure and fences each cleanup generation", async () => {
390
+ const flights = [Promise.withResolvers<MCPServerConnection>(), Promise.withResolvers<MCPServerConnection>()];
391
+ const starts = [Promise.withResolvers<void>(), Promise.withResolvers<void>()];
392
+ const recoveryTransports: FakeTransport[] = [];
393
+ let opens = 0;
394
+ const pool = new MCPConnectionPool({
395
+ connect: async (name, cfg) => {
396
+ opens += 1;
397
+ if (opens === 1) {
398
+ starts[0]?.resolve();
399
+ return flights[0]!.promise;
400
+ }
401
+ if (opens === 3) {
402
+ starts[1]?.resolve();
403
+ return flights[1]!.promise;
404
+ }
405
+ const transport = new FakeTransport();
406
+ recoveryTransports.push(transport);
407
+ return connection(name, cfg, transport);
408
+ },
409
+ });
410
+ const acquireCancelledFlight = async (index: number): Promise<MCPPoolAcquireAbortError> => {
411
+ const controller = new AbortController();
412
+ const acquire = pool.acquire("server", config(), {
413
+ sessionId: "retained-cleanup",
414
+ signal: controller.signal,
415
+ });
416
+ await starts[index]!.promise;
417
+ controller.abort(new Error(`caller abort ${index}`));
418
+ const error = await acquire.catch(error => error);
419
+ expect(error).toBeInstanceOf(MCPPoolAcquireAbortError);
420
+ if (!(error instanceof MCPPoolAcquireAbortError)) throw new Error("Expected aborted pool acquisition");
421
+ return error;
422
+ };
423
+
424
+ const firstAbort = await acquireCancelledFlight(0);
425
+ expect(pool.pendingAcquireCleanupCountForTests).toBe(1);
426
+ expect(opens).toBe(1);
427
+ const firstCleanupTransport = new FakeTransport();
428
+ let fencedSettled = false;
429
+ const fenced = pool.acquire("server", config(), { sessionId: "retained-cleanup" });
430
+ void fenced.then(
431
+ () => {
432
+ fencedSettled = true;
433
+ },
434
+ () => {
435
+ fencedSettled = true;
436
+ },
437
+ );
438
+ await Promise.resolve();
439
+ expect(fencedSettled).toBe(false);
440
+ expect(opens).toBe(1);
441
+ flights[0]!.reject(new MCPConnectionCleanupFailure(new Error("late cleanup failure 1"), firstCleanupTransport));
442
+ await expect(fenced).resolves.toBeDefined();
443
+ expect(firstCleanupTransport.closeCount).toBe(1);
444
+ expect(opens).toBe(2);
445
+
446
+ const secondLease = await fenced;
447
+ await secondLease.release();
448
+ const secondAbort = await acquireCancelledFlight(1);
449
+ expect(secondAbort.cleanupGeneration).toBeGreaterThan(firstAbort.cleanupGeneration ?? 0);
450
+ const secondCleanupTransport = new FakeTransport();
451
+ flights[1]!.reject(new MCPConnectionCleanupFailure(new Error("late cleanup failure 2"), secondCleanupTransport));
452
+ await expect(secondAbort.cleanup).rejects.toBeInstanceOf(MCPConnectionCleanupFailure);
453
+ await pool.closePendingAcquireCleanup(firstAbort.poolKey, firstAbort.cleanupGeneration!);
454
+ expect(secondCleanupTransport.closeCount).toBe(0);
455
+ expect(pool.pendingAcquireCleanupCountForTests).toBe(1);
456
+ await pool.closePendingAcquireCleanup(secondAbort.poolKey, secondAbort.cleanupGeneration!);
457
+ expect(secondCleanupTransport.closeCount).toBe(1);
458
+ expect(pool.pendingAcquireCleanupCountForTests).toBe(0);
459
+ const recovered = await pool.acquire("server", config(), { sessionId: "retained-cleanup" });
460
+ expect(opens).toBe(4);
461
+ await recovered.release();
462
+ expect(recoveryTransports).toHaveLength(2);
463
+ });
464
+
465
+ test("manager disconnect joins and retries pool-owned late cleanup without spawning", async () => {
466
+ const firstFlight = Promise.withResolvers<MCPServerConnection>();
467
+ const firstStarted = Promise.withResolvers<void>();
468
+ const cleanupTransport = new FakeTransport();
469
+ const retryFailure = new Error("deferred cleanup retry failed");
470
+ let cleanupAttempts = 0;
471
+ cleanupTransport.close = async () => {
472
+ cleanupAttempts += 1;
473
+ if (cleanupAttempts === 1) throw retryFailure;
474
+ cleanupTransport.connected = false;
475
+ };
476
+ let opens = 0;
477
+ const pool = new MCPConnectionPool({
478
+ connect: async (name, cfg) => {
479
+ opens += 1;
480
+ if (opens === 1) {
481
+ firstStarted.resolve();
482
+ return firstFlight.promise;
483
+ }
484
+ return connection(name, cfg, new FakeTransport());
485
+ },
486
+ });
487
+ const manager = new MCPManager(".", null, { pool, sessionId: "disconnect-cleanup" });
488
+ const serverConfig = config();
489
+ const controller = new AbortController();
490
+ const operation = manager.withPreparedLease("server", serverConfig, async () => undefined, {
491
+ signal: controller.signal,
492
+ });
493
+ await firstStarted.promise;
494
+ controller.abort(new Error("abort before deferred cleanup failure"));
495
+ await expect(operation).rejects.toBeInstanceOf(MCPPoolAcquireAbortError);
496
+ expect(manager.pendingConnectionCleanupCountForTests).toBe(1);
497
+
498
+ const disconnect = manager.disconnectServer("server");
499
+ const fenced = await manager.connectServers({ server: serverConfig }, {});
500
+ expect(fenced.errors.has("server")).toBe(true);
501
+ expect(opens).toBe(1);
502
+ const primaryFailure = new Error("late connection cleanup failed");
503
+ firstFlight.reject(new MCPConnectionCleanupFailure(primaryFailure, cleanupTransport));
504
+ const disconnectError = await disconnect.catch(error => error);
505
+ expect(disconnectError).toBeInstanceOf(MCPConnectionCleanupFailure);
506
+ if (!(disconnectError instanceof MCPConnectionCleanupFailure)) {
507
+ throw new Error("Expected retained disconnect cleanup failure");
508
+ }
509
+ expect(disconnectError.cause).toBeInstanceOf(AggregateError);
510
+ if (!(disconnectError.cause instanceof AggregateError)) throw new Error("Expected combined cleanup causes");
511
+ expect(disconnectError.cause.errors).toEqual([primaryFailure, retryFailure]);
512
+ expect(cleanupAttempts).toBe(1);
513
+ expect(manager.pendingConnectionCleanupCountForTests).toBe(1);
514
+ expect(pool.pendingAcquireCleanupCountForTests).toBe(1);
515
+
516
+ await manager.disconnectServer("server");
517
+ expect(cleanupAttempts).toBe(2);
518
+ expect(manager.pendingConnectionCleanupCountForTests).toBe(0);
519
+ expect(pool.pendingAcquireCleanupCountForTests).toBe(0);
520
+ await manager.withPreparedLease("server", serverConfig, async lease => {
521
+ expect(lease.serverName).toBe("server");
522
+ });
523
+ expect(opens).toBe(2);
524
+ await manager.disconnectAll();
525
+ });
526
+
527
+ test("manager reconnect retries pool-owned cleanup before replacing the live connection", async () => {
528
+ const transientFlight = Promise.withResolvers<MCPServerConnection>();
529
+ const transientStarted = Promise.withResolvers<void>();
530
+ const cleanupTransport = new FakeTransport();
531
+ const cleanupRetryFailure = new Error("reconnect cleanup retry failed");
532
+ let cleanupAttempts = 0;
533
+ cleanupTransport.close = async () => {
534
+ cleanupAttempts += 1;
535
+ if (cleanupAttempts === 1) throw cleanupRetryFailure;
536
+ cleanupTransport.connected = false;
537
+ };
538
+ let opens = 0;
539
+ const transports: ToolListTransport[] = [];
540
+ const pool = new MCPConnectionPool({
541
+ connect: async (name, cfg) => {
542
+ opens += 1;
543
+ if (opens === 2) {
544
+ transientStarted.resolve();
545
+ return transientFlight.promise;
546
+ }
547
+ const transport = new ToolListTransport();
548
+ transports.push(transport);
549
+ return connection(name, cfg, transport);
550
+ },
551
+ });
552
+ const manager = new MCPManager(".", null, { pool, sessionId: "reconnect-cleanup", sleep: async () => {} });
553
+ const serverConfig = config();
554
+ await manager.connectServers({ server: serverConfig }, {});
555
+ const original = manager.getConnection("server");
556
+ const controller = new AbortController();
557
+ const transient = manager.withPreparedLease(
558
+ "server",
559
+ { type: "stdio", command: "fake-mcp", args: ["--transient"] },
560
+ async () => undefined,
561
+ { signal: controller.signal },
562
+ );
563
+ await transientStarted.promise;
564
+ controller.abort(new Error("abort transient before reconnect"));
565
+ await expect(transient).rejects.toBeInstanceOf(MCPPoolAcquireAbortError);
566
+
567
+ const firstReconnect = manager.reconnectServer("server");
568
+ transientFlight.reject(
569
+ new MCPConnectionCleanupFailure(new Error("late reconnect cleanup failed"), cleanupTransport),
570
+ );
571
+ await expect(firstReconnect).rejects.toBeInstanceOf(MCPConnectionCleanupFailure);
572
+ expect(cleanupAttempts).toBe(1);
573
+ expect(opens).toBe(2);
574
+ expect(manager.getConnection("server")).toBe(original);
575
+ const replacement = await manager.reconnectServer("server");
576
+ expect(cleanupAttempts).toBe(2);
577
+ expect(opens).toBe(3);
578
+ expect(replacement).toBeDefined();
579
+ expect(replacement).not.toBe(original);
580
+ expect(manager.pendingConnectionCleanupCountForTests).toBe(0);
581
+ await manager.disconnectAll();
582
+ expect(transports.every(transport => transport.closeCount === 1)).toBe(true);
583
+ });
584
+
376
585
  test("shutdown aborts hanging opens, settles waiters, and closes late transports", async () => {
377
586
  let resolveOpen: ((value: MCPServerConnection) => void) | undefined;
378
587
  let openSignal: AbortSignal | undefined;