@gajae-code/coding-agent 0.16.3 → 0.16.6

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 (277) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/dist/types/capability/types.d.ts +4 -0
  3. package/dist/types/cli/args.d.ts +2 -2
  4. package/dist/types/cli/customize-doctor.d.ts +22 -0
  5. package/dist/types/cli/root-flags.d.ts +4 -2
  6. package/dist/types/cli.d.ts +1 -1
  7. package/dist/types/commands/launch.d.ts +1 -1
  8. package/dist/types/config/autorouting-tier-map.d.ts +3 -0
  9. package/dist/types/config/file-lock.d.ts +31 -3
  10. package/dist/types/config/model-registry.d.ts +4 -0
  11. package/dist/types/config/model-resolver.d.ts +9 -0
  12. package/dist/types/config/settings-schema.d.ts +29 -1
  13. package/dist/types/coordinator-mcp/question-state.d.ts +7 -0
  14. package/dist/types/coordinator-mcp/server.d.ts +7 -0
  15. package/dist/types/discovery/claude-plugins.d.ts +3 -1
  16. package/dist/types/discovery/helpers.d.ts +35 -0
  17. package/dist/types/exec/bash-executor.d.ts +17 -1
  18. package/dist/types/exec/bash-shell-guardian.d.ts +15 -0
  19. package/dist/types/exec/bash-shell-runtime-entry.d.ts +2 -0
  20. package/dist/types/exec/bash-shell-signals.d.ts +2 -0
  21. package/dist/types/exec/bash-shell-supervisor-entry.d.ts +1 -0
  22. package/dist/types/exec/bash-shell-supervisor.d.ts +10 -0
  23. package/dist/types/exec/bash-shell-worker-entry.d.ts +2 -0
  24. package/dist/types/exec/bash-shell-worker-protocol.d.ts +63 -0
  25. package/dist/types/exec/bash-shell-worker.d.ts +1 -0
  26. package/dist/types/exec/isolated-shell.d.ts +19 -0
  27. package/dist/types/extensibility/extensions/function-hooks-internal.d.ts +9 -0
  28. package/dist/types/extensibility/extensions/function-hooks.d.ts +169 -0
  29. package/dist/types/extensibility/extensions/index.d.ts +1 -0
  30. package/dist/types/extensibility/extensions/runner.d.ts +46 -6
  31. package/dist/types/extensibility/extensions/types.d.ts +16 -0
  32. package/dist/types/extensibility/extensions/wrapper.d.ts +3 -1
  33. package/dist/types/extensibility/gjc-plugins/constrained-hooks.d.ts +35 -2
  34. package/dist/types/extensibility/gjc-plugins/mcp-policy.d.ts +16 -0
  35. package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -0
  36. package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
  37. package/dist/types/extensibility/hooks/types.d.ts +4 -0
  38. package/dist/types/extensibility/runtime-skill-discovery.d.ts +5 -2
  39. package/dist/types/extensibility/shared-events.d.ts +1 -1
  40. package/dist/types/extensibility/skill-management.d.ts +9 -4
  41. package/dist/types/extensibility/skills.d.ts +2 -0
  42. package/dist/types/gjc-runtime/empty-delete-gc.d.ts +4 -1
  43. package/dist/types/gjc-runtime/linux-proc.d.ts +2 -0
  44. package/dist/types/gjc-runtime/session-state-lock.d.ts +14 -0
  45. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +41 -3
  46. package/dist/types/gjc-runtime/ultragoal-succession.d.ts +251 -0
  47. package/dist/types/modes/components/assistant-message.d.ts +1 -2
  48. package/dist/types/modes/components/bash-execution.d.ts +1 -0
  49. package/dist/types/modes/components/execution-shared.d.ts +1 -0
  50. package/dist/types/modes/components/settings-selector.d.ts +6 -1
  51. package/dist/types/modes/components/status-line/command.d.ts +35 -0
  52. package/dist/types/modes/components/status-line/index.d.ts +1 -0
  53. package/dist/types/modes/components/status-line/types.d.ts +5 -0
  54. package/dist/types/modes/components/tool-status-header.d.ts +14 -0
  55. package/dist/types/modes/controllers/event-controller.d.ts +6 -0
  56. package/dist/types/modes/interactive-mode.d.ts +3 -0
  57. package/dist/types/modes/types.d.ts +7 -0
  58. package/dist/types/runtime-mcp/client.d.ts +9 -1
  59. package/dist/types/runtime-mcp/manager.d.ts +7 -1
  60. package/dist/types/runtime-mcp/pool.d.ts +22 -3
  61. package/dist/types/runtime-mcp/transports/stdio.d.ts +0 -4
  62. package/dist/types/runtime-mcp/types.d.ts +22 -0
  63. package/dist/types/sdk/broker/ensure.d.ts +32 -0
  64. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +0 -1
  65. package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
  66. package/dist/types/sdk/broker/startup-failure.d.ts +4 -1
  67. package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -0
  68. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +12 -0
  69. package/dist/types/sdk/bus/chat-effect-journal.d.ts +17 -2
  70. package/dist/types/sdk/bus/conversation-store.d.ts +11 -0
  71. package/dist/types/sdk/bus/lifecycle-commands.d.ts +8 -8
  72. package/dist/types/sdk/bus/slack-daemon.d.ts +1 -1
  73. package/dist/types/sdk/bus/slack-live-provider.d.ts +6 -0
  74. package/dist/types/sdk/bus/slack-provider.d.ts +12 -0
  75. package/dist/types/sdk/bus/topic-registry.d.ts +8 -0
  76. package/dist/types/sdk/client/client.d.ts +6 -0
  77. package/dist/types/sdk/host/host.d.ts +3 -1
  78. package/dist/types/sdk/host/session-runtime.d.ts +26 -1
  79. package/dist/types/sdk/lifecycle/service.d.ts +1 -0
  80. package/dist/types/sdk/router/session-router.d.ts +10 -2
  81. package/dist/types/sdk/session-api-key.d.ts +26 -0
  82. package/dist/types/sdk/session.d.ts +5 -1
  83. package/dist/types/sdk/transport/serve-cli.d.ts +1 -1
  84. package/dist/types/session/agent-session.d.ts +11 -0
  85. package/dist/types/session/fallback-chain-controller.d.ts +15 -0
  86. package/dist/types/session/internal/managed-session-storage.d.ts +7 -0
  87. package/dist/types/session/messages.d.ts +1 -0
  88. package/dist/types/session/session-manager.d.ts +3 -1
  89. package/dist/types/setup/paseo/announce.d.ts +112 -0
  90. package/dist/types/setup/paseo/setup-deps.d.ts +2 -1
  91. package/dist/types/skill-state/canonical-skills.d.ts +4 -0
  92. package/dist/types/system-prompt.d.ts +10 -0
  93. package/dist/types/task/executor.d.ts +5 -1
  94. package/dist/types/tools/bash.d.ts +1 -0
  95. package/dist/types/tools/fetch.d.ts +7 -0
  96. package/dist/types/tools/github-cache.d.ts +2 -1
  97. package/dist/types/tools/index.d.ts +10 -0
  98. package/dist/types/tools/path-utils.d.ts +2 -0
  99. package/dist/types/tools/search.d.ts +6 -0
  100. package/package.json +9 -7
  101. package/scripts/capture-status-line-custom-editor-showcase.ts +151 -0
  102. package/scripts/generate-sdk-operation-inventory.ts +11 -0
  103. package/scripts/verify-status-line-custom-editor-showcase.ts +309 -0
  104. package/src/capability/index.ts +14 -2
  105. package/src/capability/types.ts +4 -0
  106. package/src/cli/args.ts +20 -9
  107. package/src/cli/customize-doctor.ts +155 -12
  108. package/src/cli/root-flags.ts +5 -3
  109. package/src/cli.ts +22 -0
  110. package/src/commands/sdk.ts +74 -31
  111. package/src/config/autorouting-tier-map.ts +1 -0
  112. package/src/config/file-lock-gc.ts +40 -2
  113. package/src/config/file-lock.ts +379 -31
  114. package/src/config/model-preset-registry.ts +120 -19
  115. package/src/config/model-profiles.ts +40 -0
  116. package/src/config/model-registry.ts +73 -23
  117. package/src/config/model-resolver.ts +48 -0
  118. package/src/config/settings-schema.ts +33 -1
  119. package/src/config/settings.ts +1 -0
  120. package/src/coordinator-mcp/question-state.ts +33 -2
  121. package/src/coordinator-mcp/server.ts +477 -101
  122. package/src/crash/sanitize.ts +1 -1
  123. package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +11 -11
  124. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +13 -2
  125. package/src/defaults/gjc/skills/ultragoal/SKILL.md +22 -0
  126. package/src/discovery/builtin.ts +52 -55
  127. package/src/discovery/claude-plugins.ts +12 -4
  128. package/src/discovery/helpers.ts +307 -34
  129. package/src/exec/bash-executor.ts +330 -50
  130. package/src/exec/bash-shell-guardian.ts +388 -0
  131. package/src/exec/bash-shell-runtime-entry.ts +5 -0
  132. package/src/exec/bash-shell-signals.ts +29 -0
  133. package/src/exec/bash-shell-supervisor-entry.ts +3 -0
  134. package/src/exec/bash-shell-supervisor.ts +359 -0
  135. package/src/exec/bash-shell-worker-entry.ts +5 -0
  136. package/src/exec/bash-shell-worker-protocol.ts +36 -0
  137. package/src/exec/bash-shell-worker.ts +116 -0
  138. package/src/exec/isolated-shell.ts +393 -0
  139. package/src/extensibility/extensions/function-hooks-internal.ts +73 -0
  140. package/src/extensibility/extensions/function-hooks.ts +959 -0
  141. package/src/extensibility/extensions/index.ts +22 -0
  142. package/src/extensibility/extensions/loader.ts +53 -2
  143. package/src/extensibility/extensions/runner.ts +895 -264
  144. package/src/extensibility/extensions/types.ts +42 -0
  145. package/src/extensibility/extensions/wrapper.ts +56 -15
  146. package/src/extensibility/gjc-plugins/compiler.ts +228 -60
  147. package/src/extensibility/gjc-plugins/constrained-hooks.ts +321 -23
  148. package/src/extensibility/gjc-plugins/installer.ts +13 -12
  149. package/src/extensibility/gjc-plugins/mcp-policy.ts +81 -52
  150. package/src/extensibility/gjc-plugins/paths.ts +13 -0
  151. package/src/extensibility/gjc-plugins/registry.ts +113 -1
  152. package/src/extensibility/gjc-plugins/runtime-adapters.ts +758 -16
  153. package/src/extensibility/gjc-plugins/runtime-quarantine.ts +20 -2
  154. package/src/extensibility/gjc-plugins/schema.ts +121 -4
  155. package/src/extensibility/gjc-plugins/types.ts +25 -0
  156. package/src/extensibility/hooks/loader.ts +133 -16
  157. package/src/extensibility/hooks/types.ts +28 -0
  158. package/src/extensibility/runtime-skill-discovery.ts +149 -190
  159. package/src/extensibility/shared-events.ts +1 -1
  160. package/src/extensibility/skill-management.ts +200 -52
  161. package/src/extensibility/skills.ts +73 -25
  162. package/src/gjc-runtime/empty-delete-gc.ts +94 -4
  163. package/src/gjc-runtime/gc-runtime.ts +29 -0
  164. package/src/gjc-runtime/linux-proc.ts +14 -0
  165. package/src/gjc-runtime/repository-binding.ts +1 -1
  166. package/src/gjc-runtime/session-state-lock.ts +143 -10
  167. package/src/gjc-runtime/session-state-sidecar.ts +27 -11
  168. package/src/gjc-runtime/state-writer.ts +30 -29
  169. package/src/gjc-runtime/ultragoal-runtime.ts +189 -47
  170. package/src/gjc-runtime/ultragoal-succession.ts +1667 -0
  171. package/src/gjc-runtime/workflow-manifest.generated.json +52 -1
  172. package/src/gjc-runtime/workflow-manifest.ts +16 -0
  173. package/src/harness-control-plane/session-lease.ts +2 -2
  174. package/src/internal-urls/docs-index.generated.ts +12 -12
  175. package/src/lsp/config.ts +10 -3
  176. package/src/lsp/index.ts +1 -1
  177. package/src/lsp/path-trust.ts +69 -14
  178. package/src/main.ts +78 -35
  179. package/src/modes/DESIGN.md +54 -0
  180. package/src/modes/acp/acp-agent.ts +16 -3
  181. package/src/modes/components/assistant-message.ts +22 -9
  182. package/src/modes/components/bash-execution.ts +4 -1
  183. package/src/modes/components/execution-shared.ts +4 -1
  184. package/src/modes/components/model-selector.ts +166 -33
  185. package/src/modes/components/session-observer-overlay.ts +1 -1
  186. package/src/modes/components/settings-selector.ts +1075 -235
  187. package/src/modes/components/status-line/command.ts +190 -0
  188. package/src/modes/components/status-line/index.ts +1 -0
  189. package/src/modes/components/status-line/segments.ts +32 -5
  190. package/src/modes/components/status-line/types.ts +5 -0
  191. package/src/modes/components/tool-status-header.ts +396 -22
  192. package/src/modes/controllers/command-controller.ts +3 -0
  193. package/src/modes/controllers/event-controller.ts +151 -7
  194. package/src/modes/controllers/extension-ui-controller.ts +1 -0
  195. package/src/modes/controllers/input-controller.ts +6 -3
  196. package/src/modes/controllers/selector-controller.ts +21 -0
  197. package/src/modes/interactive-mode.ts +53 -6
  198. package/src/modes/print-mode.ts +5 -1
  199. package/src/modes/prompt-action-autocomplete.ts +54 -27
  200. package/src/modes/runtime-init.ts +2 -0
  201. package/src/modes/types.ts +17 -3
  202. package/src/modes/utils/ui-helpers.ts +2 -0
  203. package/src/prompts/system/system-prompt.md +8 -0
  204. package/src/prompts/tools/search.md +2 -0
  205. package/src/prompts/tools/subagent.md +1 -0
  206. package/src/runtime-mcp/client.ts +60 -17
  207. package/src/runtime-mcp/manager.ts +189 -14
  208. package/src/runtime-mcp/pool.test.ts +218 -0
  209. package/src/runtime-mcp/pool.ts +224 -23
  210. package/src/runtime-mcp/transports/stdio.ts +158 -24
  211. package/src/runtime-mcp/types.ts +24 -0
  212. package/src/sdk/broker/broker.ts +124 -32
  213. package/src/sdk/broker/ensure.ts +248 -41
  214. package/src/sdk/broker/lifecycle-ledger.ts +7 -17
  215. package/src/sdk/broker/lifecycle.ts +56 -6
  216. package/src/sdk/broker/startup-failure.ts +15 -6
  217. package/src/sdk/bus/chat-daemon-control.ts +3 -1
  218. package/src/sdk/bus/chat-daemon-runtime.ts +72 -6
  219. package/src/sdk/bus/chat-effect-journal.ts +132 -7
  220. package/src/sdk/bus/conversation-store.ts +29 -0
  221. package/src/sdk/bus/lifecycle-commands.ts +17 -11
  222. package/src/sdk/bus/slack-conversation.ts +1 -0
  223. package/src/sdk/bus/slack-daemon.ts +478 -49
  224. package/src/sdk/bus/slack-live-provider.ts +12 -0
  225. package/src/sdk/bus/slack-provider.ts +5 -0
  226. package/src/sdk/bus/telegram-daemon.ts +16 -2
  227. package/src/sdk/bus/topic-registry.ts +19 -0
  228. package/src/sdk/cli/master-cli.ts +1 -1
  229. package/src/sdk/cli/session-cli.ts +11 -8
  230. package/src/sdk/client/client.ts +66 -17
  231. package/src/sdk/host/host.ts +29 -9
  232. package/src/sdk/host/query/revision-store.ts +19 -13
  233. package/src/sdk/host/reverse-leases.ts +7 -4
  234. package/src/sdk/host/session-runtime.test.ts +52 -10
  235. package/src/sdk/host/session-runtime.ts +383 -44
  236. package/src/sdk/lifecycle/service.ts +4 -0
  237. package/src/sdk/protocol/operation-inventory.generated.json +77 -0
  238. package/src/sdk/router/session-router.ts +89 -38
  239. package/src/sdk/session-api-key.ts +94 -0
  240. package/src/sdk/session.ts +228 -55
  241. package/src/sdk/transport/relay.ts +35 -13
  242. package/src/sdk/transport/serve-cli.ts +3 -3
  243. package/src/session/agent-session.ts +240 -90
  244. package/src/session/contribution-prep.ts +214 -21
  245. package/src/session/fallback-chain-controller.ts +38 -0
  246. package/src/session/internal/managed-session-storage.ts +19 -5
  247. package/src/session/messages.ts +5 -0
  248. package/src/session/session-manager.ts +29 -5
  249. package/src/session-import/redact.ts +5 -1
  250. package/src/setup/paseo/announce.ts +456 -0
  251. package/src/setup/paseo/setup-deps.ts +15 -3
  252. package/src/skill-state/canonical-skills.ts +12 -0
  253. package/src/skills/skills.test.ts +41 -21
  254. package/src/slash-commands/builtin-registry.ts +1 -0
  255. package/src/system-prompt.ts +29 -3
  256. package/src/task/executor.ts +7 -1
  257. package/src/task/index.ts +21 -4
  258. package/src/tools/bash.ts +63 -19
  259. package/src/tools/cron.ts +1 -1
  260. package/src/tools/fetch.ts +29 -1
  261. package/src/tools/gh.ts +26 -3
  262. package/src/tools/github-cache.ts +68 -16
  263. package/src/tools/index.ts +10 -0
  264. package/src/tools/job.ts +21 -4
  265. package/src/tools/monitor.ts +8 -2
  266. package/src/tools/path-utils.ts +7 -0
  267. package/src/tools/read.ts +14 -2
  268. package/src/tools/render-utils.ts +1 -3
  269. package/src/tools/resource-gc.ts +28 -8
  270. package/src/tools/search.ts +116 -58
  271. package/src/tools/skill-discovery.ts +5 -0
  272. package/src/tools/skill.ts +37 -2
  273. package/src/tools/subagent.ts +6 -3
  274. package/src/tools/tool-catalog.generated.ts +10 -3
  275. package/src/tools/ultragoal-ask-guard.ts +2 -2
  276. package/src/web/insane/bridge.ts +113 -23
  277. package/src/web/search/providers/perplexity.ts +102 -41
@@ -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,211 @@ 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 preserves late cleanup failure until pool retry clears every manager fence", 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 ToolListTransport());
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
+ const abortedAcquire = await operation.catch(error => error);
496
+ expect(abortedAcquire).toBeInstanceOf(MCPPoolAcquireAbortError);
497
+ if (!(abortedAcquire instanceof MCPPoolAcquireAbortError)) throw new Error("Expected aborted acquisition");
498
+ expect(manager.pendingConnectionCleanupCountForTests).toBe(1);
499
+
500
+ await manager.disconnectServer("server");
501
+ const fenced = await manager.connectServers({ server: serverConfig }, {});
502
+ expect(fenced.errors.has("server")).toBe(true);
503
+ expect(opens).toBe(1);
504
+ const primaryFailure = new Error("late connection cleanup failed");
505
+ firstFlight.reject(new MCPConnectionCleanupFailure(primaryFailure, cleanupTransport));
506
+ await expect(abortedAcquire.cleanup).rejects.toBeInstanceOf(MCPConnectionCleanupFailure);
507
+ const disconnectError = await manager.disconnectServer("server").catch(error => error);
508
+ expect(disconnectError).toBeInstanceOf(MCPConnectionCleanupFailure);
509
+ if (!(disconnectError instanceof MCPConnectionCleanupFailure)) {
510
+ throw new Error("Expected retained disconnect cleanup failure");
511
+ }
512
+ expect(disconnectError.cause).toBeInstanceOf(AggregateError);
513
+ if (!(disconnectError.cause instanceof AggregateError)) throw new Error("Expected combined cleanup causes");
514
+ expect(disconnectError.cause.errors).toEqual([primaryFailure, retryFailure]);
515
+ expect(cleanupAttempts).toBe(1);
516
+ expect(manager.pendingConnectionCleanupCountForTests).toBe(1);
517
+ expect(pool.pendingAcquireCleanupCountForTests).toBe(1);
518
+
519
+ const recoveryManager = new MCPManager(".", null, { pool, sessionId: "disconnect-cleanup" });
520
+ await recoveryManager.withPreparedLease("server", serverConfig, async lease => {
521
+ expect(lease.serverName).toBe("server");
522
+ });
523
+ expect(cleanupAttempts).toBe(2);
524
+ expect(pool.pendingAcquireCleanupCountForTests).toBe(0);
525
+ expect(manager.pendingConnectionCleanupCountForTests).toBe(1);
526
+
527
+ const recovered = await manager.connectServers({ server: serverConfig }, {});
528
+ expect(recovered.errors.size).toBe(0);
529
+ expect(recovered.connectedServers).toEqual(["server"]);
530
+ expect(manager.pendingConnectionCleanupCountForTests).toBe(0);
531
+ expect(opens).toBe(3);
532
+ await recoveryManager.disconnectAll();
533
+ await manager.disconnectAll();
534
+ });
535
+
536
+ test("manager reconnect retries pool-owned cleanup before replacing the live connection", async () => {
537
+ const transientFlight = Promise.withResolvers<MCPServerConnection>();
538
+ const transientStarted = Promise.withResolvers<void>();
539
+ const cleanupTransport = new FakeTransport();
540
+ const cleanupRetryFailure = new Error("reconnect cleanup retry failed");
541
+ let cleanupAttempts = 0;
542
+ cleanupTransport.close = async () => {
543
+ cleanupAttempts += 1;
544
+ if (cleanupAttempts === 1) throw cleanupRetryFailure;
545
+ cleanupTransport.connected = false;
546
+ };
547
+ let opens = 0;
548
+ const transports: ToolListTransport[] = [];
549
+ const pool = new MCPConnectionPool({
550
+ connect: async (name, cfg) => {
551
+ opens += 1;
552
+ if (opens === 2) {
553
+ transientStarted.resolve();
554
+ return transientFlight.promise;
555
+ }
556
+ const transport = new ToolListTransport();
557
+ transports.push(transport);
558
+ return connection(name, cfg, transport);
559
+ },
560
+ });
561
+ const manager = new MCPManager(".", null, { pool, sessionId: "reconnect-cleanup", sleep: async () => {} });
562
+ const serverConfig = config();
563
+ await manager.connectServers({ server: serverConfig }, {});
564
+ const original = manager.getConnection("server");
565
+ const controller = new AbortController();
566
+ const transient = manager.withPreparedLease(
567
+ "server",
568
+ { type: "stdio", command: "fake-mcp", args: ["--transient"] },
569
+ async () => undefined,
570
+ { signal: controller.signal },
571
+ );
572
+ await transientStarted.promise;
573
+ controller.abort(new Error("abort transient before reconnect"));
574
+ await expect(transient).rejects.toBeInstanceOf(MCPPoolAcquireAbortError);
575
+
576
+ const firstReconnect = manager.reconnectServer("server");
577
+ transientFlight.reject(
578
+ new MCPConnectionCleanupFailure(new Error("late reconnect cleanup failed"), cleanupTransport),
579
+ );
580
+ await expect(firstReconnect).rejects.toBeInstanceOf(MCPConnectionCleanupFailure);
581
+ expect(cleanupAttempts).toBe(1);
582
+ expect(opens).toBe(2);
583
+ expect(manager.getConnection("server")).toBe(original);
584
+ const replacement = await manager.reconnectServer("server");
585
+ expect(cleanupAttempts).toBe(2);
586
+ expect(opens).toBe(3);
587
+ expect(replacement).toBeDefined();
588
+ expect(replacement).not.toBe(original);
589
+ expect(manager.pendingConnectionCleanupCountForTests).toBe(0);
590
+ await manager.disconnectAll();
591
+ expect(transports.every(transport => transport.closeCount === 1)).toBe(true);
592
+ });
593
+
376
594
  test("shutdown aborts hanging opens, settles waiters, and closes late transports", async () => {
377
595
  let resolveOpen: ((value: MCPServerConnection) => void) | undefined;
378
596
  let openSignal: AbortSignal | undefined;
@@ -5,7 +5,7 @@
5
5
  * multiple leases and roots/resource subscriptions are connection-global unions.
6
6
  */
7
7
  import { logger } from "@gajae-code/utils";
8
- import { connectToServer, subscribeToResources, unsubscribeFromResources } from "./client";
8
+ import { connectToServer, MCPConnectionCleanupFailure, subscribeToResources, unsubscribeFromResources } from "./client";
9
9
  import {
10
10
  buildMCPPoolKeyIdentity,
11
11
  computeMCPPoolKey,
@@ -60,9 +60,16 @@ export class MCPPoolAcquireAbortError extends Error {
60
60
  readonly code = "MCP_POOL_ACQUIRE_ABORTED" as const;
61
61
  readonly serverName: string;
62
62
  readonly poolKey: string;
63
- cleanup?: Promise<unknown>;
64
-
65
- constructor(serverName: string, poolKey: string, cause: unknown, cleanup?: Promise<unknown>) {
63
+ readonly cleanup?: Promise<void>;
64
+ readonly cleanupGeneration?: number;
65
+
66
+ constructor(
67
+ serverName: string,
68
+ poolKey: string,
69
+ cause: unknown,
70
+ cleanup?: Promise<void>,
71
+ cleanupGeneration?: number,
72
+ ) {
66
73
  super(
67
74
  `MCP connection acquisition aborted for ${serverName} (${poolKey}): ${
68
75
  cause instanceof Error ? cause.message : String(cause)
@@ -73,6 +80,7 @@ export class MCPPoolAcquireAbortError extends Error {
73
80
  this.serverName = serverName;
74
81
  this.poolKey = poolKey;
75
82
  this.cleanup = cleanup;
83
+ this.cleanupGeneration = cleanupGeneration;
76
84
  }
77
85
  }
78
86
 
@@ -121,6 +129,8 @@ export interface MCPPoolAcquireOptions extends MCPPoolKeyOptions {
121
129
  advertiseRoots?: boolean;
122
130
  onNotification?: (method: string, params: unknown) => void;
123
131
  onRequest?: (method: string, params: unknown) => Promise<unknown>;
132
+ /** @internal Synchronous handoff for a pool-owned abandoned-acquire cleanup flight. */
133
+ onCleanupPending?: (error: MCPPoolAcquireAbortError) => void;
124
134
  }
125
135
 
126
136
  export interface MCPConnectionPoolOptions {
@@ -172,6 +182,7 @@ type PendingWaiter = {
172
182
  settled: boolean;
173
183
  claimed: boolean;
174
184
  removeAbortListener?: () => void;
185
+ onCleanupPending?: (error: MCPPoolAcquireAbortError) => void;
175
186
  };
176
187
 
177
188
  type PendingEntry = {
@@ -186,7 +197,17 @@ type PendingEntry = {
186
197
  cancellationReason?: unknown;
187
198
  openAbortController: AbortController;
188
199
  openSignal?: AbortSignal;
189
- settlement?: Promise<unknown>;
200
+ settlement?: Promise<void>;
201
+ };
202
+
203
+ type RetiredAcquisitionCleanup = {
204
+ key: string;
205
+ serverName: string;
206
+ generation: number;
207
+ completion: Promise<void>;
208
+ error: MCPPoolAcquireAbortError;
209
+ failure?: MCPConnectionCleanupFailure;
210
+ retry?: Promise<void>;
190
211
  };
191
212
 
192
213
  function errorMessage(error: unknown): string {
@@ -386,6 +407,7 @@ export class MCPConnectionPool {
386
407
  readonly #pending = new Map<string, PendingEntry>();
387
408
  readonly #allLeases = new Set<MCPPoolLeaseImpl>();
388
409
  readonly #retiredEntries = new Set<PoolEntry>();
410
+ readonly #retiredAcquisitionCleanups = new Map<string, RetiredAcquisitionCleanup>();
389
411
  readonly #restartOwners = new Set<string>();
390
412
  readonly #restartWaiters = new Map<string, { promise: Promise<boolean>; resolve: (value: boolean) => void }>();
391
413
  readonly #healthListeners = new Set<HealthListener>();
@@ -458,22 +480,160 @@ export class MCPConnectionPool {
458
480
  this.#restartWaiters.delete(key);
459
481
  }
460
482
 
483
+ private retainCancelledAcquireCleanup(
484
+ key: string,
485
+ name: string,
486
+ pending: PendingEntry,
487
+ cause: unknown,
488
+ ): RetiredAcquisitionCleanup {
489
+ const current = this.#retiredAcquisitionCleanups.get(key);
490
+ if (current) return current;
491
+ const completion = pending.settlement;
492
+ const generation = pending.entry?.generation;
493
+ if (!completion || generation === undefined) {
494
+ throw new Error(`MCP cancelled acquisition cleanup is missing its flight identity: ${key}`);
495
+ }
496
+ const error = new MCPPoolAcquireAbortError(name, key, cause, completion, generation);
497
+ const owner: RetiredAcquisitionCleanup = { key, serverName: name, generation, completion, error };
498
+ this.#retiredAcquisitionCleanups.set(key, owner);
499
+ void completion.then(
500
+ () => {
501
+ if (this.#retiredAcquisitionCleanups.get(key) === owner && !owner.retry) {
502
+ this.#retiredAcquisitionCleanups.delete(key);
503
+ }
504
+ },
505
+ error => {
506
+ if (error instanceof MCPConnectionCleanupFailure) {
507
+ owner.failure = error;
508
+ return;
509
+ }
510
+ if (this.#retiredAcquisitionCleanups.get(key) === owner && !owner.retry) {
511
+ this.#retiredAcquisitionCleanups.delete(key);
512
+ }
513
+ },
514
+ );
515
+ return owner;
516
+ }
517
+
518
+ private notifyPendingAcquireCleanup(
519
+ listener: MCPPoolAcquireOptions["onCleanupPending"],
520
+ owner: RetiredAcquisitionCleanup,
521
+ ): void {
522
+ try {
523
+ listener?.(owner.error);
524
+ } catch (error) {
525
+ logger.debug("MCP cancelled acquire cleanup handoff failed", {
526
+ path: `mcp:${owner.serverName}`,
527
+ poolKey: owner.key,
528
+ error,
529
+ });
530
+ }
531
+ }
532
+
533
+ /** @internal Whether this pool still owns the notified abandoned-acquire flight. */
534
+ hasPendingAcquireCleanup(error: MCPPoolAcquireAbortError): boolean {
535
+ const owner = this.#retiredAcquisitionCleanups.get(error.poolKey);
536
+ return owner?.generation === error.cleanupGeneration;
537
+ }
538
+
539
+ /** @internal Current retained failure for one notified abandoned-acquire generation. */
540
+ getPendingAcquireCleanupFailure(error: MCPPoolAcquireAbortError): MCPConnectionCleanupFailure | undefined {
541
+ const owner = this.#retiredAcquisitionCleanups.get(error.poolKey);
542
+ return owner && owner.generation === error.cleanupGeneration ? owner.failure : undefined;
543
+ }
544
+
545
+ /** @internal Prevent manager-level duplication of the pool's retained cleanup owner. */
546
+ ownsPendingAcquireCleanupFailure(error: unknown): boolean {
547
+ if (!(error instanceof MCPConnectionCleanupFailure)) return false;
548
+ for (const owner of this.#retiredAcquisitionCleanups.values()) {
549
+ if (owner.failure === error) return true;
550
+ }
551
+ return false;
552
+ }
553
+
554
+ /** Read-only test seam for abandoned-acquire cleanup owners. */
555
+ get pendingAcquireCleanupCountForTests(): number {
556
+ return this.#retiredAcquisitionCleanups.size;
557
+ }
558
+
559
+ /** Read-only test seam for the current retained abandoned-acquire cleanup failure. */
560
+ getPendingAcquireCleanupFailureForTests(name: string): MCPConnectionCleanupFailure | undefined {
561
+ for (const owner of this.#retiredAcquisitionCleanups.values()) {
562
+ if (owner.serverName === name && owner.failure) return owner.failure;
563
+ }
564
+ return undefined;
565
+ }
566
+
567
+ /** @internal Join and retry one generation of abandoned acquisition cleanup. */
568
+ async closePendingAcquireCleanup(key: string, generation: number): Promise<void> {
569
+ const owner = this.#retiredAcquisitionCleanups.get(key);
570
+ if (!owner || owner.generation !== generation) return;
571
+ if (owner.retry) return owner.retry;
572
+ let tracked!: Promise<void>;
573
+ tracked = (async () => {
574
+ let failure = owner.failure;
575
+ if (!failure) {
576
+ try {
577
+ await owner.completion;
578
+ } catch (error) {
579
+ if (!(error instanceof MCPConnectionCleanupFailure)) throw error;
580
+ failure = owner.failure ?? error;
581
+ }
582
+ }
583
+ if (failure) {
584
+ try {
585
+ await failure.close();
586
+ } catch (error) {
587
+ if (this.#retiredAcquisitionCleanups.get(key) === owner) {
588
+ owner.failure = error instanceof MCPConnectionCleanupFailure ? error : failure;
589
+ }
590
+ throw error;
591
+ }
592
+ }
593
+ if (this.#retiredAcquisitionCleanups.get(key) === owner) this.#retiredAcquisitionCleanups.delete(key);
594
+ })().finally(() => {
595
+ if (this.#retiredAcquisitionCleanups.get(key) === owner && owner.retry === tracked) owner.retry = undefined;
596
+ });
597
+ owner.retry = tracked;
598
+ return tracked;
599
+ }
600
+
461
601
  async acquire(name: string, config: MCPServerConfig, options: MCPPoolAcquireOptions = {}): Promise<MCPPoolLease> {
462
602
  if (this.#shuttingDown) throw new Error("MCP connection pool is shut down");
463
603
  const keyOptions: MCPPoolKeyOptions = options;
464
604
  const key = computeMCPPoolKey(name, config, keyOptions);
605
+ if (options.signal?.aborted) {
606
+ throw new MCPPoolAcquireAbortError(
607
+ name,
608
+ key,
609
+ options.signal.reason ?? new Error(`MCP connection acquisition aborted: ${name}`),
610
+ );
611
+ }
612
+ for (;;) {
613
+ const cleanup = this.#retiredAcquisitionCleanups.get(key);
614
+ if (!cleanup) break;
615
+ this.notifyPendingAcquireCleanup(options.onCleanupPending, cleanup);
616
+ await this.closePendingAcquireCleanup(cleanup.key, cleanup.generation);
617
+ }
618
+ if (options.signal?.aborted) {
619
+ throw new MCPPoolAcquireAbortError(
620
+ name,
621
+ key,
622
+ options.signal.reason ?? new Error(`MCP connection acquisition aborted: ${name}`),
623
+ );
624
+ }
465
625
 
466
626
  let entry = this.#entries.get(key);
467
627
  let claim: () => void = () => {};
468
628
  if (entry?.pending) {
469
- const acquisition = await this.waitForPendingEntry(key, name, entry.pending, options.signal);
629
+ const acquisition = await this.waitForPendingEntry(key, name, entry.pending, options);
470
630
  entry = acquisition.entry;
471
631
  claim = acquisition.claim;
472
632
  }
473
633
  if (!entry) {
474
634
  let pending = this.#pending.get(key);
475
635
  if (!pending) pending = this.startPendingEntry(key, name, config, options);
476
- const acquisition = await this.waitForPendingEntry(key, name, pending, options.signal);
636
+ const acquisition = await this.waitForPendingEntry(key, name, pending, options);
477
637
  entry = acquisition.entry;
478
638
  claim = acquisition.claim;
479
639
  }
@@ -502,10 +662,17 @@ export class MCPConnectionPool {
502
662
  key: string,
503
663
  name: string,
504
664
  pending: PendingEntry,
505
- signal?: AbortSignal,
665
+ options: Pick<MCPPoolAcquireOptions, "signal" | "onCleanupPending">,
506
666
  ): Promise<PendingAcquisition> {
507
667
  const { promise, resolve, reject } = Promise.withResolvers<PendingAcquisition>();
508
- const waiter: PendingWaiter = { resolve, reject, settled: false, claimed: false };
668
+ const signal = options.signal;
669
+ const waiter: PendingWaiter = {
670
+ resolve,
671
+ reject,
672
+ settled: false,
673
+ claimed: false,
674
+ onCleanupPending: options.onCleanupPending,
675
+ };
509
676
  pending.waiters.add(waiter);
510
677
  const settle = (fn: () => void): void => {
511
678
  if (waiter.settled) return;
@@ -515,14 +682,21 @@ export class MCPConnectionPool {
515
682
  fn();
516
683
  };
517
684
  const abortWaiter = (): void => {
518
- const reason = new MCPPoolAcquireAbortError(
519
- name,
520
- key,
521
- signal?.reason ?? new Error(`MCP connection acquisition aborted: ${name}`),
522
- pending.settlement,
523
- );
524
- settle(() => reject(reason));
525
- if (pending.waiters.size === 0 && !pending.settled) this.cancelPendingEntry(key, pending, reason);
685
+ if (waiter.settled) return;
686
+ waiter.settled = true;
687
+ pending.waiters.delete(waiter);
688
+ waiter.removeAbortListener?.();
689
+ const cause = signal?.reason ?? new Error(`MCP connection acquisition aborted: ${name}`);
690
+ let reason: MCPPoolAcquireAbortError;
691
+ if (pending.waiters.size === 0 && !pending.settled) {
692
+ const cleanup = this.retainCancelledAcquireCleanup(key, name, pending, cause);
693
+ reason = cleanup.error;
694
+ this.cancelPendingEntry(key, pending, reason);
695
+ this.notifyPendingAcquireCleanup(waiter.onCleanupPending, cleanup);
696
+ } else {
697
+ reason = new MCPPoolAcquireAbortError(name, key, cause);
698
+ }
699
+ reject(reason);
526
700
  };
527
701
  if (signal) {
528
702
  if (signal.aborted) abortWaiter();
@@ -618,12 +792,22 @@ export class MCPConnectionPool {
618
792
  try {
619
793
  await this.closeEntry(entry);
620
794
  } catch (error) {
621
- logger.error("MCP cancelled acquire cleanup failed", { path: `mcp:${name}`, poolKey: key, error });
795
+ throw new MCPConnectionCleanupFailure(
796
+ new AggregateError(
797
+ [
798
+ pending.cancellationReason ?? new Error(`MCP connection acquisition abandoned: ${name}`),
799
+ error,
800
+ ],
801
+ `MCP cancelled acquisition cleanup failed: ${name}`,
802
+ ),
803
+ entry.connection.transport,
804
+ );
622
805
  }
623
806
  },
624
807
  error => {
625
808
  pending.settled = true;
626
809
  pending.reject(error);
810
+ if (pending.cancelled && error instanceof MCPConnectionCleanupFailure) throw error;
627
811
  },
628
812
  )
629
813
  .finally(() => {
@@ -678,11 +862,16 @@ export class MCPConnectionPool {
678
862
  try {
679
863
  await connection.transport.close();
680
864
  } catch (closeError) {
681
- logger.error("MCP late transport close failed", {
682
- path: `mcp:${name}`,
683
- poolKey: key,
684
- error: closeError,
685
- });
865
+ throw new MCPConnectionCleanupFailure(
866
+ new AggregateError(
867
+ [
868
+ pending.cancellationReason ?? new Error(`MCP connection acquisition abandoned: ${name}`),
869
+ closeError,
870
+ ],
871
+ `MCP cancelled acquisition cleanup failed: ${name}`,
872
+ ),
873
+ connection.transport,
874
+ );
686
875
  }
687
876
  throw pending.cancellationReason ?? new Error(`MCP connection acquisition abandoned: ${name}`);
688
877
  }
@@ -938,6 +1127,7 @@ export class MCPConnectionPool {
938
1127
  this.#shutdownController.abort(reason);
939
1128
  for (const [key, pending] of this.#pending) {
940
1129
  if (!pending.cancelled) {
1130
+ this.retainCancelledAcquireCleanup(key, pending.entry?.name ?? "unknown", pending, reason);
941
1131
  pending.cancelled = true;
942
1132
  pending.cancellationReason = reason;
943
1133
  pending.openAbortController.abort(reason);
@@ -951,6 +1141,17 @@ export class MCPConnectionPool {
951
1141
  pending.waiters.clear();
952
1142
  if (this.#pending.get(key) === pending) this.#pending.delete(key);
953
1143
  }
1144
+ for (const cleanup of this.#retiredAcquisitionCleanups.values()) {
1145
+ const failure = this.getPendingAcquireCleanupFailure(cleanup.error);
1146
+ if (!failure) continue;
1147
+ void this.closePendingAcquireCleanup(cleanup.key, cleanup.generation).catch(error =>
1148
+ logger.error("MCP pool shutdown pending acquire cleanup failed", {
1149
+ serverName: cleanup.serverName,
1150
+ poolKey: cleanup.key,
1151
+ error,
1152
+ }),
1153
+ );
1154
+ }
954
1155
  for (const lease of this.#allLeases) lease.invalidate(reason);
955
1156
  this.#allLeases.clear();
956
1157
  const entries = [...new Set([...this.#entries.values(), ...this.#retiredEntries])];